Merge branch 'main' into cz/fix-tool-call-tag
This commit is contained in:
commit
2b662482a0
23 changed files with 2686 additions and 1041 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@ wheels/
|
||||||
|
|
||||||
1001*.pdf
|
1001*.pdf
|
||||||
*.json
|
*.json
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -103,4 +103,4 @@ services:
|
||||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
||||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
||||||
- LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
|
- LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
|
||||||
- LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}
|
- LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { ChevronDown, Upload, FolderOpen, Cloud, PlugZap, Plus } from "lucide-react"
|
import { ChevronDown, Upload, FolderOpen, Cloud, PlugZap, Plus } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
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 { Label } from "@/components/ui/label"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useTask } from "@/contexts/task-context"
|
import { useTask } from "@/contexts/task-context"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
interface KnowledgeDropdownProps {
|
interface KnowledgeDropdownProps {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
|
@ -16,8 +16,8 @@ interface KnowledgeDropdownProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeDropdownProps) {
|
export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeDropdownProps) {
|
||||||
const router = useRouter()
|
|
||||||
const { addTask } = useTask()
|
const { addTask } = useTask()
|
||||||
|
const router = useRouter()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
||||||
const [showS3Dialog, setShowS3Dialog] = useState(false)
|
const [showS3Dialog, setShowS3Dialog] = useState(false)
|
||||||
|
|
@ -27,23 +27,76 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
||||||
const [folderLoading, setFolderLoading] = useState(false)
|
const [folderLoading, setFolderLoading] = useState(false)
|
||||||
const [s3Loading, setS3Loading] = useState(false)
|
const [s3Loading, setS3Loading] = useState(false)
|
||||||
const [fileUploading, setFileUploading] = useState(false)
|
const [fileUploading, setFileUploading] = useState(false)
|
||||||
|
const [cloudConnectors, setCloudConnectors] = useState<{[key: string]: {name: string, available: boolean, connected: boolean, hasToken: boolean}}>({})
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Check AWS availability on mount
|
// Check AWS availability and cloud connectors on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAws = async () => {
|
const checkAvailability = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/upload_options")
|
// Check AWS
|
||||||
if (res.ok) {
|
const awsRes = await fetch("/api/upload_options")
|
||||||
const data = await res.json()
|
if (awsRes.ok) {
|
||||||
setAwsEnabled(Boolean(data.aws))
|
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) {
|
} 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
|
// 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 = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
label: "Add File",
|
label: "Add File",
|
||||||
|
|
@ -216,14 +288,7 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
||||||
setShowS3Dialog(true)
|
setShowS3Dialog(true)
|
||||||
}
|
}
|
||||||
}] : []),
|
}] : []),
|
||||||
{
|
...cloudConnectorItems
|
||||||
label: "Cloud Connectors",
|
|
||||||
icon: PlugZap,
|
|
||||||
onClick: () => {
|
|
||||||
setIsOpen(false)
|
|
||||||
router.push("/settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -265,7 +330,12 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={item.onClick}
|
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}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -364,6 +434,7 @@ export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeD
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
|
|
@ -5402,18 +5402,6 @@
|
||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,495 +1,128 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, Suspense } from "react"
|
import React, { useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation"
|
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
||||||
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 { useTask } from "@/contexts/task-context"
|
import { useTask } from "@/contexts/task-context"
|
||||||
import { ProtectedRoute } from "@/components/protected-route"
|
|
||||||
|
|
||||||
interface Connector {
|
interface GoogleDriveFile {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
mimeType: string;
|
||||||
icon: React.ReactNode
|
webViewLink?: string;
|
||||||
status: "not_connected" | "connecting" | "connected" | "error"
|
iconLink?: string;
|
||||||
type: string
|
|
||||||
connectionId?: string // Store the active connection ID for syncing
|
|
||||||
access_token?: string // For connectors that use OAuth
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncResult {
|
export default function ConnectorsPage() {
|
||||||
processed?: number;
|
const { addTask } = useTask()
|
||||||
added?: number;
|
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[]>([]);
|
||||||
skipped?: number;
|
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||||
errors?: number;
|
const [syncResult, setSyncResult] = useState<any>(null);
|
||||||
error?: string;
|
|
||||||
message?: string; // For sync started messages
|
|
||||||
isStarted?: boolean; // For sync started state
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Connection {
|
const handleFileSelection = (files: GoogleDriveFile[]) => {
|
||||||
connection_id: string
|
setSelectedFiles(files);
|
||||||
name: string
|
};
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
last_sync?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectorsPage() {
|
const handleSync = async (connector: { connectionId: string, type: string }) => {
|
||||||
const { isAuthenticated } = useAuth()
|
if (!connector.connectionId || selectedFiles.length === 0) return
|
||||||
const { addTask, refreshTasks } = useTask()
|
|
||||||
const searchParams = useSearchParams()
|
setIsSyncing(true)
|
||||||
const [connectors, setConnectors] = useState<Connector[]>([])
|
setSyncResult(null)
|
||||||
|
|
||||||
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
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the shared auth callback URL, not a separate connectors callback
|
const syncBody: {
|
||||||
const redirectUri = `${window.location.origin}/auth/callback`
|
connection_id: string;
|
||||||
|
max_files?: number;
|
||||||
const response = await fetch('/api/auth/init', {
|
selected_files?: string[];
|
||||||
method: 'POST',
|
} = {
|
||||||
headers: {
|
connection_id: connector.connectionId,
|
||||||
'Content-Type': 'application/json',
|
selected_files: selectedFiles.map(file => file.id)
|
||||||
},
|
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
} 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`, {
|
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(syncBody),
|
||||||
max_files: maxFiles
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (response.status === 201 && result.task_id) {
|
if (response.status === 201) {
|
||||||
// Task-based sync, use centralized tracking
|
const taskId = result.task_id
|
||||||
addTask(result.task_id)
|
if (taskId) {
|
||||||
console.log(`Sync task ${result.task_id} added to central tracking for connector ${connector.id}`)
|
addTask(taskId)
|
||||||
|
setSyncResult({
|
||||||
// Immediately refresh task notifications to show the new task
|
processed: 0,
|
||||||
await refreshTasks()
|
total: selectedFiles.length,
|
||||||
|
status: 'started'
|
||||||
// Show sync started message
|
})
|
||||||
setSyncResults(prev => ({
|
}
|
||||||
...prev,
|
|
||||||
[connector.id]: {
|
|
||||||
message: "Check task notification panel for progress",
|
|
||||||
isStarted: true
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
setIsSyncing(null)
|
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
// Direct sync result - still show "sync started" message
|
setSyncResult(result)
|
||||||
setSyncResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[connector.id]: {
|
|
||||||
message: "Check task notification panel for progress",
|
|
||||||
isStarted: true
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
setIsSyncing(null)
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Sync failed')
|
console.error('Sync failed:', result.error)
|
||||||
|
setSyncResult({ error: result.error || 'Sync failed' })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', error)
|
console.error('Sync error:', error)
|
||||||
setSyncResults(prev => ({
|
setSyncResult({ error: 'Network error occurred' })
|
||||||
...prev,
|
} finally {
|
||||||
[connector.id]: {
|
setIsSyncing(false)
|
||||||
error: error instanceof Error ? error.message : 'Sync failed'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
setIsSyncing(null)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="p-6">
|
||||||
<div>
|
<h1 className="text-2xl font-bold mb-4">Connectors</h1>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
<div className="mb-6">
|
||||||
Connect external services to automatically sync and index your documents
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sync Settings */}
|
{selectedFiles.length > 0 && (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
<button
|
||||||
<CardTitle className="flex items-center gap-2">
|
onClick={() => handleSync({ connectionId: "google-drive-connection-id", type: "google-drive" })}
|
||||||
<Download className="h-5 w-5" />
|
disabled={isSyncing}
|
||||||
Sync Settings
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
</CardTitle>
|
>
|
||||||
<CardDescription>
|
{isSyncing ? (
|
||||||
Configure how many files to sync when manually triggering a sync
|
<>Syncing {selectedFiles.length} Selected Items...</>
|
||||||
</CardDescription>
|
) : (
|
||||||
</CardHeader>
|
<>Sync {selectedFiles.length} Selected Items</>
|
||||||
<CardContent>
|
)}
|
||||||
<div className="space-y-4">
|
</button>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Label htmlFor="maxFiles" className="text-sm font-medium">
|
{syncResult && (
|
||||||
Max files per sync:
|
<div className="p-3 bg-gray-100 rounded text-sm">
|
||||||
</Label>
|
{syncResult.error ? (
|
||||||
<Input
|
<div className="text-red-600">Error: {syncResult.error}</div>
|
||||||
id="maxFiles"
|
) : syncResult.status === 'started' ? (
|
||||||
type="number"
|
<div className="text-blue-600">
|
||||||
value={maxFiles}
|
Sync started for {syncResult.total} files. Check the task notification for progress.
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<CardDescription className="mt-2">
|
<div className="text-green-600">
|
||||||
{connector.description}
|
<div>Processed: {syncResult.processed || 0}</div>
|
||||||
</CardDescription>
|
<div>Added: {syncResult.added || 0}</div>
|
||||||
</CardHeader>
|
{syncResult.errors && <div>Errors: {syncResult.errors}</div>}
|
||||||
<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>
|
</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>
|
||||||
<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>
|
)}
|
||||||
<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"
|
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 {
|
interface Connector {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -23,6 +42,7 @@ interface Connector {
|
||||||
type: string
|
type: string
|
||||||
connectionId?: string
|
connectionId?: string
|
||||||
access_token?: string
|
access_token?: string
|
||||||
|
selectedFiles?: GoogleDriveFile[] | OneDriveFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncResult {
|
interface SyncResult {
|
||||||
|
|
@ -143,6 +163,7 @@ function KnowledgeSourcesPage() {
|
||||||
const activeConnection = connections.find((conn: Connection) => conn.is_active)
|
const activeConnection = connections.find((conn: Connection) => conn.is_active)
|
||||||
const isConnected = activeConnection !== undefined
|
const isConnected = activeConnection !== undefined
|
||||||
|
|
||||||
|
|
||||||
setConnectors(prev => prev.map(c =>
|
setConnectors(prev => prev.map(c =>
|
||||||
c.type === connectorType
|
c.type === connectorType
|
||||||
? {
|
? {
|
||||||
|
|
@ -208,6 +229,7 @@ function KnowledgeSourcesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleSync = async (connector: Connector) => {
|
const handleSync = async (connector: Connector) => {
|
||||||
if (!connector.connectionId) return
|
if (!connector.connectionId) return
|
||||||
|
|
||||||
|
|
@ -215,15 +237,23 @@ function KnowledgeSourcesPage() {
|
||||||
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
||||||
|
|
||||||
try {
|
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`, {
|
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(syncBody),
|
||||||
connection_id: connector.connectionId,
|
|
||||||
max_files: syncAllFiles ? 0 : (maxFiles || undefined)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json()
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -29,11 +29,15 @@ dependencies = [
|
||||||
"structlog>=25.4.0",
|
"structlog>=25.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
openrag = "tui.main:run_tui"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = true
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
#agentd = { path = "/home/tato/Desktop/agentd" }
|
|
||||||
torch = [
|
torch = [
|
||||||
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||||
# macOS & other platforms use PyPI (no index entry needed)
|
|
||||||
]
|
]
|
||||||
torchvision = [
|
torchvision = [
|
||||||
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
||||||
connector_type = request.path_params.get("connector_type", "google_drive")
|
connector_type = request.path_params.get("connector_type", "google_drive")
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
max_files = data.get("max_files")
|
max_files = data.get("max_files")
|
||||||
|
selected_files = data.get("selected_files")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -29,10 +30,8 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
||||||
connector_type=connector_type,
|
connector_type=connector_type,
|
||||||
max_files=max_files,
|
max_files=max_files,
|
||||||
)
|
)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
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
|
# Get all active connections for this connector type and user
|
||||||
connections = await connector_service.connection_manager.list_connections(
|
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 = [conn for conn in connections if conn.is_active]
|
||||||
|
active_connections = active_connections[:1] # TODO: Temporary workaround for duplicate connections
|
||||||
if not active_connections:
|
if not active_connections:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": f"No active {connector_type} connections found"},
|
{"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",
|
"About to call sync_connector_files for connection",
|
||||||
connection_id=connection.connection_id,
|
connection_id=connection.connection_id,
|
||||||
)
|
)
|
||||||
task_id = await connector_service.sync_connector_files(
|
if selected_files:
|
||||||
connection.connection_id, user.user_id, max_files, jwt_token=jwt_token
|
task_id = await connector_service.sync_specific_files(
|
||||||
)
|
connection.connection_id,
|
||||||
task_ids.append(task_id)
|
user.user_id,
|
||||||
logger.debug("Got task ID", task_id=task_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(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"task_ids": task_ids,
|
"task_ids": task_ids,
|
||||||
|
|
@ -70,14 +78,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import sys
|
logger.error("Connector sync failed", error=str(e))
|
||||||
import traceback
|
|
||||||
|
|
||||||
error_msg = f"[ERROR] Connector sync failed: {str(e)}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
traceback.print_exc(file=sys.stderr)
|
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
return JSONResponse({"error": f"Sync failed: {str(e)}"}, status_code=500)
|
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):
|
async def connector_webhook(request: Request, connector_service, session_manager):
|
||||||
"""Handle webhook notifications from any connector type"""
|
"""Handle webhook notifications from any connector type"""
|
||||||
connector_type = request.path_params.get("connector_type")
|
connector_type = request.path_params.get("connector_type")
|
||||||
|
if connector_type is None:
|
||||||
|
connector_type = "unknown"
|
||||||
|
|
||||||
# Handle webhook validation (connector-specific)
|
# Handle webhook validation (connector-specific)
|
||||||
temp_config = {"token_file": "temp.json"}
|
temp_config = {"token_file": "temp.json"}
|
||||||
|
|
@ -124,7 +127,7 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
||||||
|
|
||||||
temp_connection = ConnectionConfig(
|
temp_connection = ConnectionConfig(
|
||||||
connection_id="temp",
|
connection_id="temp",
|
||||||
connector_type=connector_type,
|
connector_type=str(connector_type),
|
||||||
name="temp",
|
name="temp",
|
||||||
config=temp_config,
|
config=temp_config,
|
||||||
)
|
)
|
||||||
|
|
@ -194,7 +197,6 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process webhook for the specific connection
|
# Process webhook for the specific connection
|
||||||
results = []
|
|
||||||
try:
|
try:
|
||||||
# Get the connector instance
|
# Get the connector instance
|
||||||
connector = await connector_service._get_connector(connection.connection_id)
|
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
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|
@ -279,10 +282,59 @@ async def connector_webhook(request: Request, connector_service, session_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger.error("Webhook processing failed", error=str(e))
|
logger.error("Webhook processing failed", error=str(e))
|
||||||
traceback.print_exc()
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": f"Webhook processing failed: {str(e)}"}, status_code=500
|
{"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 os
|
||||||
import requests
|
import requests
|
||||||
import asyncio
|
|
||||||
import time
|
import time
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ class BaseConnector(ABC):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@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."""
|
"""List all files. Returns files and next_page_token if any."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import asyncio
|
from typing import Optional
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from google_auth_oauthlib.flow import Flow
|
from google_auth_oauthlib.flow import Flow
|
||||||
|
|
@ -25,8 +24,8 @@ class GoogleDriveOAuth:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client_id: str = None,
|
client_id: Optional[str] = None,
|
||||||
client_secret: str = None,
|
client_secret: Optional[str] = None,
|
||||||
token_file: str = "token.json",
|
token_file: str = "token.json",
|
||||||
):
|
):
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
|
|
@ -133,7 +132,7 @@ class GoogleDriveOAuth:
|
||||||
if not self.creds:
|
if not self.creds:
|
||||||
await self.load_credentials()
|
await self.load_credentials()
|
||||||
|
|
||||||
return self.creds and self.creds.valid
|
return bool(self.creds and self.creds.valid)
|
||||||
|
|
||||||
def get_service(self):
|
def get_service(self):
|
||||||
"""Get authenticated Google Drive service"""
|
"""Get authenticated Google Drive service"""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
@ -12,6 +11,8 @@ from .sharepoint import SharePointConnector
|
||||||
from .onedrive import OneDriveConnector
|
from .onedrive import OneDriveConnector
|
||||||
from .connection_manager import ConnectionManager
|
from .connection_manager import ConnectionManager
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectorService:
|
class ConnectorService:
|
||||||
"""Service to manage document connectors and process files"""
|
"""Service to manage document connectors and process files"""
|
||||||
|
|
@ -267,9 +268,6 @@ class ConnectorService:
|
||||||
|
|
||||||
page_token = file_list.get("nextPageToken")
|
page_token = file_list.get("nextPageToken")
|
||||||
|
|
||||||
if not files_to_process:
|
|
||||||
raise ValueError("No files found to sync")
|
|
||||||
|
|
||||||
# Get user information
|
# Get user information
|
||||||
user = self.session_manager.get_user(user_id) if self.session_manager else None
|
user = self.session_manager.get_user(user_id) if self.session_manager else None
|
||||||
owner_name = user.name if user else None
|
owner_name = user.name if user else None
|
||||||
|
|
|
||||||
28
src/main.py
28
src/main.py
|
|
@ -1,12 +1,5 @@
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Check for TUI flag FIRST, before any heavy imports
|
|
||||||
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--tui":
|
|
||||||
from tui.main import run_tui
|
|
||||||
|
|
||||||
run_tui()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Configure structured logging early
|
# Configure structured logging early
|
||||||
from utils.logging_config import configure_from_env, get_logger
|
from utils.logging_config import configure_from_env, get_logger
|
||||||
|
|
||||||
|
|
@ -21,11 +14,6 @@ import subprocess
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.routing import Route
|
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
|
from utils.process_pool import process_pool
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
|
|
@ -62,6 +50,9 @@ from api import (
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set multiprocessing start method to 'spawn' for CUDA compatibility
|
||||||
|
multiprocessing.set_start_method("spawn", force=True)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"CUDA device information",
|
"CUDA device information",
|
||||||
cuda_available=torch.cuda.is_available(),
|
cuda_available=torch.cuda.is_available(),
|
||||||
|
|
@ -317,7 +308,7 @@ async def initialize_services():
|
||||||
"Failed to load persisted connections on startup", error=str(e)
|
"Failed to load persisted connections on startup", error=str(e)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Skipping connector loading in no-auth mode")
|
logger.info("[CONNECTORS] Skipping connection loading in no-auth mode")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"document_service": document_service,
|
"document_service": document_service,
|
||||||
|
|
@ -663,6 +654,17 @@ async def create_app():
|
||||||
),
|
),
|
||||||
methods=["GET"],
|
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(
|
Route(
|
||||||
"/connectors/{connector_type}/webhook",
|
"/connectors/{connector_type}/webhook",
|
||||||
partial(
|
partial(
|
||||||
|
|
|
||||||
|
|
@ -107,11 +107,27 @@ class AuthService:
|
||||||
auth_endpoint = oauth_class.AUTH_ENDPOINT
|
auth_endpoint = oauth_class.AUTH_ENDPOINT
|
||||||
token_endpoint = oauth_class.TOKEN_ENDPOINT
|
token_endpoint = oauth_class.TOKEN_ENDPOINT
|
||||||
|
|
||||||
# Get client_id from environment variable using connector's env var name
|
# src/services/auth_service.py
|
||||||
client_id = os.getenv(connector_class.CLIENT_ID_ENV_VAR)
|
client_key = getattr(connector_class, "CLIENT_ID_ENV_VAR", None)
|
||||||
if not client_id:
|
secret_key = getattr(connector_class, "CLIENT_SECRET_ENV_VAR", None)
|
||||||
raise ValueError(
|
|
||||||
f"{connector_class.CLIENT_ID_ENV_VAR} environment variable not set"
|
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 = {
|
oauth_config = {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import random
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from models.tasks import TaskStatus, UploadTask, FileTask
|
from models.tasks import TaskStatus, UploadTask, FileTask
|
||||||
|
from utils.gpu_detection import get_worker_count
|
||||||
from session_manager import AnonymousUser
|
from session_manager import AnonymousUser
|
||||||
from src.utils.gpu_detection import get_worker_count
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -1406,7 +1406,7 @@ wheels = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openrag"
|
name = "openrag"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agentd" },
|
{ name = "agentd" },
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue