Merge pull request #11 from langflow-ai/feat-googledrive-enhancements
feat: Google Drive File Picker and General Enhancements
This commit is contained in:
commit
ca2786e3dd
20 changed files with 2691 additions and 1042 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@ wheels/
|
|||
|
||||
1001*.pdf
|
||||
*.json
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ services:
|
|||
#context: .
|
||||
#dockerfile: Dockerfile
|
||||
container_name: os
|
||||
depends_on:
|
||||
- openrag-backend
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD}
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD:-admin123}
|
||||
# Run security setup in background after OpenSearch starts
|
||||
command: >
|
||||
bash -c "
|
||||
|
|
@ -34,7 +32,7 @@ services:
|
|||
environment:
|
||||
OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
|
||||
OPENSEARCH_USERNAME: "admin"
|
||||
OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD}
|
||||
OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD:-admin123}
|
||||
ports:
|
||||
- "5601:5601"
|
||||
|
||||
|
|
@ -45,17 +43,18 @@ services:
|
|||
#dockerfile: Dockerfile.backend
|
||||
container_name: openrag-backend
|
||||
depends_on:
|
||||
- opensearch
|
||||
- langflow
|
||||
environment:
|
||||
- OPENSEARCH_HOST=opensearch
|
||||
- LANGFLOW_URL=http://langflow:7860
|
||||
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
|
||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER:-admin}
|
||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD:-admin123}
|
||||
- FLOW_ID=${FLOW_ID}
|
||||
- OPENSEARCH_PORT=9200
|
||||
- OPENSEARCH_USERNAME=admin
|
||||
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
|
||||
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD:-admin123}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
|
|
@ -69,6 +68,8 @@ services:
|
|||
volumes:
|
||||
- ./documents:/app/documents:Z
|
||||
- ./keys:/app/keys:Z
|
||||
ports:
|
||||
- "8000:8000"
|
||||
gpus: all
|
||||
|
||||
openrag-frontend:
|
||||
|
|
@ -87,7 +88,7 @@ services:
|
|||
langflow:
|
||||
volumes:
|
||||
- ./flows:/app/flows:Z
|
||||
image: phact/langflow:responses
|
||||
image: langflowai/langflow:latest
|
||||
container_name: langflow
|
||||
ports:
|
||||
- "7860:7860"
|
||||
|
|
@ -99,8 +100,8 @@ services:
|
|||
- OPENRAG-QUERY-FILTER="{}"
|
||||
- LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER
|
||||
- LANGFLOW_LOG_LEVEL=DEBUG
|
||||
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
|
||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
||||
- LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
|
||||
- LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}
|
||||
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN:-true}
|
||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER:-admin}
|
||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD:-admin123}
|
||||
- LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE:-true}
|
||||
- LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI:-true}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronDown, Upload, FolderOpen, Cloud, PlugZap, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
|
@ -9,6 +8,7 @@ import { Input } from "@/components/ui/input"
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface KnowledgeDropdownProps {
|
||||
active?: boolean
|
||||
|
|
@ -16,8 +16,8 @@ interface KnowledgeDropdownProps {
|
|||
}
|
||||
|
||||
export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeDropdownProps) {
|
||||
const router = useRouter()
|
||||
const { addTask } = useTask()
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
||||
const [showS3Dialog, setShowS3Dialog] = useState(false)
|
||||
|
|
@ -27,23 +27,76 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
|||
const [folderLoading, setFolderLoading] = useState(false)
|
||||
const [s3Loading, setS3Loading] = useState(false)
|
||||
const [fileUploading, setFileUploading] = useState(false)
|
||||
const [cloudConnectors, setCloudConnectors] = useState<{[key: string]: {name: string, available: boolean, connected: boolean, hasToken: boolean}}>({})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Check AWS availability on mount
|
||||
// Check AWS availability and cloud connectors on mount
|
||||
useEffect(() => {
|
||||
const checkAws = async () => {
|
||||
const checkAvailability = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/upload_options")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAwsEnabled(Boolean(data.aws))
|
||||
// Check AWS
|
||||
const awsRes = await fetch("/api/upload_options")
|
||||
if (awsRes.ok) {
|
||||
const awsData = await awsRes.json()
|
||||
setAwsEnabled(Boolean(awsData.aws))
|
||||
}
|
||||
|
||||
// Check cloud connectors
|
||||
const connectorsRes = await fetch('/api/connectors')
|
||||
if (connectorsRes.ok) {
|
||||
const connectorsResult = await connectorsRes.json()
|
||||
const cloudConnectorTypes = ['google_drive', 'onedrive', 'sharepoint']
|
||||
const connectorInfo: {[key: string]: {name: string, available: boolean, connected: boolean, hasToken: boolean}} = {}
|
||||
|
||||
for (const type of cloudConnectorTypes) {
|
||||
if (connectorsResult.connectors[type]) {
|
||||
connectorInfo[type] = {
|
||||
name: connectorsResult.connectors[type].name,
|
||||
available: connectorsResult.connectors[type].available,
|
||||
connected: false,
|
||||
hasToken: false
|
||||
}
|
||||
|
||||
// Check connection status
|
||||
try {
|
||||
const statusRes = await fetch(`/api/connectors/${type}/status`)
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json()
|
||||
const connections = statusData.connections || []
|
||||
const activeConnection = connections.find((conn: {is_active: boolean, connection_id: string}) => conn.is_active)
|
||||
const isConnected = activeConnection !== undefined
|
||||
|
||||
if (isConnected && activeConnection) {
|
||||
connectorInfo[type].connected = true
|
||||
|
||||
// Check token availability
|
||||
try {
|
||||
const tokenRes = await fetch(`/api/connectors/${type}/token?connection_id=${activeConnection.connection_id}`)
|
||||
if (tokenRes.ok) {
|
||||
const tokenData = await tokenRes.json()
|
||||
if (tokenData.access_token) {
|
||||
connectorInfo[type].hasToken = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token check failed
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Status check failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCloudConnectors(connectorInfo)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check AWS availability", err)
|
||||
console.error("Failed to check availability", err)
|
||||
}
|
||||
}
|
||||
checkAws()
|
||||
checkAvailability()
|
||||
}, [])
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
|
|
@ -194,6 +247,25 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
|||
}
|
||||
}
|
||||
|
||||
const cloudConnectorItems = Object.entries(cloudConnectors)
|
||||
.filter(([, info]) => info.available)
|
||||
.map(([type, info]) => ({
|
||||
label: info.name,
|
||||
icon: PlugZap,
|
||||
onClick: () => {
|
||||
setIsOpen(false)
|
||||
if (info.connected && info.hasToken) {
|
||||
router.push(`/upload/${type}`)
|
||||
} else {
|
||||
router.push('/settings')
|
||||
}
|
||||
},
|
||||
disabled: !info.connected || !info.hasToken,
|
||||
tooltip: !info.connected ? `Connect ${info.name} in Settings first` :
|
||||
!info.hasToken ? `Reconnect ${info.name} - access token required` :
|
||||
undefined
|
||||
}))
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: "Add File",
|
||||
|
|
@ -216,14 +288,7 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
|||
setShowS3Dialog(true)
|
||||
}
|
||||
}] : []),
|
||||
{
|
||||
label: "Cloud Connectors",
|
||||
icon: PlugZap,
|
||||
onClick: () => {
|
||||
setIsOpen(false)
|
||||
router.push("/settings")
|
||||
}
|
||||
}
|
||||
...cloudConnectorItems
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -265,7 +330,12 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
|||
<button
|
||||
key={index}
|
||||
onClick={item.onClick}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
disabled={'disabled' in item ? item.disabled : false}
|
||||
title={'tooltip' in item ? item.tooltip : undefined}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
|
@ -364,6 +434,7 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
|
|
@ -5402,18 +5402,6 @@
|
|||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -1,495 +1,128 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, Download, AlertCircle } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import React, { useState } from "react";
|
||||
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
|
||||
interface Connector {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
status: "not_connected" | "connecting" | "connected" | "error"
|
||||
type: string
|
||||
connectionId?: string // Store the active connection ID for syncing
|
||||
access_token?: string // For connectors that use OAuth
|
||||
interface GoogleDriveFile {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
webViewLink?: string;
|
||||
iconLink?: string;
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
processed?: number;
|
||||
added?: number;
|
||||
skipped?: number;
|
||||
errors?: number;
|
||||
error?: string;
|
||||
message?: string; // For sync started messages
|
||||
isStarted?: boolean; // For sync started state
|
||||
}
|
||||
export default function ConnectorsPage() {
|
||||
const { addTask } = useTask()
|
||||
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[]>([]);
|
||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||
const [syncResult, setSyncResult] = useState<any>(null);
|
||||
|
||||
interface Connection {
|
||||
connection_id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
last_sync?: string
|
||||
}
|
||||
const handleFileSelection = (files: GoogleDriveFile[]) => {
|
||||
setSelectedFiles(files);
|
||||
};
|
||||
|
||||
function ConnectorsPage() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { addTask, refreshTasks } = useTask()
|
||||
const searchParams = useSearchParams()
|
||||
const [connectors, setConnectors] = useState<Connector[]>([])
|
||||
|
||||
const [isConnecting, setIsConnecting] = useState<string | null>(null)
|
||||
const [isSyncing, setIsSyncing] = useState<string | null>(null)
|
||||
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
|
||||
const [maxFiles, setMaxFiles] = useState<number>(10)
|
||||
|
||||
// Helper function to get connector icon
|
||||
const getConnectorIcon = (iconName: string) => {
|
||||
const iconMap: { [key: string]: React.ReactElement } = {
|
||||
'google-drive': <div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center text-white font-bold">G</div>,
|
||||
'sharepoint': <div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold">SP</div>,
|
||||
'onedrive': <div className="w-8 h-8 bg-blue-400 rounded flex items-center justify-center text-white font-bold">OD</div>,
|
||||
}
|
||||
return iconMap[iconName] || <div className="w-8 h-8 bg-gray-500 rounded flex items-center justify-center text-white font-bold">?</div>
|
||||
}
|
||||
|
||||
// Function definitions first
|
||||
const checkConnectorStatuses = useCallback(async () => {
|
||||
try {
|
||||
// Fetch available connectors from backend
|
||||
const connectorsResponse = await fetch('/api/connectors')
|
||||
if (!connectorsResponse.ok) {
|
||||
throw new Error('Failed to load connectors')
|
||||
}
|
||||
|
||||
const connectorsResult = await connectorsResponse.json()
|
||||
const connectorTypes = Object.keys(connectorsResult.connectors)
|
||||
|
||||
// Initialize connectors list with metadata from backend
|
||||
const initialConnectors = connectorTypes
|
||||
.filter(type => connectorsResult.connectors[type].available) // Only show available connectors
|
||||
.map(type => ({
|
||||
id: type,
|
||||
name: connectorsResult.connectors[type].name,
|
||||
description: connectorsResult.connectors[type].description,
|
||||
icon: getConnectorIcon(connectorsResult.connectors[type].icon),
|
||||
status: "not_connected" as const,
|
||||
type: type
|
||||
}))
|
||||
|
||||
setConnectors(initialConnectors)
|
||||
|
||||
// Check status for each connector type
|
||||
|
||||
for (const connectorType of connectorTypes) {
|
||||
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) => conn.is_active)
|
||||
const isConnected = activeConnection !== undefined
|
||||
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.type === connectorType
|
||||
? {
|
||||
...c,
|
||||
status: isConnected ? "connected" : "not_connected",
|
||||
connectionId: activeConnection?.connection_id
|
||||
}
|
||||
: c
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check connector statuses:', error)
|
||||
}
|
||||
}, [setConnectors])
|
||||
|
||||
const handleConnect = async (connector: Connector) => {
|
||||
setIsConnecting(connector.id)
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.id === connector.id ? { ...c, status: "connecting" } : c
|
||||
))
|
||||
const handleSync = async (connector: { connectionId: string, type: string }) => {
|
||||
if (!connector.connectionId || selectedFiles.length === 0) return
|
||||
|
||||
setIsSyncing(true)
|
||||
setSyncResult(null)
|
||||
|
||||
try {
|
||||
// Use the shared auth callback URL, not a separate connectors callback
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
|
||||
const response = await fetch('/api/auth/init', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
connector_type: connector.type,
|
||||
purpose: "data_source",
|
||||
name: `${connector.name} Connection`,
|
||||
redirect_uri: redirectUri
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Store connector ID for callback
|
||||
localStorage.setItem('connecting_connector_id', result.connection_id)
|
||||
localStorage.setItem('connecting_connector_type', connector.type)
|
||||
|
||||
// Handle client-side OAuth with Google's library
|
||||
if (result.oauth_config) {
|
||||
// Use the redirect URI provided by the backend
|
||||
const authUrl = `${result.oauth_config.authorization_endpoint}?` +
|
||||
`client_id=${result.oauth_config.client_id}&` +
|
||||
`response_type=code&` +
|
||||
`scope=${result.oauth_config.scopes.join(' ')}&` +
|
||||
`redirect_uri=${encodeURIComponent(result.oauth_config.redirect_uri)}&` +
|
||||
`access_type=offline&` +
|
||||
`prompt=consent&` +
|
||||
`state=${result.connection_id}`
|
||||
|
||||
window.location.href = authUrl
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to initialize OAuth')
|
||||
const syncBody: {
|
||||
connection_id: string;
|
||||
max_files?: number;
|
||||
selected_files?: string[];
|
||||
} = {
|
||||
connection_id: connector.connectionId,
|
||||
selected_files: selectedFiles.map(file => file.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth initialization failed:', error)
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.id === connector.id ? { ...c, status: "error" } : c
|
||||
))
|
||||
} finally {
|
||||
setIsConnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async (connector: Connector) => {
|
||||
if (!connector.connectionId) {
|
||||
console.error('No connection ID available for connector')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSyncing(connector.id)
|
||||
setSyncResults(prev => ({ ...prev, [connector.id]: null })) // Clear any existing progress
|
||||
|
||||
try {
|
||||
|
||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
max_files: maxFiles
|
||||
}),
|
||||
body: JSON.stringify(syncBody),
|
||||
})
|
||||
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.status === 201 && result.task_id) {
|
||||
// Task-based sync, use centralized tracking
|
||||
addTask(result.task_id)
|
||||
console.log(`Sync task ${result.task_id} added to central tracking for connector ${connector.id}`)
|
||||
|
||||
// Immediately refresh task notifications to show the new task
|
||||
await refreshTasks()
|
||||
|
||||
// Show sync started message
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
message: "Check task notification panel for progress",
|
||||
isStarted: true
|
||||
}
|
||||
}))
|
||||
setIsSyncing(null)
|
||||
|
||||
if (response.status === 201) {
|
||||
const taskId = result.task_id
|
||||
if (taskId) {
|
||||
addTask(taskId)
|
||||
setSyncResult({
|
||||
processed: 0,
|
||||
total: selectedFiles.length,
|
||||
status: 'started'
|
||||
})
|
||||
}
|
||||
} else if (response.ok) {
|
||||
// Direct sync result - still show "sync started" message
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
message: "Check task notification panel for progress",
|
||||
isStarted: true
|
||||
}
|
||||
}))
|
||||
setIsSyncing(null)
|
||||
setSyncResult(result)
|
||||
} else {
|
||||
throw new Error(result.error || 'Sync failed')
|
||||
console.error('Sync failed:', result.error)
|
||||
setSyncResult({ error: result.error || 'Sync failed' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error)
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
error: error instanceof Error ? error.message : 'Sync failed'
|
||||
}
|
||||
}))
|
||||
setIsSyncing(null)
|
||||
console.error('Sync error:', error)
|
||||
setSyncResult({ error: 'Network error occurred' })
|
||||
} finally {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (connector: Connector) => {
|
||||
// This would call a disconnect endpoint when implemented
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.id === connector.id ? { ...c, status: "not_connected", connectionId: undefined } : c
|
||||
))
|
||||
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: Connector['status']) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
case "connecting":
|
||||
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />
|
||||
default:
|
||||
return <XCircle className="h-4 w-4 text-gray-400" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: Connector['status']) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">Connected</Badge>
|
||||
case "connecting":
|
||||
return <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">Connecting...</Badge>
|
||||
case "error":
|
||||
return <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">Error</Badge>
|
||||
default:
|
||||
return <Badge variant="outline" className="bg-gray-500/10 text-gray-500 border-gray-500/20">Not Connected</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
// Check connector status on mount and when returning from OAuth
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
checkConnectorStatuses()
|
||||
}
|
||||
|
||||
// If we just returned from OAuth, clear the URL parameter
|
||||
if (searchParams.get('oauth_success') === 'true') {
|
||||
// Clear the URL parameter without causing a page reload
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('oauth_success')
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
}, [searchParams, isAuthenticated, checkConnectorStatuses])
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Connect external services to automatically sync and index your documents
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<GoogleDrivePicker
|
||||
onFileSelected={handleFileSelection}
|
||||
selectedFiles={selectedFiles}
|
||||
isAuthenticated={false} // This would come from auth context in real usage
|
||||
accessToken={undefined} // This would come from connected account
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Sync Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how many files to sync when manually triggering a sync
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Label htmlFor="maxFiles" className="text-sm font-medium">
|
||||
Max files per sync:
|
||||
</Label>
|
||||
<Input
|
||||
id="maxFiles"
|
||||
type="number"
|
||||
value={maxFiles}
|
||||
onChange={(e) => setMaxFiles(parseInt(e.target.value) || 10)}
|
||||
className="w-24"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
(Leave blank or set to 0 for unlimited)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connectors Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{connectors.map((connector) => (
|
||||
<Card key={connector.id} className="relative">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{connector.icon}
|
||||
<div>
|
||||
<CardTitle className="text-lg">{connector.name}</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getStatusIcon(connector.status)}
|
||||
{getStatusBadge(connector.status)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<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"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>Syncing {selectedFiles.length} Selected Items...</>
|
||||
) : (
|
||||
<>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' ? (
|
||||
<div className="text-blue-600">
|
||||
Sync started for {syncResult.total} files. Check the task notification for progress.
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="mt-2">
|
||||
{connector.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{connector.status === "not_connected" && (
|
||||
<Button
|
||||
onClick={() => handleConnect(connector)}
|
||||
disabled={isConnecting === connector.id}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting === connector.id ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlugZap className="h-4 w-4 mr-2" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{connector.status === "connected" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleSync(connector)}
|
||||
disabled={isSyncing === connector.id}
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
{isSyncing === connector.id ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Syncing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Files
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDisconnect(connector)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{connector.status === "error" && (
|
||||
<Button
|
||||
onClick={() => handleConnect(connector)}
|
||||
disabled={isConnecting === connector.id}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Connection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sync Results */}
|
||||
{syncResults[connector.id] && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
{syncResults[connector.id]?.isStarted && (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-600 mb-1">
|
||||
<RefreshCw className="inline h-3 w-3 mr-1" />
|
||||
Task initiated:
|
||||
</div>
|
||||
<div className="text-blue-600">
|
||||
{syncResults[connector.id]?.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{syncResults[connector.id]?.error && (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-red-600 mb-1">
|
||||
<XCircle className="h-4 w-4 inline mr-1" />
|
||||
Sync Failed
|
||||
</div>
|
||||
<div className="text-red-600">
|
||||
{syncResults[connector.id]?.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="text-green-600">
|
||||
<div>Processed: {syncResult.processed || 0}</div>
|
||||
<div>Added: {syncResult.added || 0}</div>
|
||||
{syncResult.errors && <div>Errors: {syncResult.errors}</div>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Section */}
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-muted-foreground">Coming Soon</CardTitle>
|
||||
<CardDescription>
|
||||
Additional connectors are in development
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 opacity-50">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold">D</div>
|
||||
<div>
|
||||
<div className="font-medium">Dropbox</div>
|
||||
<div className="text-sm text-muted-foreground">File storage</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
|
||||
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center text-white font-bold">O</div>
|
||||
<div>
|
||||
<div className="font-medium">OneDrive</div>
|
||||
<div className="text-sm text-muted-foreground">Microsoft cloud storage</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
|
||||
<div className="w-8 h-8 bg-orange-600 rounded flex items-center justify-center text-white font-bold">B</div>
|
||||
<div>
|
||||
<div className="font-medium">Box</div>
|
||||
<div className="text-sm text-muted-foreground">Enterprise file sharing</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProtectedConnectorsPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Suspense fallback={<div>Loading connectors...</div>}>
|
||||
<ConnectorsPage />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,6 +14,25 @@ import { useTask } from "@/contexts/task-context"
|
|||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
|
||||
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?: any
|
||||
}
|
||||
}
|
||||
|
||||
interface Connector {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -23,6 +42,7 @@ interface Connector {
|
|||
type: string
|
||||
connectionId?: string
|
||||
access_token?: string
|
||||
selectedFiles?: GoogleDriveFile[] | OneDriveFile[]
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
|
|
@ -143,6 +163,7 @@ function KnowledgeSourcesPage() {
|
|||
const activeConnection = connections.find((conn: Connection) => conn.is_active)
|
||||
const isConnected = activeConnection !== undefined
|
||||
|
||||
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.type === connectorType
|
||||
? {
|
||||
|
|
@ -208,6 +229,7 @@ function KnowledgeSourcesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
const handleSync = async (connector: Connector) => {
|
||||
if (!connector.connectionId) return
|
||||
|
||||
|
|
@ -215,15 +237,23 @@ function KnowledgeSourcesPage() {
|
|||
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
||||
|
||||
try {
|
||||
const syncBody: {
|
||||
connection_id: string;
|
||||
max_files?: number;
|
||||
selected_files?: string[];
|
||||
} = {
|
||||
connection_id: connector.connectionId,
|
||||
max_files: syncAllFiles ? 0 : (maxFiles || undefined)
|
||||
}
|
||||
|
||||
// Note: File selection is now handled via the cloud connectors dialog
|
||||
|
||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
connection_id: connector.connectionId,
|
||||
max_files: syncAllFiles ? 0 : (maxFiles || undefined)
|
||||
}),
|
||||
body: JSON.stringify(syncBody),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
|
|
|||
370
frontend/src/app/upload/[provider]/page.tsx
Normal file
370
frontend/src/app/upload/[provider]/page.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"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"
|
||||
|
||||
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?: object
|
||||
}
|
||||
}
|
||||
|
||||
interface CloudConnector {
|
||||
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 [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)
|
||||
|
||||
try {
|
||||
// Fetch available connectors to validate the provider
|
||||
const connectorsResponse = await fetch('/api/connectors')
|
||||
if (!connectorsResponse.ok) {
|
||||
throw new Error('Failed to load connectors')
|
||||
}
|
||||
|
||||
const connectorsResult = await connectorsResponse.json()
|
||||
const providerInfo = connectorsResult.connectors[provider]
|
||||
|
||||
if (!providerInfo || !providerInfo.available) {
|
||||
setError(`Cloud provider "${provider}" is not available or configured.`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check connector status
|
||||
const statusResponse = await fetch(`/api/connectors/${provider}/status`)
|
||||
if (!statusResponse.ok) {
|
||||
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
|
||||
|
||||
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}`)
|
||||
if (tokenResponse.ok) {
|
||||
const tokenData = await tokenResponse.json()
|
||||
if (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'
|
||||
}
|
||||
} catch {
|
||||
accessTokenError = 'Failed to fetch access token'
|
||||
}
|
||||
}
|
||||
|
||||
setConnector({
|
||||
id: provider,
|
||||
name: providerInfo.name,
|
||||
description: providerInfo.description,
|
||||
status: isConnected ? "connected" : "not_connected",
|
||||
type: provider,
|
||||
connectionId: activeConnection?.connection_id,
|
||||
hasAccessToken,
|
||||
accessTokenError
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load connector info:', error)
|
||||
setError(error instanceof Error ? error.message : 'Failed to load connector information')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
fetchConnectorInfo()
|
||||
}
|
||||
}, [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') {
|
||||
// Task completed successfully, show toast and redirect
|
||||
setIsIngesting(false)
|
||||
setShowSuccessToast(true)
|
||||
setTimeout(() => {
|
||||
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)
|
||||
}
|
||||
}, [tasks, currentSyncTaskId, router])
|
||||
|
||||
const handleFileSelected = (files: GoogleDriveFile[] | OneDriveFile[]) => {
|
||||
setSelectedFiles(files)
|
||||
console.log(`Selected ${files.length} files from ${provider}:`, files)
|
||||
// You can add additional handling here like triggering sync, etc.
|
||||
}
|
||||
|
||||
const handleSync = async (connector: CloudConnector) => {
|
||||
if (!connector.connectionId || selectedFiles.length === 0) return
|
||||
|
||||
setIsIngesting(true)
|
||||
|
||||
try {
|
||||
const syncBody: {
|
||||
connection_id: string;
|
||||
max_files?: number;
|
||||
selected_files?: string[];
|
||||
} = {
|
||||
connection_id: connector.connectionId,
|
||||
selected_files: selectedFiles.map(file => file.id)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(syncBody),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.status === 201) {
|
||||
const taskIds = result.task_ids
|
||||
if (taskIds && taskIds.length > 0) {
|
||||
const taskId = taskIds[0] // Use the first task ID
|
||||
addTask(taskId)
|
||||
setCurrentSyncTaskId(taskId)
|
||||
}
|
||||
} else {
|
||||
console.error('Sync failed:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
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
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading {getProviderDisplayName()} connector...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !connector) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
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>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<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"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
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>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You need to connect your {connector.name} account before you can select files.
|
||||
</p>
|
||||
<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"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
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>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{connector.accessTokenError || `Unable to get access token for ${connector.name}. Try reconnecting your account.`}
|
||||
</p>
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold">Add Cloud Knowledge</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{connector.type === "google_drive" && (
|
||||
<GoogleDrivePicker
|
||||
onFileSelected={handleFileSelected}
|
||||
selectedFiles={selectedFiles as GoogleDriveFile[]}
|
||||
isAuthenticated={true}
|
||||
accessToken={accessToken || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(connector.type === "onedrive" || connector.type === "sharepoint") && (
|
||||
<OneDrivePicker
|
||||
onFileSelected={handleFileSelected}
|
||||
selectedFiles={selectedFiles as OneDriveFile[]}
|
||||
isAuthenticated={true}
|
||||
accessToken={accessToken || undefined}
|
||||
connectorType={connector.type as "onedrive" | "sharepoint"}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Success toast notification */}
|
||||
<Toast
|
||||
message="Ingested successfully!."
|
||||
show={showSuccessToast}
|
||||
onHide={() => setShowSuccessToast(false)}
|
||||
duration={20000}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
frontend/src/components/cloud-connectors-dialog.tsx
Normal file
299
frontend/src/components/cloud-connectors-dialog.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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"
|
||||
|
||||
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?: any
|
||||
}
|
||||
}
|
||||
|
||||
interface CloudConnector {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
status: "not_connected" | "connecting" | "connected" | "error"
|
||||
type: string
|
||||
connectionId?: string
|
||||
hasAccessToken: boolean
|
||||
accessTokenError?: string
|
||||
}
|
||||
|
||||
interface CloudConnectorsDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onFileSelected?: (files: GoogleDriveFile[] | OneDriveFile[], connectorType: string) => void
|
||||
}
|
||||
|
||||
export function CloudConnectorsDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
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 [isGooglePickerOpen, setIsGooglePickerOpen] = useState(false)
|
||||
|
||||
const getConnectorIcon = (iconName: string) => {
|
||||
const iconMap: { [key: string]: React.ReactElement } = {
|
||||
'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': (
|
||||
<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': (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
const fetchConnectorStatuses = useCallback(async () => {
|
||||
if (!isOpen) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Fetch available connectors from backend
|
||||
const connectorsResponse = await fetch('/api/connectors')
|
||||
if (!connectorsResponse.ok) {
|
||||
throw new Error('Failed to load 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
|
||||
)
|
||||
|
||||
// Initialize connectors list
|
||||
const initialConnectors = cloudConnectorTypes.map(type => ({
|
||||
id: type,
|
||||
name: connectorsResult.connectors[type].name,
|
||||
description: connectorsResult.connectors[type].description,
|
||||
icon: getConnectorIcon(connectorsResult.connectors[type].icon),
|
||||
status: "not_connected" as const,
|
||||
type: type,
|
||||
hasAccessToken: false,
|
||||
accessTokenError: undefined
|
||||
}))
|
||||
|
||||
setConnectors(initialConnectors)
|
||||
|
||||
// Check status for each cloud connector type
|
||||
for (const connectorType of cloudConnectorTypes) {
|
||||
try {
|
||||
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: any) => 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}`)
|
||||
if (tokenResponse.ok) {
|
||||
const tokenData = await tokenResponse.json()
|
||||
if (tokenData.access_token) {
|
||||
hasAccessToken = true
|
||||
setConnectorAccessTokens(prev => ({
|
||||
...prev,
|
||||
[connectorType]: tokenData.access_token
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
const errorData = await tokenResponse.json().catch(() => ({ error: 'Token unavailable' }))
|
||||
accessTokenError = errorData.error || 'Access token unavailable'
|
||||
}
|
||||
} catch (e) {
|
||||
accessTokenError = 'Failed to fetch access token'
|
||||
}
|
||||
}
|
||||
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.type === connectorType
|
||||
? {
|
||||
...c,
|
||||
status: isConnected ? "connected" : "not_connected",
|
||||
connectionId: activeConnection?.connection_id,
|
||||
hasAccessToken,
|
||||
accessTokenError
|
||||
}
|
||||
: c
|
||||
))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to check status for ${connectorType}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cloud connectors:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleFileSelection = (connectorId: string, files: GoogleDriveFile[] | OneDriveFile[]) => {
|
||||
setSelectedFiles(prev => ({
|
||||
...prev,
|
||||
[connectorId]: files
|
||||
}))
|
||||
|
||||
onFileSelected?.(files, connectorId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectorStatuses()
|
||||
}, [fetchConnectorStatuses])
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cloud File Connectors</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select files from your connected cloud storage providers
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
Loading connectors...
|
||||
</div>
|
||||
) : connectors.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No cloud connectors available. Configure them in Settings first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Service Buttons Row */}
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{connectors
|
||||
.filter(connector => connector.status === "connected")
|
||||
.map((connector) => (
|
||||
<Button
|
||||
key={connector.id}
|
||||
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()
|
||||
if (connector.hasAccessToken) {
|
||||
setActivePickerType(connector.id)
|
||||
}
|
||||
}}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{connector.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</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") {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<GoogleDrivePicker
|
||||
onFileSelected={(files) => {
|
||||
handleFileSelection(connector.id, files)
|
||||
setActivePickerType(null)
|
||||
setIsGooglePickerOpen(false)
|
||||
}}
|
||||
selectedFiles={selectedFiles[connector.id] as GoogleDriveFile[] || []}
|
||||
isAuthenticated={connector.status === "connected"}
|
||||
accessToken={connectorAccessTokens[connector.type]}
|
||||
onPickerStateChange={setIsGooglePickerOpen}
|
||||
/>
|
||||
</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"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/cloud-connectors-dropdown.tsx
Normal file
77
frontend/src/components/cloud-connectors-dropdown.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { CloudConnectorsDialog } from "@/components/cloud-connectors-dialog"
|
||||
import { Cloud, ChevronDown } 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?: any
|
||||
}
|
||||
}
|
||||
|
||||
interface CloudConnectorsDropdownProps {
|
||||
onFileSelected?: (files: GoogleDriveFile[] | OneDriveFile[], connectorType: string) => void
|
||||
buttonText?: string
|
||||
variant?: "default" | "outline" | "secondary" | "ghost" | "link" | "destructive"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
}
|
||||
|
||||
export function CloudConnectorsDropdown({
|
||||
onFileSelected,
|
||||
buttonText = "Cloud Files",
|
||||
variant = "outline",
|
||||
size = "default"
|
||||
}: CloudConnectorsDropdownProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={variant} size={size}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{buttonText}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={handleOpenDialog} className="cursor-pointer">
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
Select Cloud Files
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CloudConnectorsDialog
|
||||
isOpen={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
onFileSelected={onFileSelected}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
341
frontend/src/components/google-drive-picker.tsx
Normal file
341
frontend/src/components/google-drive-picker.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
322
frontend/src/components/onedrive-picker.tsx
Normal file
322
frontend/src/components/onedrive-picker.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { FileText, Folder, Trash2, X } from "lucide-react"
|
||||
|
||||
interface OneDrivePickerProps {
|
||||
onFileSelected: (files: OneDriveFile[]) => void
|
||||
selectedFiles?: OneDriveFile[]
|
||||
isAuthenticated: boolean
|
||||
accessToken?: string
|
||||
connectorType?: "onedrive" | "sharepoint"
|
||||
onPickerStateChange?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
interface OneDriveFile {
|
||||
id: string
|
||||
name: string
|
||||
mimeType?: string
|
||||
webUrl?: string
|
||||
driveItem?: {
|
||||
file?: { mimeType: string }
|
||||
folder?: any
|
||||
}
|
||||
}
|
||||
|
||||
interface GraphResponse {
|
||||
value: OneDriveFile[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mgt?: {
|
||||
Providers: {
|
||||
globalProvider: any
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OneDrivePicker({
|
||||
onFileSelected,
|
||||
selectedFiles = [],
|
||||
isAuthenticated,
|
||||
accessToken,
|
||||
connectorType = "onedrive",
|
||||
onPickerStateChange
|
||||
}: OneDrivePickerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [files, setFiles] = useState<OneDriveFile[]>([])
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||
const [currentPath, setCurrentPath] = useState<string>(
|
||||
connectorType === "sharepoint" ? 'sites?search=' : 'me/drive/root/children'
|
||||
)
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<{id: string, name: string}[]>([
|
||||
{id: 'root', name: connectorType === "sharepoint" ? 'SharePoint' : 'OneDrive'}
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const loadMGT = async () => {
|
||||
if (typeof window !== 'undefined' && !window.mgt) {
|
||||
try {
|
||||
const mgtModule = await import('@microsoft/mgt-components')
|
||||
const mgtProvider = await import('@microsoft/mgt-msal2-provider')
|
||||
|
||||
// Initialize provider if needed
|
||||
if (!window.mgt?.Providers?.globalProvider && accessToken) {
|
||||
// For simplicity, we'll use direct Graph API calls instead of MGT components
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('MGT not available, falling back to direct API calls')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMGT()
|
||||
}, [accessToken])
|
||||
|
||||
|
||||
const fetchFiles = async (path: string = currentPath) => {
|
||||
if (!accessToken) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`https://graph.microsoft.com/v1.0/${path}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: GraphResponse = await response.json()
|
||||
setFiles(data.value || [])
|
||||
} else {
|
||||
console.error('Failed to fetch OneDrive files:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching OneDrive files:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openPicker = () => {
|
||||
if (!accessToken) return
|
||||
|
||||
setIsPickerOpen(true)
|
||||
onPickerStateChange?.(true)
|
||||
fetchFiles()
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
setIsPickerOpen(false)
|
||||
onPickerStateChange?.(false)
|
||||
setFiles([])
|
||||
setCurrentPath(
|
||||
connectorType === "sharepoint" ? 'sites?search=' : 'me/drive/root/children'
|
||||
)
|
||||
setBreadcrumbs([
|
||||
{id: 'root', name: connectorType === "sharepoint" ? 'SharePoint' : 'OneDrive'}
|
||||
])
|
||||
}
|
||||
|
||||
const handleFileClick = (file: OneDriveFile) => {
|
||||
if (file.driveItem?.folder) {
|
||||
// Navigate to folder
|
||||
const newPath = `me/drive/items/${file.id}/children`
|
||||
setCurrentPath(newPath)
|
||||
setBreadcrumbs([...breadcrumbs, {id: file.id, name: file.name}])
|
||||
fetchFiles(newPath)
|
||||
} else {
|
||||
// Select file
|
||||
const isAlreadySelected = selectedFiles.some(f => f.id === file.id)
|
||||
if (!isAlreadySelected) {
|
||||
onFileSelected([...selectedFiles, file])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToBreadcrumb = (index: number) => {
|
||||
if (index === 0) {
|
||||
setCurrentPath('me/drive/root/children')
|
||||
setBreadcrumbs([{id: 'root', name: 'OneDrive'}])
|
||||
fetchFiles('me/drive/root/children')
|
||||
} else {
|
||||
const targetCrumb = breadcrumbs[index]
|
||||
const newPath = `me/drive/items/${targetCrumb.id}/children`
|
||||
setCurrentPath(newPath)
|
||||
setBreadcrumbs(breadcrumbs.slice(0, index + 1))
|
||||
fetchFiles(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (fileId: string) => {
|
||||
const updatedFiles = selectedFiles.filter(file => file.id !== fileId)
|
||||
onFileSelected(updatedFiles)
|
||||
}
|
||||
|
||||
const getFileIcon = (file: OneDriveFile) => {
|
||||
if (file.driveItem?.folder) {
|
||||
return <Folder className="h-4 w-4" />
|
||||
}
|
||||
return <FileText className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const getMimeTypeLabel = (file: OneDriveFile) => {
|
||||
const mimeType = file.driveItem?.file?.mimeType || file.mimeType || ''
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word Doc',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint',
|
||||
'application/pdf': 'PDF',
|
||||
'text/plain': 'Text',
|
||||
'image/jpeg': 'Image',
|
||||
'image/png': 'Image',
|
||||
}
|
||||
|
||||
if (file.driveItem?.folder) return 'Folder'
|
||||
return typeMap[mimeType] || 'Document'
|
||||
}
|
||||
|
||||
const serviceName = connectorType === "sharepoint" ? "SharePoint" : "OneDrive"
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
|
||||
Please connect to {serviceName} first to select specific files.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">{serviceName} File Selection</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose specific files to sync instead of syncing everything
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openPicker}
|
||||
disabled={!accessToken}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={!accessToken ? `Access token required - try disconnecting and reconnecting ${serviceName}` : ""}
|
||||
>
|
||||
{!accessToken ? "No Access Token" : "Select Files"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status message when access token is missing */}
|
||||
{isAuthenticated && !accessToken && (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 p-3 rounded-md border border-amber-200">
|
||||
<div className="font-medium mb-1">Access token unavailable</div>
|
||||
<div>The file picker requires an access token. Try disconnecting and reconnecting your {serviceName} account.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Picker Modal */}
|
||||
{isPickerOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Select Files from {serviceName}</h3>
|
||||
<Button onClick={closePicker} size="sm" variant="ghost">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center space-x-2 mb-4 text-sm">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.id} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2 text-gray-400">/</span>}
|
||||
<button
|
||||
onClick={() => navigateToBreadcrumb(index)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">Loading...</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">No files found</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center p-3 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleFileClick(file)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{getFileIcon(file)}
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getMimeTypeLabel(file)}
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedFiles.some(f => f.id === file.id) && (
|
||||
<Badge variant="default" className="text-xs">Selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected files ({selectedFiles.length}):
|
||||
</p>
|
||||
<div className="max-h-48 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)}
|
||||
<span className="truncate font-medium">{file.name}</span>
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
|
||||
{getMimeTypeLabel(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>
|
||||
<Button
|
||||
onClick={() => onFileSelected([])}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/ui/toast.tsx
Normal file
39
frontend/src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
show: boolean
|
||||
onHide?: () => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function Toast({ message, show, onHide, duration = 3000 }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(show)
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(show)
|
||||
|
||||
if (show && duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
onHide?.()
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [show, duration, onHide])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-full">
|
||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 max-w-md">
|
||||
<Check className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
|||
connector_type = request.path_params.get("connector_type", "google_drive")
|
||||
data = await request.json()
|
||||
max_files = data.get("max_files")
|
||||
selected_files = data.get("selected_files")
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
|
|
@ -29,10 +30,8 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
|||
connector_type=connector_type,
|
||||
max_files=max_files,
|
||||
)
|
||||
|
||||
user = request.state.user
|
||||
jwt_token = request.state.jwt_token
|
||||
logger.debug("User authenticated", user_id=user.user_id)
|
||||
|
||||
# Get all active connections for this connector type and user
|
||||
connections = await connector_service.connection_manager.list_connections(
|
||||
|
|
@ -40,6 +39,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
|||
)
|
||||
|
||||
active_connections = [conn for conn in connections if conn.is_active]
|
||||
active_connections = active_connections[:1] # TODO: Temporary workaround for duplicate connections
|
||||
if not active_connections:
|
||||
return JSONResponse(
|
||||
{"error": f"No active {connector_type} connections found"},
|
||||
|
|
@ -53,12 +53,20 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
|||
"About to call sync_connector_files for connection",
|
||||
connection_id=connection.connection_id,
|
||||
)
|
||||
task_id = await connector_service.sync_connector_files(
|
||||
connection.connection_id, user.user_id, max_files, jwt_token=jwt_token
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
logger.debug("Got task ID", task_id=task_id)
|
||||
|
||||
if selected_files:
|
||||
task_id = await connector_service.sync_specific_files(
|
||||
connection.connection_id,
|
||||
user.user_id,
|
||||
selected_files,
|
||||
jwt_token=jwt_token,
|
||||
)
|
||||
else:
|
||||
task_id = await connector_service.sync_connector_files(
|
||||
connection.connection_id,
|
||||
user.user_id,
|
||||
max_files,
|
||||
jwt_token=jwt_token,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"task_ids": task_ids,
|
||||
|
|
@ -70,14 +78,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
error_msg = f"[ERROR] Connector sync failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
|
||||
logger.error("Connector sync failed", error=str(e))
|
||||
return JSONResponse({"error": f"Sync failed: {str(e)}"}, status_code=500)
|
||||
|
||||
|
||||
|
|
@ -117,6 +118,8 @@ async def connector_status(request: Request, connector_service, session_manager)
|
|||
async def connector_webhook(request: Request, connector_service, session_manager):
|
||||
"""Handle webhook notifications from any connector type"""
|
||||
connector_type = request.path_params.get("connector_type")
|
||||
if connector_type is None:
|
||||
connector_type = "unknown"
|
||||
|
||||
# Handle webhook validation (connector-specific)
|
||||
temp_config = {"token_file": "temp.json"}
|
||||
|
|
@ -124,7 +127,7 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
|||
|
||||
temp_connection = ConnectionConfig(
|
||||
connection_id="temp",
|
||||
connector_type=connector_type,
|
||||
connector_type=str(connector_type),
|
||||
name="temp",
|
||||
config=temp_config,
|
||||
)
|
||||
|
|
@ -194,7 +197,6 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
|||
)
|
||||
|
||||
# Process webhook for the specific connection
|
||||
results = []
|
||||
try:
|
||||
# Get the connector instance
|
||||
connector = await connector_service._get_connector(connection.connection_id)
|
||||
|
|
@ -268,6 +270,7 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
|||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "error",
|
||||
|
|
@ -279,10 +282,59 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error("Webhook processing failed", error=str(e))
|
||||
traceback.print_exc()
|
||||
return JSONResponse(
|
||||
{"error": f"Webhook processing failed: {str(e)}"}, status_code=500
|
||||
)
|
||||
|
||||
async def connector_token(request: Request, connector_service, session_manager):
|
||||
"""Get access token for connector API calls (e.g., Google Picker)"""
|
||||
connector_type = request.path_params.get("connector_type")
|
||||
connection_id = request.query_params.get("connection_id")
|
||||
|
||||
if not connection_id:
|
||||
return JSONResponse({"error": "connection_id is required"}, status_code=400)
|
||||
|
||||
user = request.state.user
|
||||
|
||||
try:
|
||||
# Get the connection and verify it belongs to the user
|
||||
connection = await connector_service.connection_manager.get_connection(connection_id)
|
||||
if not connection or connection.user_id != user.user_id:
|
||||
return JSONResponse({"error": "Connection not found"}, status_code=404)
|
||||
|
||||
# Get the connector instance
|
||||
connector = await connector_service._get_connector(connection_id)
|
||||
if not connector:
|
||||
return JSONResponse({"error": f"Connector not available - authentication may have failed for {connector_type}"}, status_code=404)
|
||||
|
||||
# For Google Drive, get the access token
|
||||
if connector_type == "google_drive" and hasattr(connector, 'oauth'):
|
||||
await connector.oauth.load_credentials()
|
||||
if connector.oauth.creds and connector.oauth.creds.valid:
|
||||
return JSONResponse({
|
||||
"access_token": connector.oauth.creds.token,
|
||||
"expires_in": (connector.oauth.creds.expiry.timestamp() -
|
||||
__import__('time').time()) if connector.oauth.creds.expiry else None
|
||||
})
|
||||
else:
|
||||
return JSONResponse({"error": "Invalid or expired credentials"}, status_code=401)
|
||||
|
||||
# For OneDrive and SharePoint, get the access token
|
||||
elif connector_type in ["onedrive", "sharepoint"] and hasattr(connector, 'oauth'):
|
||||
try:
|
||||
access_token = connector.oauth.get_access_token()
|
||||
return JSONResponse({
|
||||
"access_token": access_token,
|
||||
"expires_in": None # MSAL handles token expiry internally
|
||||
})
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": f"Failed to get access token: {str(e)}"}, status_code=401)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Authentication error: {str(e)}"}, status_code=500)
|
||||
|
||||
return JSONResponse({"error": "Token not available for this connector type"}, status_code=400)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting connector token", error=str(e))
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import os
|
||||
import requests
|
||||
import asyncio
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from utils.logging_config import get_logger
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class BaseConnector(ABC):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_files(self, page_token: Optional[str] = None) -> Dict[str, Any]:
|
||||
async def list_files(self, page_token: Optional[str] = None, max_files: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""List all files. Returns files and next_page_token if any."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,6 @@
|
|||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Optional
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
|
|
@ -25,8 +24,8 @@ class GoogleDriveOAuth:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
token_file: str = "token.json",
|
||||
):
|
||||
self.client_id = client_id
|
||||
|
|
@ -133,7 +132,7 @@ class GoogleDriveOAuth:
|
|||
if not self.creds:
|
||||
await self.load_credentials()
|
||||
|
||||
return self.creds and self.creds.valid
|
||||
return bool(self.creds and self.creds.valid)
|
||||
|
||||
def get_service(self):
|
||||
"""Get authenticated Google Drive service"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import asyncio
|
||||
import tempfile
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
|
@ -12,6 +11,8 @@ from .sharepoint import SharePointConnector
|
|||
from .onedrive import OneDriveConnector
|
||||
from .connection_manager import ConnectionManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConnectorService:
|
||||
"""Service to manage document connectors and process files"""
|
||||
|
|
@ -267,9 +268,6 @@ class ConnectorService:
|
|||
|
||||
page_token = file_list.get("nextPageToken")
|
||||
|
||||
if not files_to_process:
|
||||
raise ValueError("No files found to sync")
|
||||
|
||||
# Get user information
|
||||
user = self.session_manager.get_user(user_id) if self.session_manager else None
|
||||
owner_name = user.name if user else None
|
||||
|
|
|
|||
21
src/main.py
21
src/main.py
|
|
@ -21,11 +21,6 @@ import subprocess
|
|||
from functools import partial
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
# Set multiprocessing start method to 'spawn' for CUDA compatibility
|
||||
multiprocessing.set_start_method("spawn", force=True)
|
||||
|
||||
# Create process pool FIRST, before any torch/CUDA imports
|
||||
from utils.process_pool import process_pool
|
||||
|
||||
import torch
|
||||
|
|
@ -62,6 +57,9 @@ from api import (
|
|||
settings,
|
||||
)
|
||||
|
||||
# Set multiprocessing start method to 'spawn' for CUDA compatibility
|
||||
multiprocessing.set_start_method("spawn", force=True)
|
||||
|
||||
logger.info(
|
||||
"CUDA device information",
|
||||
cuda_available=torch.cuda.is_available(),
|
||||
|
|
@ -317,7 +315,7 @@ async def initialize_services():
|
|||
"Failed to load persisted connections on startup", error=str(e)
|
||||
)
|
||||
else:
|
||||
logger.info("Skipping connector loading in no-auth mode")
|
||||
logger.info("[CONNECTORS] Skipping connection loading in no-auth mode")
|
||||
|
||||
return {
|
||||
"document_service": document_service,
|
||||
|
|
@ -663,6 +661,17 @@ async def create_app():
|
|||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/connectors/{connector_type}/token",
|
||||
require_auth(services["session_manager"])(
|
||||
partial(
|
||||
connectors.connector_token,
|
||||
connector_service=services["connector_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/connectors/{connector_type}/webhook",
|
||||
partial(
|
||||
|
|
|
|||
|
|
@ -107,11 +107,27 @@ class AuthService:
|
|||
auth_endpoint = oauth_class.AUTH_ENDPOINT
|
||||
token_endpoint = oauth_class.TOKEN_ENDPOINT
|
||||
|
||||
# Get client_id from environment variable using connector's env var name
|
||||
client_id = os.getenv(connector_class.CLIENT_ID_ENV_VAR)
|
||||
if not client_id:
|
||||
raise ValueError(
|
||||
f"{connector_class.CLIENT_ID_ENV_VAR} environment variable not set"
|
||||
# src/services/auth_service.py
|
||||
client_key = getattr(connector_class, "CLIENT_ID_ENV_VAR", None)
|
||||
secret_key = getattr(connector_class, "CLIENT_SECRET_ENV_VAR", None)
|
||||
|
||||
def _assert_env_key(name, val):
|
||||
if not isinstance(val, str) or not val.strip():
|
||||
raise RuntimeError(
|
||||
f"{connector_class.__name__} misconfigured: {name} must be a non-empty string "
|
||||
f"(got {val!r}). Define it as a class attribute on the connector."
|
||||
)
|
||||
|
||||
_assert_env_key("CLIENT_ID_ENV_VAR", client_key)
|
||||
_assert_env_key("CLIENT_SECRET_ENV_VAR", secret_key)
|
||||
|
||||
client_id = os.getenv(client_key)
|
||||
client_secret = os.getenv(secret_key)
|
||||
|
||||
if not client_id or not client_secret:
|
||||
raise RuntimeError(
|
||||
f"Missing OAuth env vars for {connector_class.__name__}. "
|
||||
f"Set {client_key} and {secret_key} in the environment."
|
||||
)
|
||||
|
||||
oauth_config = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue