misc ui improvements

This commit is contained in:
phact 2025-08-22 11:13:01 -04:00
parent a8a3c7b4ab
commit 674fed412d
4 changed files with 544 additions and 370 deletions

View file

@ -0,0 +1,359 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { ChevronDown, Upload, FolderOpen, Cloud, PlugZap, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { useTask } from "@/contexts/task-context"
interface KnowledgeDropdownProps {
active?: boolean
variant?: 'navigation' | 'button'
}
export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeDropdownProps) {
const router = useRouter()
const { addTask } = useTask()
const [isOpen, setIsOpen] = useState(false)
const [showFolderDialog, setShowFolderDialog] = useState(false)
const [showS3Dialog, setShowS3Dialog] = useState(false)
const [awsEnabled, setAwsEnabled] = useState(false)
const [folderPath, setFolderPath] = useState("/app/documents/")
const [bucketUrl, setBucketUrl] = useState("s3://")
const [folderLoading, setFolderLoading] = useState(false)
const [s3Loading, setS3Loading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Check AWS availability on mount
useEffect(() => {
const checkAws = async () => {
try {
const res = await fetch("/api/upload_options")
if (res.ok) {
const data = await res.json()
setAwsEnabled(Boolean(data.aws))
}
} catch (err) {
console.error("Failed to check AWS availability", err)
}
}
checkAws()
}, [])
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}
}, [isOpen])
const handleFileUpload = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
// Trigger the same file upload event as the chat page
window.dispatchEvent(new CustomEvent('fileUploadStart', {
detail: { filename: files[0].name }
}))
try {
const formData = new FormData()
formData.append('file', files[0])
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (response.ok) {
window.dispatchEvent(new CustomEvent('fileUploaded', {
detail: { file: files[0], result }
}))
} else {
window.dispatchEvent(new CustomEvent('fileUploadError', {
detail: { filename: files[0].name, error: result.error || 'Upload failed' }
}))
}
} catch (error) {
window.dispatchEvent(new CustomEvent('fileUploadError', {
detail: { filename: files[0].name, error: error instanceof Error ? error.message : 'Upload failed' }
}))
} finally {
window.dispatchEvent(new CustomEvent('fileUploadComplete'))
}
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
setIsOpen(false)
}
const handleFolderUpload = async () => {
if (!folderPath.trim()) return
setFolderLoading(true)
try {
const response = await fetch("/api/upload_path", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ path: folderPath }),
})
const result = await response.json()
if (response.status === 201) {
const taskId = result.task_id || result.id
const totalFiles = result.total_files || 0
if (!taskId) {
throw new Error("No task ID received from server")
}
addTask(taskId)
setFolderPath("")
setShowFolderDialog(false)
} else if (response.ok) {
setFolderPath("")
setShowFolderDialog(false)
} else {
console.error("Folder upload failed:", result.error)
}
} catch (error) {
console.error("Folder upload error:", error)
} finally {
setFolderLoading(false)
}
}
const handleS3Upload = async () => {
if (!bucketUrl.trim()) return
setS3Loading(true)
try {
const response = await fetch("/api/upload_bucket", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ s3_url: bucketUrl }),
})
const result = await response.json()
if (response.status === 201) {
const taskId = result.task_id || result.id
const totalFiles = result.total_files || 0
if (!taskId) {
throw new Error("No task ID received from server")
}
addTask(taskId)
setBucketUrl("s3://")
setShowS3Dialog(false)
} else {
console.error("S3 upload failed:", result.error)
}
} catch (error) {
console.error("S3 upload error:", error)
} finally {
setS3Loading(false)
}
}
const menuItems = [
{
label: "Add File",
icon: Upload,
onClick: handleFileUpload
},
{
label: "Process Folder",
icon: FolderOpen,
onClick: () => {
setIsOpen(false)
setShowFolderDialog(true)
}
},
...(awsEnabled ? [{
label: "Process S3 Bucket",
icon: Cloud,
onClick: () => {
setIsOpen(false)
setShowS3Dialog(true)
}
}] : []),
{
label: "Cloud Connectors",
icon: PlugZap,
onClick: () => {
setIsOpen(false)
router.push("/settings")
}
}
]
return (
<>
<div ref={dropdownRef} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
variant === 'button'
? "rounded-lg h-12 px-4 flex items-center gap-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
: "text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all",
variant === 'navigation' && active
? "bg-accent text-accent-foreground shadow-sm"
: variant === 'navigation' ? "text-foreground hover:text-accent-foreground" : "",
)}
>
{variant === 'button' ? (
<>
<Plus className="h-4 w-4" />
<span>Add Knowledge</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
</>
) : (
<>
<div className="flex items-center flex-1">
<Upload className={cn("h-4 w-4 mr-3 shrink-0", active ? "text-accent-foreground" : "text-muted-foreground group-hover:text-foreground")} />
Knowledge
</div>
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
</>
)}
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-md z-50">
<div className="py-1">
{menuItems.map((item, index) => (
<button
key={index}
onClick={item.onClick}
className="w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
{item.label}
</button>
))}
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
</div>
{/* Process Folder Dialog */}
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5" />
Process Folder
</DialogTitle>
<DialogDescription>
Process all documents in a folder path
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="folder-path">Folder Path</Label>
<Input
id="folder-path"
type="text"
placeholder="/path/to/documents"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowFolderDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleFolderUpload}
disabled={!folderPath.trim() || folderLoading}
>
{folderLoading ? "Processing..." : "Process Folder"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Process S3 Bucket Dialog */}
<Dialog open={showS3Dialog} onOpenChange={setShowS3Dialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cloud className="h-5 w-5" />
Process S3 Bucket
</DialogTitle>
<DialogDescription>
Process all documents from an S3 bucket. AWS credentials must be configured.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bucket-url">S3 URL</Label>
<Input
id="bucket-url"
type="text"
placeholder="s3://bucket/path"
value={bucketUrl}
onChange={(e) => setBucketUrl(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowS3Dialog(false)}
>
Cancel
</Button>
<Button
onClick={handleS3Upload}
disabled={!bucketUrl.trim() || s3Loading}
>
{s3Loading ? "Processing..." : "Process Bucket"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -6,6 +6,7 @@ import { Library, MessageSquare, Settings2, Plus, FileText } from "lucide-react"
import { cn } from "@/lib/utils"
import { useState, useEffect, useRef, useCallback } from "react"
import { useChat } from "@/contexts/chat-context"
import { KnowledgeDropdown } from "@/components/knowledge-dropdown"
import { EndpointType } from "@/contexts/chat-context"

View file

@ -12,6 +12,7 @@ import { SiGoogledrive } from "react-icons/si"
import { ProtectedRoute } from "@/components/protected-route"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
import { useTask } from "@/contexts/task-context"
import { KnowledgeDropdown } from "@/components/knowledge-dropdown"
type FacetBucket = { key: string; count: number }
@ -77,7 +78,7 @@ function SearchPage() {
const router = useRouter()
const { isMenuOpen } = useTask()
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter()
const [query, setQuery] = useState("")
const [query, setQuery] = useState("*")
const [loading, setLoading] = useState(false)
const [chunkResults, setChunkResults] = useState<ChunkResult[]>([])
const [fileResults, setFileResults] = useState<FileResult[]>([])
@ -372,6 +373,13 @@ function SearchPage() {
}
}, [parsedFilterData])
// Auto-search on mount with "*"
useEffect(() => {
if (query === "*") {
handleSearch()
}
}, []) // Only run once on mount
// Initial stats fetch and refresh when filter changes
useEffect(() => {
fetchStats()
@ -411,225 +419,150 @@ function SearchPage() {
<Search className="h-4 w-4" />
)}
</Button>
<Button
type="button"
onClick={() => router.push('/settings')}
className="rounded-lg h-12 px-4 flex-shrink-0"
>
<Plus className="h-4 w-4 mr-2" />
Add Knowledge
</Button>
<div className="flex-shrink-0">
<KnowledgeDropdown variant="button" />
</div>
</form>
</div>
{/* Results Area */}
<div className="flex-1 overflow-y-auto">
{searchPerformed ? (
<div className="space-y-4">
{fileResults.length === 0 && chunkResults.length === 0 ? (
<div className="text-center py-12">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">No documents found</p>
<p className="text-sm text-muted-foreground/70 mt-2">
Try adjusting your search terms
</p>
</div>
) : (
<>
{/* View Toggle and Results Count */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{viewMode === 'files' ? fileResults.length : chunkResults.length} {viewMode === 'files' ? 'file' : 'result'}{(viewMode === 'files' ? fileResults.length : chunkResults.length) !== 1 ? 's' : ''} found
</div>
<div className="flex gap-2">
<Button
variant={viewMode === 'files' ? 'default' : 'outline'}
size="sm"
onClick={() => {setViewMode('files'); setSelectedFile(null)}}
>
Files
</Button>
<Button
variant={viewMode === 'chunks' ? 'default' : 'outline'}
size="sm"
onClick={() => {setViewMode('chunks'); setSelectedFile(null)}}
>
Chunks
</Button>
</div>
</div>
{/* Results Display */}
<div className="space-y-4">
{viewMode === 'files' ? (
selectedFile ? (
// Show chunks for selected file
<>
<div className="flex items-center gap-2 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
>
Back to files
</Button>
<span className="text-sm text-muted-foreground">
Chunks from {selectedFile}
</span>
</div>
{chunkResults
.filter(chunk => chunk.filename === selectedFile)
.map((chunk, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{chunk.filename}</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{chunk.mimetype} Page {chunk.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</>
) : (
// Show files table
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50 bg-muted/10">
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Source</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Type</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Size</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Chunks</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Score</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Owner</th>
</tr>
</thead>
<tbody>
{fileResults.map((file, index) => (
<tr
key={index}
className="border-b border-border/30 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => setSelectedFile(file.filename)}
>
<td className="p-3">
<div className="flex items-center gap-2">
{getSourceIcon(file.connector_type)}
<span className="font-medium truncate" title={file.filename}>
{file.filename}
</span>
</div>
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.mimetype}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.size ? `${Math.round(file.size / 1024)} KB` : '—'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.chunkCount}
</td>
<td className="p-3">
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{file.avgScore.toFixed(2)}
</span>
</td>
<td className="p-3 text-sm text-muted-foreground" title={file.owner_email}>
{file.owner_name || file.owner || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : (
// Show chunks view
chunkResults.map((result, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{result.filename}</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{result.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{result.mimetype} Page {result.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{result.text}
</p>
</div>
))
)}
</div>
</>
)}
</div>
) : (
/* Knowledge Overview - Show when no search has been performed */
<div className="bg-muted/20 rounded-lg border border-border/50">
<div className="p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold">Knowledge Overview</h2>
</div>
{/* Documents row */}
<div className="mb-6">
<div className="text-sm text-muted-foreground mb-1">Total documents</div>
<div className="text-2xl font-semibold">{statsLoading ? '—' : totalDocs}</div>
</div>
{/* Separator */}
<div className="border-t border-border/50 my-6" />
{/* Chunks and breakdown */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Total chunks</div>
<div className="text-2xl font-semibold">{statsLoading ? '—' : totalChunks}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Top sources</div>
<div className="flex flex-wrap gap-2">
{(facetStats?.connector_types || []).slice(0,5).map((b) => (
<Badge key={`connector-${b.key}`} variant="secondary">
<span className="capitalize">{b.key}</span> · {b.count}
</Badge>
))}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Top types</div>
<div className="flex flex-wrap gap-2">
{(facetStats?.document_types || []).slice(0,5).map((b) => (
<Badge key={`type-${b.key}`} variant="secondary">{b.key} · {b.count}</Badge>
))}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Top owners</div>
<div className="flex flex-wrap gap-2">
{(facetStats?.owners || []).slice(0,5).map((b) => (
<Badge key={`owner-${b.key}`} variant="secondary">{b.key || 'unknown'} · {b.count}</Badge>
))}
</div>
</div>
</div>
<div className="space-y-4">
{fileResults.length === 0 && chunkResults.length === 0 && !loading ? (
<div className="text-center py-12">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">No documents found</p>
<p className="text-sm text-muted-foreground/70 mt-2">
Try adjusting your search terms
</p>
</div>
</div>
)}
) : (
<>
{/* Results Count */}
<div className="mb-4">
<div className="text-sm text-muted-foreground">
{fileResults.length} file{fileResults.length !== 1 ? 's' : ''} found
</div>
</div>
{/* Results Display */}
<div className="space-y-4">
{viewMode === 'files' ? (
selectedFile ? (
// Show chunks for selected file
<>
<div className="flex items-center gap-2 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
>
Back to files
</Button>
<span className="text-sm text-muted-foreground">
Chunks from {selectedFile}
</span>
</div>
{chunkResults
.filter(chunk => chunk.filename === selectedFile)
.map((chunk, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{chunk.filename}</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{chunk.mimetype} Page {chunk.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</>
) : (
// Show files table
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50 bg-muted/10">
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Source</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Type</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Size</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Matching chunks</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Average score</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Owner</th>
</tr>
</thead>
<tbody>
{fileResults.map((file, index) => (
<tr
key={index}
className="border-b border-border/30 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => setSelectedFile(file.filename)}
>
<td className="p-3">
<div className="flex items-center gap-2">
{getSourceIcon(file.connector_type)}
<span className="font-medium truncate" title={file.filename}>
{file.filename}
</span>
</div>
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.mimetype}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.size ? `${Math.round(file.size / 1024)} KB` : '—'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.chunkCount}
</td>
<td className="p-3">
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{file.avgScore.toFixed(2)}
</span>
</td>
<td className="p-3 text-sm text-muted-foreground" title={file.owner_email}>
{file.owner_name || file.owner || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : (
// Show chunks view
chunkResults.map((result, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{result.filename}</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{result.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{result.mimetype} Page {result.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{result.text}
</p>
</div>
))
)}
</div>
</>
)}
</div>
</div>
</div>
</div>

View file

@ -425,133 +425,25 @@ function KnowledgeSourcesPage() {
return (
<div className="space-y-8">
{/* Upload Section */}
<div className="space-y-6">
{/* Agent Behavior Section */}
<div className="flex items-center justify-between py-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight mb-2">Import</h2>
<h3 className="text-lg font-medium">Agent behavior</h3>
<p className="text-sm text-muted-foreground">Adjust your retrieval agent flow</p>
</div>
<div className={`grid gap-6 ${awsEnabled ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
{/* File Upload Card */}
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Add File
</CardTitle>
<CardDescription>
Import a single document to be processed and indexed
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end">
<FileUploadArea
onFileSelected={handleDirectFileUpload}
isLoading={fileUploadLoading}
/>
</CardContent>
</Card>
{/* Folder Upload Card */}
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5" />
Process Folder
</CardTitle>
<CardDescription>
Process all documents in a folder path
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end">
<form onSubmit={handlePathUpload} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="folder-path">Folder Path</Label>
<Input
id="folder-path"
type="text"
placeholder="/path/to/documents"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
/>
</div>
<Button
type="submit"
disabled={!folderPath.trim() || pathUploadLoading}
className="w-full"
>
{pathUploadLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<FolderOpen className="mr-2 h-4 w-4" />
Process Folder
</>
)}
</Button>
</form>
</CardContent>
</Card>
{/* S3 Bucket Upload Card - only show if AWS is enabled */}
{awsEnabled && (
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cloud className="h-5 w-5" />
Process S3 Bucket
</CardTitle>
<CardDescription>
Process all documents from an S3 bucket. AWS credentials must be configured.
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end">
<form onSubmit={handleBucketUpload} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bucket-url">S3 URL</Label>
<Input
id="bucket-url"
type="text"
placeholder="s3://bucket/path"
value={bucketUrl}
onChange={(e) => setBucketUrl(e.target.value)}
/>
</div>
<Button
type="submit"
disabled={!bucketUrl.trim() || bucketUploadLoading}
className="w-full"
>
{bucketUploadLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
Process Bucket
</>
)}
</Button>
</form>
</CardContent>
</Card>
)}
</div>
{/* Upload Status */}
{uploadStatus && (
<Card className="bg-muted/20">
<CardContent className="pt-6">
<p className="text-sm">{uploadStatus}</p>
</CardContent>
</Card>
)}
<Button
onClick={() => window.open('http://localhost:7860/flow/1098eea1-6649-4e1d-aed1-b77249fb8dd0', '_blank')}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" viewBox="0 0 24 22" className="h-4 w-4 mr-2">
<path fill="currentColor" d="M13.0486 0.462158H9.75399C9.44371 0.462158 9.14614 0.586082 8.92674 0.806667L4.03751 5.72232C3.81811 5.9429 3.52054 6.06682 3.21026 6.06682H1.16992C0.511975 6.06682 -0.0165756 6.61212 0.000397655 7.2734L0.0515933 9.26798C0.0679586 9.90556 0.586745 10.4139 1.22111 10.4139H3.59097C3.90124 10.4139 4.19881 10.2899 4.41821 10.0694L9.34823 5.11269C9.56763 4.89211 9.8652 4.76818 10.1755 4.76818H13.0486C13.6947 4.76818 14.2185 4.24157 14.2185 3.59195V1.63839C14.2185 0.988773 13.6947 0.462158 13.0486 0.462158Z"></path>
<path fill="currentColor" d="M19.5355 11.5862H22.8301C23.4762 11.5862 24 12.1128 24 12.7624V14.716C24 15.3656 23.4762 15.8922 22.8301 15.8922H19.957C19.6467 15.8922 19.3491 16.0161 19.1297 16.2367L14.1997 21.1934C13.9803 21.414 13.6827 21.5379 13.3725 21.5379H11.0026C10.3682 21.5379 9.84945 21.0296 9.83309 20.392L9.78189 18.3974C9.76492 17.7361 10.2935 17.1908 10.9514 17.1908H12.9918C13.302 17.1908 13.5996 17.0669 13.819 16.8463L18.7082 11.9307C18.9276 11.7101 19.2252 11.5862 19.5355 11.5862Z"></path>
<path fill="currentColor" d="M19.5355 2.9796L22.8301 2.9796C23.4762 2.9796 24 3.50622 24 4.15583V6.1094C24 6.75901 23.4762 7.28563 22.8301 7.28563H19.957C19.6467 7.28563 19.3491 7.40955 19.1297 7.63014L14.1997 12.5868C13.9803 12.8074 13.6827 12.9313 13.3725 12.9313H10.493C10.1913 12.9313 9.90126 13.0485 9.68346 13.2583L4.14867 18.5917C3.93087 18.8016 3.64085 18.9187 3.33917 18.9187H1.32174C0.675616 18.9187 0.151832 18.3921 0.151832 17.7425V15.7343C0.151832 15.0846 0.675616 14.558 1.32174 14.558H3.32468C3.63496 14.558 3.93253 14.4341 4.15193 14.2135L9.40827 8.92878C9.62767 8.70819 9.92524 8.58427 10.2355 8.58427H12.9918C13.302 8.58427 13.5996 8.46034 13.819 8.23976L18.7082 3.32411C18.9276 3.10353 19.2252 2.9796 19.5355 2.9796Z"></path>
</svg>
Edit in Langflow
</Button>
</div>
{/* Connectors Section */}
<div className="space-y-6">
<div>
@ -559,40 +451,29 @@ function KnowledgeSourcesPage() {
</div>
{/* Sync Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Sync Settings
</CardTitle>
<CardDescription>
Configure how many files to sync when manually triggering a sync
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center text-sm">
<div className="flex items-center gap-3">
<Label htmlFor="maxFiles" className="font-medium whitespace-nowrap">
Max files per sync:
</Label>
<Input
id="maxFiles"
type="number"
value={maxFiles}
onChange={(e) => setMaxFiles(parseInt(e.target.value) || 10)}
className="w-16 min-w-16 max-w-16 flex-shrink-0"
min="1"
max="100"
/>
<span className="text-muted-foreground whitespace-nowrap">
(Leave blank or set to 0 for unlimited)
</span>
</div>
</div>
<div className="flex items-center justify-between py-4">
<div>
<h3 className="text-lg font-medium">Sync Settings</h3>
<p className="text-sm text-muted-foreground">Configure how many files to sync when manually triggering a sync</p>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="maxFiles" className="font-medium whitespace-nowrap">
Max files per sync:
</Label>
<div className="relative">
<Input
id="maxFiles"
type="number"
value={maxFiles}
onChange={(e) => setMaxFiles(parseInt(e.target.value) || 10)}
className="w-16 min-w-16 max-w-16 flex-shrink-0"
min="1"
max="100"
title="Leave blank or set to 0 for unlimited"
/>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Connectors Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">