frontend redesign

This commit is contained in:
estevez.sebastian@gmail.com 2025-08-14 15:06:38 -04:00
parent d780013a2b
commit d97a2fb7b5
20 changed files with 2397 additions and 1421 deletions

View file

@ -0,0 +1,426 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { ChevronDown, Filter, Search, X, Loader2, Plus, Save } from "lucide-react"
import { cn } from "@/lib/utils"
interface KnowledgeFilter {
id: string
name: string
description: string
query_data: string
owner: string
created_at: string
updated_at: string
}
interface ParsedQueryData {
query: string
filters: {
data_sources: string[]
document_types: string[]
owners: string[]
}
limit: number
scoreThreshold: number
}
interface KnowledgeFilterDropdownProps {
selectedFilter: KnowledgeFilter | null
onFilterSelect: (filter: KnowledgeFilter | null) => void
}
export function KnowledgeFilterDropdown({ selectedFilter, onFilterSelect }: KnowledgeFilterDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [filters, setFilters] = useState<KnowledgeFilter[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [showCreateModal, setShowCreateModal] = useState(false)
const [createName, setCreateName] = useState("")
const [createDescription, setCreateDescription] = useState("")
const [creating, setCreating] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const loadFilters = async (query = "") => {
setLoading(true)
try {
const response = await fetch("/api/knowledge-filter/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: 20 // Limit for dropdown
}),
})
const result = await response.json()
if (response.ok && result.success) {
setFilters(result.filters)
} else {
console.error("Failed to load knowledge filters:", result.error)
setFilters([])
}
} catch (error) {
console.error("Error loading knowledge filters:", error)
setFilters([])
} finally {
setLoading(false)
}
}
const deleteFilter = async (filterId: string, e: React.MouseEvent) => {
e.stopPropagation()
try {
const response = await fetch(`/api/knowledge-filter/${filterId}`, {
method: "DELETE",
})
if (response.ok) {
// Remove from local state
setFilters(prev => prev.filter(f => f.id !== filterId))
// If this was the selected filter, clear selection
if (selectedFilter?.id === filterId) {
onFilterSelect(null)
}
} else {
console.error("Failed to delete knowledge filter")
}
} catch (error) {
console.error("Error deleting knowledge filter:", error)
}
}
const handleFilterSelect = (filter: KnowledgeFilter) => {
onFilterSelect(filter)
setIsOpen(false)
}
const handleClearFilter = () => {
onFilterSelect(null)
setIsOpen(false)
}
const handleCreateNew = () => {
setIsOpen(false)
setShowCreateModal(true)
}
const handleCreateFilter = async () => {
if (!createName.trim()) return
setCreating(true)
try {
// Create a basic filter with wildcards (match everything by default)
const defaultFilterData = {
query: "",
filters: {
data_sources: ["*"],
document_types: ["*"],
owners: ["*"]
},
limit: 10,
scoreThreshold: 0
}
const response = await fetch("/api/knowledge-filter", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: createName.trim(),
description: createDescription.trim(),
queryData: JSON.stringify(defaultFilterData)
}),
})
const result = await response.json()
if (response.ok && result.success) {
// Create the new filter object
const newFilter: KnowledgeFilter = {
id: result.filter.id,
name: createName.trim(),
description: createDescription.trim(),
query_data: JSON.stringify(defaultFilterData),
owner: result.filter.owner,
created_at: result.filter.created_at,
updated_at: result.filter.updated_at
}
// Add to local filters list
setFilters(prev => [newFilter, ...prev])
// Select the new filter
onFilterSelect(newFilter)
// Close modal and reset form
setShowCreateModal(false)
setCreateName("")
setCreateDescription("")
} else {
console.error("Failed to create knowledge filter:", result.error)
}
} catch (error) {
console.error("Error creating knowledge filter:", error)
} finally {
setCreating(false)
}
}
const handleCancelCreate = () => {
setShowCreateModal(false)
setCreateName("")
setCreateDescription("")
}
const getFilterSummary = (filter: KnowledgeFilter): string => {
try {
const parsed = JSON.parse(filter.query_data) as ParsedQueryData
const parts = []
if (parsed.query) parts.push(`"${parsed.query}"`)
if (parsed.filters.data_sources.length > 0) parts.push(`${parsed.filters.data_sources.length} sources`)
if (parsed.filters.document_types.length > 0) parts.push(`${parsed.filters.document_types.length} types`)
if (parsed.filters.owners.length > 0) parts.push(`${parsed.filters.owners.length} owners`)
return parts.join(" • ") || "No filters"
} catch {
return "Invalid filter"
}
}
useEffect(() => {
if (isOpen) {
loadFilters()
}
}, [isOpen])
useEffect(() => {
const timeoutId = setTimeout(() => {
if (isOpen) {
loadFilters(searchQuery)
}
}, 300)
return () => clearTimeout(timeoutId)
}, [searchQuery, isOpen])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
return (
<div className="relative" ref={dropdownRef}>
<Button
variant={selectedFilter ? "default" : "outline"}
size="sm"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex items-center gap-2 h-8 px-3",
selectedFilter
? "hover:bg-primary hover:text-primary-foreground"
: "hover:bg-transparent hover:text-foreground hover:border-border"
)}
>
<Filter className="h-3 w-3" />
{selectedFilter ? (
<span className="max-w-32 truncate">{selectedFilter.name}</span>
) : (
<span>All Knowledge</span>
)}
<ChevronDown className={cn("h-3 w-3 transition-transform", isOpen && "rotate-180")} />
</Button>
{isOpen && (
<Card className="absolute right-0 top-full mt-1 w-80 max-h-96 overflow-hidden z-50 shadow-lg border-border/50 bg-card/95 backdrop-blur-sm">
<CardContent className="p-0">
{/* Search Header */}
<div className="p-3 border-b border-border/50">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder="Search filters..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-8 text-sm"
/>
</div>
</div>
{/* Filter List */}
<div className="max-h-64 overflow-y-auto">
{/* Clear filter option */}
<div
onClick={handleClearFilter}
className={cn(
"flex items-center gap-3 p-3 hover:bg-accent hover:text-accent-foreground cursor-pointer border-b border-border/30 transition-colors",
!selectedFilter && "bg-accent text-accent-foreground"
)}
>
<div className="flex items-center gap-2 flex-1">
<Filter className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm font-medium">All Knowledge</div>
<div className="text-xs text-muted-foreground">No filters applied</div>
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="ml-2 text-sm text-muted-foreground">Loading...</span>
</div>
) : filters.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery ? "No filters found" : "No saved filters"}
</div>
) : (
filters.map((filter) => (
<div
key={filter.id}
onClick={() => handleFilterSelect(filter)}
className={cn(
"flex items-center gap-3 p-3 hover:bg-accent hover:text-accent-foreground cursor-pointer group transition-colors",
selectedFilter?.id === filter.id && "bg-accent text-accent-foreground"
)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Filter className="h-4 w-4 text-muted-foreground group-hover:text-accent-foreground flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate group-hover:text-accent-foreground">{filter.name}</div>
<div className="text-xs text-muted-foreground group-hover:text-accent-foreground/70 truncate">
{getFilterSummary(filter)}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => deleteFilter(filter.id, e)}
className="opacity-0 group-hover:opacity-100 h-6 w-6 p-0 bg-transparent hover:bg-gray-700 hover:text-white transition-all duration-200 border border-transparent hover:border-gray-600"
>
<X className="h-3 w-3 text-gray-400 hover:text-white" />
</Button>
</div>
))
)}
</div>
{/* Create New Filter Option */}
<div className="border-t border-border/50">
<div
onClick={handleCreateNew}
className="flex items-center gap-3 p-3 hover:bg-accent hover:text-accent-foreground cursor-pointer transition-colors"
>
<Plus className="h-4 w-4 text-green-500" />
<div>
<div className="text-sm font-medium text-green-600">Create New Filter</div>
<div className="text-xs text-muted-foreground">Save current search as filter</div>
</div>
</div>
</div>
{/* Selected Filter Details */}
{selectedFilter && (
<div className="border-t border-border/50 p-3 bg-muted/20">
<div className="text-xs text-muted-foreground">
<strong>Selected:</strong> {selectedFilter.name}
</div>
{selectedFilter.description && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{selectedFilter.description}
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Create Filter Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Create New Knowledge Filter</h3>
<div className="space-y-4">
<div>
<Label htmlFor="filter-name" className="font-medium">
Name <span className="text-red-400">*</span>
</Label>
<Input
id="filter-name"
type="text"
placeholder="Enter filter name"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="filter-description" className="font-medium">
Description (optional)
</Label>
<Textarea
id="filter-description"
placeholder="Brief description of this filter"
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
className="mt-1"
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
onClick={handleCancelCreate}
disabled={creating}
>
Cancel
</Button>
<Button
onClick={handleCreateFilter}
disabled={!createName.trim() || creating}
className="flex items-center gap-2"
>
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Save className="h-4 w-4" />
Create Filter
</>
)}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,611 @@
"use client"
import { useState, useEffect } from 'react'
import { Filter, X, Edit3, Save, Settings, ChevronDown, ChevronUp, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useKnowledgeFilter } from '@/contexts/knowledge-filter-context'
interface ParsedQueryData {
query: string
filters: {
data_sources: string[]
document_types: string[]
owners: string[]
}
limit: number
scoreThreshold: number
}
interface FacetBucket {
key: string
count: number
}
interface AvailableFacets {
data_sources: FacetBucket[]
document_types: FacetBucket[]
owners: FacetBucket[]
}
export function KnowledgeFilterPanel() {
const { selectedFilter, parsedFilterData, setSelectedFilter, isPanelOpen, closePanelOnly } = useKnowledgeFilter()
// Edit mode states
const [isEditingMeta, setIsEditingMeta] = useState(false)
const [editingName, setEditingName] = useState('')
const [editingDescription, setEditingDescription] = useState('')
const [isSaving, setIsSaving] = useState(false)
// Filter configuration states (mirror search page exactly)
const [query, setQuery] = useState('')
const [selectedFilters, setSelectedFilters] = useState({
data_sources: ["*"] as string[], // Default to wildcard
document_types: ["*"] as string[], // Default to wildcard
owners: ["*"] as string[] // Default to wildcard
})
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const [openSections, setOpenSections] = useState({
data_sources: true,
document_types: true,
owners: true
})
// Available facets (loaded from API)
const [availableFacets, setAvailableFacets] = useState<AvailableFacets>({
data_sources: [],
document_types: [],
owners: []
})
// Load current filter data into controls
useEffect(() => {
if (selectedFilter && parsedFilterData) {
setQuery(parsedFilterData.query || '')
// Set the actual filter selections from the saved knowledge filter
const filters = parsedFilterData.filters
// If arrays are empty, default to wildcard (match everything)
// Otherwise use the specific selections from the saved filter
const processedFilters = {
data_sources: filters.data_sources.length === 0 ? ["*"] : filters.data_sources,
document_types: filters.document_types.length === 0 ? ["*"] : filters.document_types,
owners: filters.owners.length === 0 ? ["*"] : filters.owners
}
console.log("[DEBUG] Loading filter selections:", processedFilters)
setSelectedFilters(processedFilters)
setResultLimit(parsedFilterData.limit || 10)
setScoreThreshold(parsedFilterData.scoreThreshold || 0)
setEditingName(selectedFilter.name)
setEditingDescription(selectedFilter.description || '')
}
}, [selectedFilter, parsedFilterData])
// Load available facets from API
useEffect(() => {
if (isPanelOpen) {
loadAvailableFacets()
}
}, [isPanelOpen])
const loadAvailableFacets = async () => {
console.log("[DEBUG] Loading available facets...")
try {
// Do a search to get facets (similar to search page)
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "*", // Use wildcard like search page to get all documents/facets
limit: 1,
scoreThreshold: 0
// Omit filters entirely to get all available facets
}),
})
const result = await response.json()
console.log("[DEBUG] Search API response:", result)
if (response.ok && result.aggregations) {
const facets = {
data_sources: result.aggregations.data_sources?.buckets || [],
document_types: result.aggregations.document_types?.buckets || [],
owners: result.aggregations.owners?.buckets || []
}
console.log("[DEBUG] Setting facets:", facets)
setAvailableFacets(facets)
} else {
console.log("[DEBUG] No aggregations in response or response not ok")
}
} catch (error) {
console.error("Failed to load available facets:", error)
}
}
// Don't render if panel is closed or no filter selected
if (!isPanelOpen || !selectedFilter || !parsedFilterData) return null
const toggleSection = (section: keyof typeof openSections) => {
setOpenSections(prev => ({
...prev,
[section]: !prev[section]
}))
}
const handleFilterChange = (facetType: keyof typeof selectedFilters, value: string, checked: boolean) => {
setSelectedFilters(prev => ({
...prev,
[facetType]: checked
? [...prev[facetType], value]
: prev[facetType].filter(item => item !== value)
}))
}
const selectAllFilters = () => {
// Use wildcards instead of listing all specific items
setSelectedFilters({
data_sources: ["*"],
document_types: ["*"],
owners: ["*"]
})
}
const clearAllFilters = () => {
setSelectedFilters({
data_sources: [],
document_types: [],
owners: []
})
}
const handleEditMeta = () => {
setIsEditingMeta(true)
}
const handleCancelEdit = () => {
setIsEditingMeta(false)
setEditingName(selectedFilter.name)
setEditingDescription(selectedFilter.description || '')
}
const handleSaveMeta = async () => {
if (!editingName.trim()) return
setIsSaving(true)
try {
const response = await fetch(`/api/knowledge-filter/${selectedFilter.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: editingName.trim(),
description: editingDescription.trim(),
}),
})
const result = await response.json()
if (response.ok && result.success) {
const updatedFilter = {
...selectedFilter,
name: editingName.trim(),
description: editingDescription.trim(),
updated_at: new Date().toISOString(),
}
setSelectedFilter(updatedFilter)
setIsEditingMeta(false)
}
} catch (error) {
console.error('Error updating filter:', error)
} finally {
setIsSaving(false)
}
}
const handleSaveConfiguration = async () => {
const filterData = {
query,
filters: selectedFilters,
limit: resultLimit,
scoreThreshold
}
setIsSaving(true)
try {
const response = await fetch(`/api/knowledge-filter/${selectedFilter.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
queryData: JSON.stringify(filterData)
}),
})
const result = await response.json()
if (response.ok && result.success) {
// Update the filter in context
const updatedFilter = {
...selectedFilter,
query_data: JSON.stringify(filterData),
updated_at: new Date().toISOString(),
}
setSelectedFilter(updatedFilter)
}
} catch (error) {
console.error('Error updating filter configuration:', error)
} finally {
setIsSaving(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const FacetSection = ({
title,
buckets,
facetType,
isOpen,
onToggle
}: {
title: string
buckets: FacetBucket[]
facetType: keyof typeof selectedFilters
isOpen: boolean
onToggle: () => void
}) => {
if (!buckets || buckets.length === 0) return null
const isAllSelected = selectedFilters[facetType].includes("*") // Wildcard
const hasSpecificSelections = selectedFilters[facetType].some(item => item !== "*")
const handleAllToggle = (checked: boolean) => {
if (checked) {
// Select "All" - clear specific selections and add wildcard
setSelectedFilters(prev => ({
...prev,
[facetType]: ["*"]
}))
} else {
// Unselect "All" - remove wildcard but keep any specific selections
setSelectedFilters(prev => ({
...prev,
[facetType]: prev[facetType].filter(item => item !== "*")
}))
}
}
const handleSpecificToggle = (value: string, checked: boolean) => {
setSelectedFilters(prev => {
let newValues = [...prev[facetType]]
// Remove wildcard if selecting specific items
newValues = newValues.filter(item => item !== "*")
if (checked) {
newValues.push(value)
} else {
newValues = newValues.filter(item => item !== value)
}
return {
...prev,
[facetType]: newValues
}
})
}
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-0 h-auto font-medium text-left">
<span className="text-sm font-medium">{title}</span>
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mt-3">
{/* "All" wildcard option */}
<div className="flex items-center space-x-2 pb-2 border-b border-border/30">
<Checkbox
id={`${facetType}-all`}
checked={isAllSelected}
onCheckedChange={handleAllToggle}
/>
<Label
htmlFor={`${facetType}-all`}
className="text-sm font-medium flex-1 cursor-pointer flex items-center justify-between"
>
<span>All {title}</span>
<span className="text-xs text-blue-500 bg-blue-500/10 px-1.5 py-0.5 rounded ml-2 flex-shrink-0">
*
</span>
</Label>
</div>
{/* Individual items - disabled if "All" is selected */}
{buckets.map((bucket, index) => {
const isSelected = selectedFilters[facetType].includes(bucket.key)
const isDisabled = isAllSelected
return (
<div key={index} className={`flex items-center space-x-2 ${isDisabled ? 'opacity-50' : ''}`}>
<Checkbox
id={`${facetType}-${index}`}
checked={isSelected}
disabled={isDisabled}
onCheckedChange={(checked) =>
handleSpecificToggle(bucket.key, checked as boolean)
}
/>
<Label
htmlFor={`${facetType}-${index}`}
className={`text-sm font-normal flex-1 flex items-center justify-between ${isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className="truncate" title={bucket.key}>
{bucket.key}
</span>
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded ml-2 flex-shrink-0">
{bucket.count}
</span>
</Label>
</div>
)
})}
</CollapsibleContent>
</Collapsible>
)
}
return (
<div className="fixed right-0 top-14 bottom-0 w-80 bg-background border-l border-border/40 z-40 overflow-y-auto">
<Card className="h-full rounded-none border-0 shadow-lg">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Settings className="h-5 w-5" />
Knowledge Filter
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={closePanelOnly}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<CardDescription>
Configure your knowledge filter settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Filter Name and Description */}
<div className="space-y-4">
{isEditingMeta ? (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="filter-name">Name</Label>
<Input
id="filter-name"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
placeholder="Filter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="filter-description">Description</Label>
<Textarea
id="filter-description"
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleSaveMeta}
disabled={!editingName.trim() || isSaving}
size="sm"
className="flex-1"
>
<Save className="h-3 w-3 mr-1" />
{isSaving ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={handleCancelEdit}
variant="outline"
size="sm"
className="flex-1"
>
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-lg">{selectedFilter.name}</h3>
{selectedFilter.description && (
<p className="text-sm text-muted-foreground mt-1">
{selectedFilter.description}
</p>
)}
</div>
<Button
onClick={handleEditMeta}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
</div>
<div className="text-xs text-muted-foreground">
Created {formatDate(selectedFilter.created_at)}
{selectedFilter.updated_at !== selectedFilter.created_at && (
<span> Updated {formatDate(selectedFilter.updated_at)}</span>
)}
</div>
</div>
)}
</div>
{/* Search Query */}
<div className="space-y-2">
<Label htmlFor="search-query" className="text-sm font-medium">Search Query</Label>
<Input
id="search-query"
type="text"
placeholder="e.g., 'financial reports from Q4'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="bg-background/50 border-border/50"
/>
</div>
{/* Facet Sections - exactly like search page */}
<div className="space-y-6">
<FacetSection
title="Data Sources"
buckets={availableFacets.data_sources || []}
facetType="data_sources"
isOpen={openSections.data_sources}
onToggle={() => toggleSection('data_sources')}
/>
<FacetSection
title="Document Types"
buckets={availableFacets.document_types || []}
facetType="document_types"
isOpen={openSections.document_types}
onToggle={() => toggleSection('document_types')}
/>
<FacetSection
title="Owners"
buckets={availableFacets.owners || []}
facetType="owners"
isOpen={openSections.owners}
onToggle={() => toggleSection('owners')}
/>
{/* All/None buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllFilters}
className="h-auto px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 border-border/50"
>
All
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAllFilters}
className="h-auto px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 border-border/50"
>
None
</Button>
</div>
{/* Result Limit Control - exactly like search page */}
<div className="space-y-4 pt-4 border-t border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Limit</Label>
<Input
type="number"
min="1"
max="1000"
value={resultLimit}
onChange={(e) => {
const newLimit = Math.max(1, Math.min(1000, parseInt(e.target.value) || 1))
setResultLimit(newLimit)
}}
className="w-16 h-6 text-xs text-center"
/>
</div>
<input
type="range"
min={1}
max={1000}
value={resultLimit}
onChange={(e) => setResultLimit(parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
{/* Score Threshold Control - exactly like search page */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Score Threshold</Label>
<Input
type="number"
min="0"
max="1"
step="0.01"
value={scoreThreshold}
onChange={(e) => setScoreThreshold(parseFloat(e.target.value) || 0)}
className="w-16 h-6 text-xs text-center"
/>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={scoreThreshold}
onChange={(e) => setScoreThreshold(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
{/* Save Configuration Button */}
<div className="pt-4 border-t border-border/50">
<Button
onClick={handleSaveConfiguration}
disabled={isSaving}
className="w-full"
size="sm"
>
{isSaving ? (
<>
<RefreshCw className="h-3 w-3 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-3 w-3 mr-2" />
Save Configuration
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -1,11 +1,17 @@
"use client"
import { Navigation } from "@/components/navigation";
import { ModeToggle } from "@/components/mode-toggle";
import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
interface NavigationLayoutProps {
children: React.ReactNode;
}
export function NavigationLayout({ children }: NavigationLayoutProps) {
const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();
return (
<div className="h-full relative">
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] border-r border-border/40">
@ -24,7 +30,11 @@ export function NavigationLayout({ children }: NavigationLayoutProps) {
<div className="w-full flex-1 md:w-auto md:flex-none">
{/* Search component could go here */}
</div>
<nav className="flex items-center">
<nav className="flex items-center space-x-2">
<KnowledgeFilterDropdown
selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter}
/>
<ModeToggle />
</nav>
</div>

View file

@ -2,7 +2,7 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Search, Settings, MessageCircle, PlugZap, BookOpenCheck } from "lucide-react"
import { Search, Database, MessageCircle } from "lucide-react"
import { cn } from "@/lib/utils"
export function Navigation() {
@ -10,16 +10,16 @@ export function Navigation() {
const routes = [
{
label: "Ingest",
icon: Settings,
href: "/admin",
active: pathname === "/admin",
label: "Knowledge Sources",
icon: Database,
href: "/knowledge-sources",
active: pathname === "/" || pathname === "/knowledge-sources",
},
{
label: "Search",
icon: Search,
href: "/",
active: pathname === "/",
href: "/search",
active: pathname === "/search",
},
{
label: "Chat",
@ -27,18 +27,6 @@ export function Navigation() {
href: "/chat",
active: pathname === "/chat",
},
{
label: "Knowledge Filters",
icon: BookOpenCheck,
href: "/knowledge-filters",
active: pathname.startsWith("/knowledge-filters"),
},
{
label: "Connectors",
icon: PlugZap,
href: "/connectors",
active: pathname.startsWith("/connectors"),
},
]
return (

View file

@ -84,7 +84,7 @@ function AuthCallbackContent() {
await refreshAuth()
// Get redirect URL from login page
const redirectTo = searchParams.get('redirect') || '/'
const redirectTo = searchParams.get('redirect') || '/knowledge-sources'
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')

View file

@ -1,13 +1,13 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { useTask } from "@/contexts/task-context"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
interface Message {
role: "user" | "assistant"
@ -56,7 +56,6 @@ interface RequestBody {
}
function ChatPage() {
const searchParams = useSearchParams()
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
@ -78,51 +77,9 @@ function ChatPage() {
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { addTask } = useTask()
const { selectedFilter, parsedFilterData } = useKnowledgeFilter()
// Context-related state
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({
data_sources: [],
document_types: [],
owners: []
})
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const [loadedContextName, setLoadedContextName] = useState<string | null>(null)
// Load knowledge filter if filterId is provided in URL
useEffect(() => {
const filterId = searchParams.get('filterId')
if (filterId) {
loadKnowledgeFilter(filterId)
}
}, [searchParams])
const loadKnowledgeFilter = async (filterId: string) => {
try {
const response = await fetch(`/api/knowledge-filter/${filterId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
const result = await response.json()
if (response.ok && result.success) {
const filter = result.filter
const parsedQueryData = JSON.parse(filter.query_data)
// Load the context data into state
setSelectedFilters(parsedQueryData.filters)
setResultLimit(parsedQueryData.limit)
setScoreThreshold(parsedQueryData.scoreThreshold)
setLoadedContextName(filter.name)
} else {
console.error("Failed to load knowledge filter:", result.error)
}
} catch (error) {
console.error("Error loading knowledge filter:", error)
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@ -279,20 +236,36 @@ function ChatPage() {
inputRef.current?.focus()
}, [])
// Update input when global filter query changes
useEffect(() => {
if (parsedFilterData?.query) {
setInput(parsedFilterData.query)
}
}, [parsedFilterData])
const handleSSEStream = async (userMessage: Message) => {
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
try {
const hasFilters = selectedFilters.data_sources.length > 0 ||
selectedFilters.document_types.length > 0 ||
selectedFilters.owners.length > 0
const requestBody: RequestBody = {
const requestBody: RequestBody = {
prompt: userMessage.content,
stream: true,
...(hasFilters && { filters: selectedFilters }),
limit: resultLimit,
scoreThreshold: scoreThreshold
...(parsedFilterData?.filters && (() => {
const filters = parsedFilterData.filters
const processed: SelectedFilters = {}
if (!filters.data_sources.includes("*")) {
processed.data_sources = filters.data_sources
}
if (!filters.document_types.includes("*")) {
processed.document_types = filters.document_types
}
if (!filters.owners.includes("*")) {
processed.owners = filters.owners
}
return Object.keys(processed).length > 0 ? { filters: processed } : {}
})()),
limit: parsedFilterData?.limit ?? 10,
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0
}
// Add previous_response_id if we have one for this endpoint
@ -748,15 +721,24 @@ function ChatPage() {
try {
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
const hasFilters = selectedFilters.data_sources.length > 0 ||
selectedFilters.document_types.length > 0 ||
selectedFilters.owners.length > 0
const requestBody: RequestBody = {
const requestBody: RequestBody = {
prompt: userMessage.content,
...(hasFilters && { filters: selectedFilters }),
limit: resultLimit,
scoreThreshold: scoreThreshold
...(parsedFilterData?.filters && (() => {
const filters = parsedFilterData.filters
const processed: SelectedFilters = {}
if (!filters.data_sources.includes("*")) {
processed.data_sources = filters.data_sources
}
if (!filters.document_types.includes("*")) {
processed.document_types = filters.document_types
}
if (!filters.owners.includes("*")) {
processed.owners = filters.owners
}
return Object.keys(processed).length > 0 ? { filters: processed } : {}
})()),
limit: parsedFilterData?.limit ?? 10,
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0
}
// Add previous_response_id if we have one for this endpoint
@ -1042,9 +1024,9 @@ function ChatPage() {
<div className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
<CardTitle>Chat</CardTitle>
{loadedContextName && (
{selectedFilter && (
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
Context: {loadedContextName}
Context: {selectedFilter.name}
</span>
)}
</div>
@ -1093,9 +1075,9 @@ function ChatPage() {
<CardDescription>
Chat with AI about your indexed documents using {endpoint === "chat" ? "Chat" : "Langflow"} endpoint
{asyncMode ? " with real-time streaming" : ""}
{loadedContextName && (
{selectedFilter && (
<span className="block text-blue-400 text-xs mt-1">
Using search context with configured filters and settings
Using knowledge filter: {selectedFilter.name}
</span>
)}
</CardDescription>
@ -1276,4 +1258,4 @@ export default function ProtectedChatPage() {
<ChatPage />
</ProtectedRoute>
)
}
}

View file

@ -1,347 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Search, Loader2, BookOpenCheck, Settings, Calendar, MessageCircle } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
interface KnowledgeFilter {
id: string
name: string
description: string
query_data: string
owner: string
created_at: string
updated_at: string
}
interface ParsedQueryData {
query: string
filters: {
data_sources: string[]
document_types: string[]
owners: string[]
}
limit: number
scoreThreshold: number
}
function KnowledgeFiltersPage() {
const router = useRouter()
const [filters, setFilters] = useState<KnowledgeFilter[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [selectedFilter, setSelectedFilter] = useState<KnowledgeFilter | null>(null)
const [parsedQueryData, setParsedQueryData] = useState<ParsedQueryData | null>(null)
const loadFilters = async (query = "") => {
setLoading(true)
try {
const response = await fetch("/api/knowledge-filter/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: 50
}),
})
const result = await response.json()
if (response.ok && result.success) {
setFilters(result.filters)
} else {
console.error("Failed to load knowledge filters:", result.error)
setFilters([])
}
} catch (error) {
console.error("Error loading knowledge filters:", error)
setFilters([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadFilters()
}, [])
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
await loadFilters(searchQuery)
}
const handleFilterClick = (filter: KnowledgeFilter) => {
setSelectedFilter(filter)
try {
const parsed = JSON.parse(filter.query_data) as ParsedQueryData
setParsedQueryData(parsed)
} catch (error) {
console.error("Error parsing query data:", error)
setParsedQueryData(null)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const handleSearchWithFilter = () => {
if (!selectedFilter) return
router.push(`/?filterId=${selectedFilter.id}`)
}
const handleChatWithFilter = () => {
if (!selectedFilter) return
router.push(`/chat?filterId=${selectedFilter.id}`)
}
return (
<div className="space-y-8">
{/* Hero Section */}
<div className="space-y-4">
<div className="mb-4">
<h1 className="text-4xl font-bold tracking-tight text-white">
Knowledge Filters
</h1>
</div>
<p className="text-xl text-muted-foreground">
Manage your saved knowledge filters
</p>
<p className="text-sm text-muted-foreground max-w-2xl">
View and manage your saved search configurations that help you focus on specific subsets of your knowledge base.
</p>
</div>
{/* Search Interface */}
<Card className="w-full bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpenCheck className="h-5 w-5" />
Search Knowledge Filters
</CardTitle>
<CardDescription>
Search through your saved knowledge filters by name or description
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-2">
<Input
type="text"
placeholder="Search knowledge filters..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20 flex-1"
/>
<Button
type="submit"
disabled={loading}
className="h-12 px-6 transition-all duration-200"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Searching...
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
Search
</>
)}
</Button>
</div>
</form>
<div className="flex gap-6">
{/* Context List */}
<div className="flex-1 space-y-4">
{filters.length === 0 ? (
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
<BookOpenCheck className="h-8 w-8 text-muted-foreground/50" />
</div>
<p className="text-lg font-medium text-muted-foreground">
No knowledge filters found
</p>
<p className="text-sm text-muted-foreground/70 max-w-md mx-auto">
Create your first knowledge filter by saving a search configuration from the search page.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filters.map((filter) => (
<Card
key={filter.id}
className={`bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/70 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/10 cursor-pointer ${
selectedFilter?.id === filter.id ? 'ring-2 ring-blue-500/50 bg-card/70' : ''
}`}
onClick={() => handleFilterClick(filter)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<h3 className="font-semibold text-lg">{filter.name}</h3>
{filter.description && (
<p className="text-sm text-muted-foreground">{filter.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Created {formatDate(filter.created_at)}</span>
</div>
{filter.updated_at !== filter.created_at && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Updated {formatDate(filter.updated_at)}</span>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Context Detail Panel */}
{selectedFilter && parsedQueryData && (
<div className="w-64 space-y-6 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Settings className="h-5 w-5" />
Knowledge Filter Details
</h2>
</div>
<div className="space-y-6">
{/* Query Information */}
<div className="space-y-2">
<Label className="text-sm font-medium">Query</Label>
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-sm">{parsedQueryData.query}</p>
</div>
</div>
{/* Filters */}
{(parsedQueryData.filters.data_sources.length > 0 ||
parsedQueryData.filters.document_types.length > 0 ||
parsedQueryData.filters.owners.length > 0) && (
<div className="space-y-4">
<Label className="text-sm font-medium">Filters</Label>
{parsedQueryData.filters.data_sources.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Data Sources</Label>
<div className="space-y-1">
{parsedQueryData.filters.data_sources.map((source, index) => (
<div key={index} className="px-2 py-1 bg-muted/30 rounded text-xs">
{source}
</div>
))}
</div>
</div>
)}
{parsedQueryData.filters.document_types.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Document Types</Label>
<div className="space-y-1">
{parsedQueryData.filters.document_types.map((type, index) => (
<div key={index} className="px-2 py-1 bg-muted/30 rounded text-xs">
{type}
</div>
))}
</div>
</div>
)}
{parsedQueryData.filters.owners.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Owners</Label>
<div className="space-y-1">
{parsedQueryData.filters.owners.map((owner, index) => (
<div key={index} className="px-2 py-1 bg-muted/30 rounded text-xs">
{owner}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Search Settings */}
<div className="space-y-4 pt-4 border-t border-border/50">
<Label className="text-sm font-medium">Search Settings</Label>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-xs text-muted-foreground">Limit</Label>
<span className="text-sm font-mono">{parsedQueryData.limit}</span>
</div>
<div className="flex justify-between items-center">
<Label className="text-xs text-muted-foreground">Score Threshold</Label>
<span className="text-sm font-mono">{parsedQueryData.scoreThreshold}</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-2 pt-4 border-t border-border/50">
<Button
onClick={handleSearchWithFilter}
className="w-full flex items-center gap-2"
variant="default"
>
<Search className="h-4 w-4" />
Search with Filter
</Button>
<Button
onClick={handleChatWithFilter}
className="w-full flex items-center gap-2"
variant="outline"
>
<MessageCircle className="h-4 w-4" />
Chat with Filter
</Button>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ProtectedKnowledgeFiltersPage() {
return (
<ProtectedRoute>
<KnowledgeFiltersPage />
</ProtectedRoute>
)
}

View file

@ -0,0 +1,730 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Upload, FolderOpen, Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, Download, AlertCircle, Database } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { useTask } from "@/contexts/task-context"
import { useAuth } from "@/contexts/auth-context"
type FacetBucket = { key: string; count: number }
interface Connector {
id: string
name: string
description: string
icon: React.ReactNode
status: "not_connected" | "connecting" | "connected" | "error"
type: string
connectionId?: string
access_token?: string
}
interface SyncResult {
processed?: number;
added?: number;
errors?: number;
skipped?: number;
total?: number;
}
interface Connection {
connection_id: string
is_active: boolean
created_at: string
last_sync?: string
}
function KnowledgeSourcesPage() {
const { isAuthenticated } = useAuth()
const { addTask, refreshTasks, tasks } = useTask()
const searchParams = useSearchParams()
// File upload state
const [fileUploadLoading, setFileUploadLoading] = useState(false)
const [pathUploadLoading, setPathUploadLoading] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [folderPath, setFolderPath] = useState("/app/documents/")
const [uploadStatus, setUploadStatus] = useState<string>("")
// Connectors state
const [connectors, setConnectors] = useState<Connector[]>([])
const [isConnecting, setIsConnecting] = useState<string | null>(null)
const [isSyncing, setIsSyncing] = useState<string | null>(null)
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
const [maxFiles, setMaxFiles] = useState<number>(10)
// Stats state (from wildcard search aggregations)
const [statsLoading, setStatsLoading] = useState<boolean>(false)
const [totalDocs, setTotalDocs] = useState<number>(0)
const [totalChunks, setTotalChunks] = useState<number>(0)
const [facetStats, setFacetStats] = useState<{ data_sources: FacetBucket[]; document_types: FacetBucket[]; owners: FacetBucket[] } | null>(null)
// File upload handlers
const handleFileUpload = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedFile) return
setFileUploadLoading(true)
setUploadStatus("")
try {
const formData = new FormData()
formData.append("file", selectedFile)
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
})
const result = await response.json()
if (response.ok) {
setUploadStatus(`File processed successfully! ID: ${result.id}`)
setSelectedFile(null)
const fileInput = document.getElementById("file-input") as HTMLInputElement
if (fileInput) fileInput.value = ""
// Refresh stats after successful file upload
fetchStats()
} else {
setUploadStatus(`Error: ${result.error || "Processing failed"}`)
}
} catch (error) {
setUploadStatus(`Error: ${error instanceof Error ? error.message : "Processing failed"}`)
} finally {
setFileUploadLoading(false)
}
}
const handlePathUpload = async (e: React.FormEvent) => {
e.preventDefault()
if (!folderPath.trim()) return
setPathUploadLoading(true)
setUploadStatus("")
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)
setUploadStatus(`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`)
setFolderPath("")
setPathUploadLoading(false)
} else if (response.ok) {
const successful = result.results?.filter((r: {status: string}) => r.status === "indexed").length || 0
const total = result.results?.length || 0
setUploadStatus(`Path processed successfully! ${successful}/${total} files indexed.`)
setFolderPath("")
setPathUploadLoading(false)
} else {
setUploadStatus(`Error: ${result.error || "Path upload failed"}`)
setPathUploadLoading(false)
}
} catch (error) {
setUploadStatus(`Error: ${error instanceof Error ? error.message : "Path upload failed"}`)
setPathUploadLoading(false)
}
}
// Connector functions
const checkConnectorStatuses = async () => {
setConnectors([
{
id: "google_drive",
name: "Google Drive",
description: "Connect your Google Drive to automatically sync documents",
icon: (
<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>
),
status: "not_connected",
type: "google_drive"
},
])
try {
const connectorTypes = ["google_drive"]
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)
}
}
const handleConnect = async (connector: Connector) => {
setIsConnecting(connector.id)
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
try {
const response = await fetch(`/api/connectors/${connector.type}/connect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
const result = await response.json()
if (result.oauth_config) {
localStorage.setItem('connecting_connector_id', result.connection_id)
localStorage.setItem('connecting_connector_type', connector.type)
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 {
console.error('Failed to initiate connection')
setIsConnecting(null)
}
} catch (error) {
console.error('Connection error:', error)
setIsConnecting(null)
}
}
const handleSync = async (connector: Connector) => {
if (!connector.connectionId) return
setIsSyncing(connector.id)
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
try {
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
connection_id: connector.connectionId,
max_files: maxFiles || undefined
}),
})
const result = await response.json()
if (response.status === 201) {
const taskId = result.task_id
if (taskId) {
addTask(taskId)
setSyncResults(prev => ({
...prev,
[connector.id]: {
processed: 0,
total: result.total_files || 0
}
}))
}
} else if (response.ok) {
setSyncResults(prev => ({ ...prev, [connector.id]: result }))
// Note: Stats will auto-refresh via task completion watcher for async syncs
} else {
console.error('Sync failed:', result.error)
}
} catch (error) {
console.error('Sync error:', error)
} finally {
setIsSyncing(null)
}
}
const getStatusBadge = (status: Connector["status"]) => {
switch (status) {
case "connected":
return <Badge variant="default" className="bg-green-500/20 text-green-400 border-green-500/30">Connected</Badge>
case "connecting":
return <Badge variant="secondary" className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">Connecting...</Badge>
case "error":
return <Badge variant="destructive">Error</Badge>
default:
return <Badge variant="outline" className="bg-muted/20 text-muted-foreground border-muted">Not Connected</Badge>
}
}
// Check connector status on mount and when returning from OAuth
useEffect(() => {
if (isAuthenticated) {
checkConnectorStatuses()
}
if (searchParams.get('oauth_success') === 'true') {
const url = new URL(window.location.href)
url.searchParams.delete('oauth_success')
window.history.replaceState({}, '', url.toString())
}
}, [searchParams, isAuthenticated])
// Fetch global stats using match-all wildcard
const fetchStats = async () => {
try {
setStatsLoading(true)
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '*', limit: 0 })
})
const result = await response.json()
if (response.ok) {
const aggs = result.aggregations || {}
const toBuckets = (agg: any): FacetBucket[] =>
(agg?.buckets || []).map((b: any) => ({ key: String(b.key), count: b.doc_count }))
const dataSourceBuckets = toBuckets(aggs.data_sources)
setFacetStats({
data_sources: dataSourceBuckets.slice(0, 10),
document_types: toBuckets(aggs.document_types).slice(0, 10),
owners: toBuckets(aggs.owners).slice(0, 10)
})
// Frontend-only doc count: number of distinct filenames (data_sources buckets)
setTotalDocs(dataSourceBuckets.length)
// Chunk count from hits.total (match_all over chunks)
setTotalChunks(Number(result.total || 0))
}
} catch {
// non-fatal keep page functional without stats
} finally {
setStatsLoading(false)
}
}
// Initial stats fetch
useEffect(() => {
fetchStats()
}, [])
// Track previous tasks to detect new completions
const [prevTasks, setPrevTasks] = useState<typeof tasks>([])
// Watch for task completions and refresh stats
useEffect(() => {
// Find newly completed tasks by comparing with previous state
const newlyCompletedTasks = tasks.filter(task => {
const wasCompleted = prevTasks.find(prev => prev.task_id === task.task_id)?.status === 'completed'
return task.status === 'completed' && !wasCompleted
})
if (newlyCompletedTasks.length > 0) {
// Refresh stats when any task newly completes
const timeoutId = setTimeout(() => {
fetchStats()
}, 1000)
// Update previous tasks state
setPrevTasks(tasks)
return () => clearTimeout(timeoutId)
} else {
// Always update previous tasks state
setPrevTasks(tasks)
}
}, [tasks, prevTasks])
return (
<div className="space-y-8">
{/* Hero Section */}
<div className="space-y-4">
<div className="mb-4">
<h1 className="text-3xl font-bold tracking-tight">
Knowledge Sources
</h1>
</div>
<p className="text-xl text-muted-foreground">
Add documents to your knowledge base
</p>
<p className="text-sm text-muted-foreground max-w-2xl">
Import files and folders directly, or connect external services like Google Drive to automatically sync and index your documents.
</p>
</div>
{/* Knowledge Overview Stats */}
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">Knowledge Overview</div>
<Button
variant="outline"
size="sm"
onClick={fetchStats}
disabled={statsLoading}
className="ml-auto"
>
{statsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</CardTitle>
<CardDescription>Snapshot of indexed content</CardDescription>
</CardHeader>
<CardContent>
{/* Documents row */}
<div className="grid gap-6 md:grid-cols-1">
<div>
<div className="text-sm text-muted-foreground mb-1">Total documents</div>
<div className="text-2xl font-semibold">{statsLoading ? '—' : totalDocs}</div>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/50 my-6" />
{/* Chunks row */}
<div className="grid gap-6 md: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 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="text-sm text-muted-foreground mb-2">Top files</div>
<div className="flex flex-wrap gap-2">
{(facetStats?.data_sources || []).slice(0,5).map((b) => (
<Badge key={`file-${b.key}`} variant="secondary" title={b.key}>{b.key} · {b.count}</Badge>
))}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Upload Section */}
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight mb-2">Direct Import</h2>
<p className="text-muted-foreground">
Add individual files or process entire folders from your local system
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* File Upload Card */}
<Card>
<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>
<form onSubmit={handleFileUpload} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file-input">File</Label>
<Input
id="file-input"
type="file"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
accept=".pdf,.docx,.txt,.md"
/>
</div>
<Button
type="submit"
disabled={!selectedFile || fileUploadLoading}
className="w-full"
>
{fileUploadLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Add File
</>
)}
</Button>
</form>
</CardContent>
</Card>
{/* Folder Upload Card */}
<Card>
<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>
<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>
</div>
{/* Upload Status */}
{uploadStatus && (
<Card className="bg-muted/20">
<CardContent className="pt-6">
<p className="text-sm">{uploadStatus}</p>
</CardContent>
</Card>
)}
</div>
{/* Connectors Section */}
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight mb-2">Connectors</h2>
<p className="text-muted-foreground">
Connect external services to automatically sync and index your documents
</p>
</div>
{/* Sync Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Sync Settings
</CardTitle>
<CardDescription>
Configure how many files to sync when manually triggering a sync
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<Label htmlFor="maxFiles" className="text-sm font-medium">
Max files per sync:
</Label>
<Input
id="maxFiles"
type="number"
value={maxFiles}
onChange={(e) => setMaxFiles(parseInt(e.target.value) || 10)}
className="w-24"
min="1"
max="100"
/>
<span className="text-sm text-muted-foreground">
(Leave blank or set to 0 for unlimited)
</span>
</div>
</div>
</CardContent>
</Card>
{/* Connectors Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{connectors.map((connector) => (
<Card key={connector.id} className="relative">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{connector.icon}
<div>
<CardTitle className="text-lg">{connector.name}</CardTitle>
<CardDescription className="text-sm">
{connector.description}
</CardDescription>
</div>
</div>
{getStatusBadge(connector.status)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{connector.status === "connected" ? (
<div className="space-y-3">
<Button
onClick={() => handleSync(connector)}
disabled={isSyncing === connector.id}
className="w-full"
variant="outline"
>
{isSyncing === connector.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Sync Now
</>
)}
</Button>
{syncResults[connector.id] && (
<div className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
<div>Processed: {syncResults[connector.id]?.processed || 0}</div>
<div>Added: {syncResults[connector.id]?.added || 0}</div>
{syncResults[connector.id]?.errors && (
<div>Errors: {syncResults[connector.id]?.errors}</div>
)}
</div>
)}
</div>
) : (
<Button
onClick={() => handleConnect(connector)}
disabled={isConnecting === connector.id}
className="w-full"
>
{isConnecting === connector.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<PlugZap className="mr-2 h-4 w-4" />
Connect
</>
)}
</Button>
)}
</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 leading-none">D</div>
<div>
<div className="font-medium">Dropbox</div>
<div className="text-sm text-muted-foreground">File storage</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center text-white font-bold leading-none">O</div>
<div>
<div className="font-medium">OneDrive</div>
<div className="text-sm text-muted-foreground">Microsoft cloud storage</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
<div className="w-8 h-8 bg-orange-600 rounded flex items-center justify-center text-white font-bold leading-none">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 ProtectedKnowledgeSourcesPage() {
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading knowledge sources...</div>}>
<KnowledgeSourcesPage />
</Suspense>
</ProtectedRoute>
)
}

View file

@ -4,6 +4,7 @@ import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/contexts/auth-context";
import { TaskProvider } from "@/contexts/task-context";
import { KnowledgeFilterProvider } from "@/contexts/knowledge-filter-context";
import { LayoutWrapper } from "@/components/layout-wrapper";
import { Toaster } from "@/components/ui/sonner";
@ -40,9 +41,11 @@ export default function RootLayout({
>
<AuthProvider>
<TaskProvider>
<LayoutWrapper>
{children}
</LayoutWrapper>
<KnowledgeFilterProvider>
<LayoutWrapper>
{children}
</LayoutWrapper>
</KnowledgeFilterProvider>
</TaskProvider>
</AuthProvider>
</ThemeProvider>

View file

@ -11,7 +11,7 @@ function LoginPageContent() {
const { isLoading, isAuthenticated, login } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
const redirect = searchParams.get('redirect') || '/knowledge-sources'
// Redirect if already authenticated
useEffect(() => {

View file

@ -1,971 +1,24 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Search, Loader2, FileText, Zap, ChevronDown, ChevronUp, X, Settings, Save } from "lucide-react"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { ProtectedRoute } from "@/components/protected-route"
import { toast } from 'sonner'
interface SearchResult {
filename: string
mimetype: string
page: number
text: string
score: number
source_url?: string
owner?: string
}
function HomePage() {
const router = useRouter()
interface FacetBucket {
key: string
count: number
}
interface Facets {
data_sources?: FacetBucket[]
document_types?: FacetBucket[]
owners?: FacetBucket[]
}
interface AggregationBucket {
key: string
doc_count: number
}
interface Aggregations {
data_sources?: {
buckets: AggregationBucket[]
}
document_types?: {
buckets: AggregationBucket[]
}
owners?: {
buckets: AggregationBucket[]
}
}
interface SearchResponse {
results: SearchResult[]
aggregations: Aggregations
error?: string
}
interface SelectedFilters {
data_sources: string[]
document_types: string[]
owners: string[]
}
function SearchPage() {
const searchParams = useSearchParams()
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [facets, setFacets] = useState<Facets>({})
const [searchPerformed, setSearchPerformed] = useState(false)
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({
data_sources: [],
document_types: [],
owners: []
})
const [openSections, setOpenSections] = useState({
data_sources: true,
document_types: true,
owners: true
})
const [sidebarOpen, setSidebarOpen] = useState(true)
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const [showSaveModal, setShowSaveModal] = useState(false)
const [contextTitle, setContextTitle] = useState("")
const [contextDescription, setContextDescription] = useState("")
const [savingContext, setSavingContext] = useState(false)
const [loadedContextName, setLoadedContextName] = useState<string | null>(null)
const handleSearch = useCallback(async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (!query.trim()) return
setLoading(true)
setSearchPerformed(false)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold,
...(searchPerformed && { filters: selectedFilters })
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
// Process aggregations into facets
const aggs = result.aggregations
const processedFacets: Facets = {}
const newSelectedFilters: SelectedFilters = {
data_sources: [],
document_types: [],
owners: []
}
if (aggs && Object.keys(aggs).length > 0) {
processedFacets.data_sources = aggs.data_sources?.buckets?.map((b: AggregationBucket) => ({ key: b.key, count: b.doc_count })).filter((b: FacetBucket) => b.count > 0) || []
processedFacets.document_types = aggs.document_types?.buckets?.map((b: AggregationBucket) => ({ key: b.key, count: b.doc_count })).filter((b: FacetBucket) => b.count > 0) || []
processedFacets.owners = aggs.owners?.buckets?.map((b: AggregationBucket) => ({ key: b.key, count: b.doc_count })).filter((b: FacetBucket) => b.count > 0) || []
// Set all filters as checked by default
newSelectedFilters.data_sources = processedFacets.data_sources?.map(f => f.key) || []
newSelectedFilters.document_types = processedFacets.document_types?.map(f => f.key) || []
newSelectedFilters.owners = processedFacets.owners?.map(f => f.key) || []
}
setFacets(processedFacets)
setSelectedFilters(newSelectedFilters)
setSearchPerformed(true)
} else {
console.error("Search failed:", result.error)
setResults([])
setFacets({})
setSelectedFilters({
data_sources: [],
document_types: [],
owners: []
})
setSearchPerformed(true)
}
} catch (error) {
console.error("Search error:", error)
setResults([])
setFacets({})
setSelectedFilters({
data_sources: [],
document_types: [],
owners: []
})
setSearchPerformed(true)
} finally {
setLoading(false)
}
}, [query, resultLimit, scoreThreshold, searchPerformed, selectedFilters])
const loadKnowledgeFilter = useCallback(async (filterId: string) => {
try {
const response = await fetch(`/api/knowledge-filter/${filterId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
const result = await response.json()
if (response.ok && result.success) {
const filter = result.filter
const parsedQueryData = JSON.parse(filter.query_data)
// Load the context data into state
setQuery(parsedQueryData.query)
setSelectedFilters(parsedQueryData.filters)
setResultLimit(parsedQueryData.limit)
setScoreThreshold(parsedQueryData.scoreThreshold)
setLoadedContextName(filter.name)
// Automatically perform the search
setTimeout(() => {
handleSearch()
}, 100)
} else {
console.error("Failed to load knowledge filter:", result.error)
}
} catch (err) {
console.error("Error loading knowledge filter:", err)
}
}, [handleSearch])
// Load knowledge filter if filterId is provided in URL
useEffect(() => {
const filterId = searchParams.get('filterId')
if (filterId) {
loadKnowledgeFilter(filterId)
}
}, [searchParams, loadKnowledgeFilter])
// Redirect to knowledge sources page - the new home page
router.replace("/knowledge-sources")
}, [router])
const handleFilterChange = async (facetType: keyof SelectedFilters, value: string, checked: boolean) => {
const newFilters = {
...selectedFilters,
[facetType]: checked
? [...selectedFilters[facetType], value]
: selectedFilters[facetType].filter(item => item !== value)
}
setSelectedFilters(newFilters)
// Re-search immediately if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold,
filters: newFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}
const clearAllFilters = () => {
setSelectedFilters({
data_sources: [],
document_types: [],
owners: []
})
}
const selectAllFilters = () => {
setSelectedFilters({
data_sources: facets.data_sources?.map(f => f.key) || [],
document_types: facets.document_types?.map(f => f.key) || [],
owners: facets.owners?.map(f => f.key) || []
})
}
const toggleSection = (section: keyof typeof openSections) => {
setOpenSections(prev => ({
...prev,
[section]: !prev[section]
}))
}
const getSelectedFilterCount = () => {
return selectedFilters.data_sources.length +
selectedFilters.document_types.length +
selectedFilters.owners.length
}
const handleSaveKnowledgeFilter = async () => {
const filterId = searchParams.get('filterId')
// If no filterId present and no title, we need the modal
if (!filterId && !contextTitle.trim()) return
setSavingContext(true)
try {
const filterData = {
query,
filters: selectedFilters,
limit: resultLimit,
scoreThreshold
}
let response;
if (filterId) {
// Update existing knowledge filter (upsert)
response = await fetch(`/api/knowledge-filter/${filterId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
queryData: JSON.stringify(filterData)
}),
})
} else {
// Create new knowledge filter
response = await fetch("/api/knowledge-filter", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: contextTitle,
description: contextDescription,
queryData: JSON.stringify(filterData)
}),
})
}
const result = await response.json()
if (response.ok && result.success) {
if (!filterId) {
// Reset modal state only if we were creating a new knowledge filter
setShowSaveModal(false)
setContextTitle("")
setContextDescription("")
}
toast.success(filterId ? "Knowledge filter updated successfully" : "Knowledge filter saved successfully")
} else {
toast.error(filterId ? "Failed to update knowledge filter" : "Failed to save knowledge filter")
}
} catch {
toast.error(filterId ? "Error updating knowledge filter" : "Error saving knowledge filter")
} finally {
setSavingContext(false)
}
}
const FacetSection = ({
title,
buckets,
facetType,
isOpen,
onToggle
}: {
title: string
buckets: FacetBucket[]
facetType: keyof SelectedFilters
isOpen: boolean
onToggle: () => void
}) => {
if (!buckets || buckets.length === 0) return null
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-0 h-auto font-medium text-left">
<span className="text-sm font-medium">{title}</span>
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mt-3">
{buckets.map((bucket, index) => {
const isSelected = selectedFilters[facetType].includes(bucket.key)
return (
<div key={index} className="flex items-center space-x-2">
<Checkbox
id={`${facetType}-${index}`}
checked={isSelected}
onCheckedChange={(checked) =>
handleFilterChange(facetType, bucket.key, checked as boolean)
}
/>
<Label
htmlFor={`${facetType}-${index}`}
className="text-sm font-normal flex-1 cursor-pointer flex items-center justify-between"
>
<span className="truncate" title={bucket.key}>
{bucket.key}
</span>
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded ml-2 flex-shrink-0">
{bucket.count}
</span>
</Label>
</div>
)
})}
</CollapsibleContent>
</Collapsible>
)
}
return (
<div className="space-y-8">
{/* Hero Section */}
<div className="space-y-4">
<div className="mb-4">
<h1 className="text-4xl font-bold tracking-tight text-white">
Search
</h1>
</div>
<p className="text-xl text-muted-foreground">
Find documents using hybrid search
</p>
<p className="text-sm text-muted-foreground max-w-2xl">
Enter your search query to find relevant documents using AI-powered semantic search combined with keyword matching across your document collection.
</p>
</div>
{/* Search Interface */}
<Card className="w-full bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Search Documents
{loadedContextName && (
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
Context: {loadedContextName}
</span>
)}
</CardTitle>
<CardDescription>
Enter your search query to find relevant documents using hybrid search (semantic + keyword)
{loadedContextName && (
<span className="block text-blue-400 text-xs mt-1">
Search configuration loaded from saved context
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="space-y-3">
<Label htmlFor="search-query" className="font-medium">
Search Query
</Label>
<div className="flex gap-2">
<Input
id="search-query"
type="text"
placeholder="e.g., 'financial reports from Q4' or 'user authentication setup'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20 flex-1"
/>
<Button
type="submit"
disabled={!query.trim() || loading}
className="h-12 px-6 transition-all duration-200"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Searching...
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
Search
</>
)}
</Button>
</div>
</div>
</form>
{/* Search Results with Filters */}
{searchPerformed && (
<div className="space-y-4">
{/* Search Results Header - Always visible when search is performed */}
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold flex items-center gap-2">
<Zap className="h-6 w-6 text-yellow-400" />
Search Results
</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''} returned
</span>
</div>
{/* Filter Toggle - Only visible when filters are available */}
{((facets.data_sources?.length ?? 0) > 0 || (facets.document_types?.length ?? 0) > 0 || (facets.owners?.length ?? 0) > 0) && (
<Button
variant="outline"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center gap-2"
>
<Settings className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex gap-6">
{/* Main Content */}
<div className="flex-1 space-y-6">
{/* Active Filters Display */}
{getSelectedFilterCount() > 0 && getSelectedFilterCount() < (facets.data_sources?.length || 0) + (facets.document_types?.length || 0) + (facets.owners?.length || 0) && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Active Filters</h3>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={selectAllFilters} className="h-auto px-2 py-1 text-xs">
Select all
</Button>
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="h-auto px-2 py-1 text-xs">
Clear all
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(selectedFilters).map(([facetType, values]) =>
values.map((value: string) => (
<div key={`${facetType}-${value}`} className="flex items-center gap-1 bg-primary/10 text-primary px-2 py-1 rounded-md text-xs">
<span>{value}</span>
<Button
variant="ghost"
size="sm"
className="h-auto w-auto p-0.5 hover:bg-primary/20"
onClick={() => handleFilterChange(facetType as keyof SelectedFilters, value, false)}
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</div>
)}
{/* Results Section */}
<div>
{results.length === 0 ? (
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
<Search className="h-8 w-8 text-muted-foreground/50" />
</div>
<p className="text-lg font-medium text-muted-foreground">
No documents found
</p>
<p className="text-sm text-muted-foreground/70 max-w-md mx-auto">
Try adjusting your search terms or check if documents have been indexed.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{results.map((result, index) => (
<Card key={index} className="bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/70 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/10">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
<FileText className="h-4 w-4 text-blue-400" />
</div>
<span className="truncate">{result.filename}</span>
</CardTitle>
<div className="flex items-center gap-2">
<div className="px-2 py-1 rounded-md bg-green-500/20 border border-green-500/30">
<span className="text-xs font-medium text-green-400">
{result.score.toFixed(2)}
</span>
</div>
</div>
</div>
<CardDescription className="flex items-center gap-4 text-sm">
<span className="px-2 py-1 rounded bg-muted/50 text-muted-foreground">
{result.mimetype}
</span>
<span className="text-muted-foreground">
Page {result.page}
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-l-2 border-blue-400/50 pl-4 py-2 bg-muted/20 rounded-r-lg">
<p className="text-sm leading-relaxed text-foreground/90">
{result.text}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
{/* Right Sidebar - Settings */}
{((facets.data_sources?.length ?? 0) > 0 || (facets.document_types?.length ?? 0) > 0 || (facets.owners?.length ?? 0) > 0) && sidebarOpen && (
<div className="w-64 space-y-6 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Settings className="h-5 w-5" />
Search Configuration
</h2>
</div>
<div className="space-y-6">
<FacetSection
title="Data Sources"
buckets={facets.data_sources || []}
facetType="data_sources"
isOpen={openSections.data_sources}
onToggle={() => toggleSection('data_sources')}
/>
<FacetSection
title="Document Types"
buckets={facets.document_types || []}
facetType="document_types"
isOpen={openSections.document_types}
onToggle={() => toggleSection('document_types')}
/>
<FacetSection
title="Owners"
buckets={facets.owners || []}
facetType="owners"
isOpen={openSections.owners}
onToggle={() => toggleSection('owners')}
/>
{/* All/None buttons - moved below facets */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllFilters}
className="h-auto px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 border-border/50"
>
All
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAllFilters}
className="h-auto px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 border-border/50"
>
None
</Button>
</div>
{/* Result Limit Control */}
<div className="space-y-4 pt-4 border-t border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Limit</Label>
<Input
type="number"
min="1"
max="1000"
value={resultLimit}
onChange={async (e) => {
const newLimit = Math.max(1, Math.min(1000, parseInt(e.target.value) || 1))
setResultLimit(newLimit)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: newLimit,
scoreThreshold,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-16 h-6 text-xs text-center"
/>
</div>
<input
type="range"
min={1}
max={1000}
value={resultLimit}
onChange={async (e) => {
const value = parseInt(e.target.value)
setResultLimit(value)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: value,
scoreThreshold,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
{/* Score Threshold Control */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Score Threshold</Label>
<Input
type="number"
min="0"
max="10"
step="0.1"
value={scoreThreshold}
onChange={async (e) => {
const newThreshold = Math.max(0, Math.min(10, parseFloat(e.target.value) || 0))
setScoreThreshold(newThreshold)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold: newThreshold,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-16 h-6 text-xs text-center"
/>
</div>
<input
type="range"
min={0}
max={10}
step={0.1}
value={scoreThreshold}
onChange={async (e) => {
const value = parseFloat(e.target.value)
setScoreThreshold(value)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold: value,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
{/* Save Knowledge Filter Button */}
<div className="pt-4 border-t border-border/50">
<Button
onClick={() => {
const filterId = searchParams.get('filterId')
if (filterId) {
handleSaveKnowledgeFilter()
} else {
setShowSaveModal(true)
}
}}
disabled={!searchPerformed || !query.trim() || savingContext}
className="w-full flex items-center gap-2"
variant="outline"
>
{savingContext ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{searchParams.get('filterId') ? 'Updating...' : 'Saving...'}
</>
) : (
<>
<Save className="h-4 w-4" />
{searchParams.get('filterId') ? 'Update Knowledge Filter' : 'Save Knowledge Filter'}
</>
)}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Empty State */}
{!searchPerformed && (
<div className="h-32 flex items-center justify-center">
<p className="text-muted-foreground/50 text-sm">
Enter a search query above to get started
</p>
</div>
)}
</CardContent>
</Card>
{/* Save Context Modal */}
{showSaveModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Save Search Context</h3>
<div className="space-y-4">
<div>
<Label htmlFor="context-title" className="font-medium">
Title <span className="text-red-400">*</span>
</Label>
<Input
id="context-title"
type="text"
placeholder="Enter a title for this search context"
value={contextTitle}
onChange={(e) => setContextTitle(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="context-description" className="font-medium">
Description (optional)
</Label>
<Input
id="context-description"
type="text"
placeholder="Brief description of this search context"
value={contextDescription}
onChange={(e) => setContextDescription(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
onClick={() => {
setShowSaveModal(false)
setContextTitle("")
setContextDescription("")
}}
disabled={savingContext}
>
Cancel
</Button>
<Button
onClick={handleSaveKnowledgeFilter}
disabled={!contextTitle.trim() || savingContext}
className="flex items-center gap-2"
>
{savingContext ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
Save Context
</>
)}
</Button>
</div>
</div>
</div>
)}
</div>
)
return null
}
export default function ProtectedSearchPage() {
export default function ProtectedHomePage() {
return (
<ProtectedRoute>
<SearchPage />
<HomePage />
</ProtectedRoute>
)
}

View file

@ -0,0 +1,298 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Search, Loader2, FileText, Zap } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
interface SearchResult {
filename: string
mimetype: string
page: number
text: string
score: number
source_url?: string
owner?: string
}
interface SearchResponse {
results: SearchResult[]
error?: string
}
function SearchPage() {
const searchParams = useSearchParams()
const { selectedFilter, parsedFilterData } = useKnowledgeFilter()
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [searchPerformed, setSearchPerformed] = useState(false)
const handleSearch = useCallback(async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (!query.trim()) return
setLoading(true)
setSearchPerformed(false)
try {
// Build search payload with global filter data
const searchPayload: any = {
query,
limit: parsedFilterData?.limit || 10,
scoreThreshold: parsedFilterData?.scoreThreshold || 0
}
// Add filters from global context if available and not wildcards
if (parsedFilterData?.filters) {
const filters = parsedFilterData.filters
// Only include filters if they're not wildcards (not "*")
const hasSpecificFilters =
!filters.data_sources.includes("*") ||
!filters.document_types.includes("*") ||
!filters.owners.includes("*")
if (hasSpecificFilters) {
const processedFilters: any = {}
// Only add filter arrays that don't contain wildcards
if (!filters.data_sources.includes("*")) {
processedFilters.data_sources = filters.data_sources
}
if (!filters.document_types.includes("*")) {
processedFilters.document_types = filters.document_types
}
if (!filters.owners.includes("*")) {
processedFilters.owners = filters.owners
}
// Only add filters object if it has any actual filters
if (Object.keys(processedFilters).length > 0) {
searchPayload.filters = processedFilters
}
}
// If all filters are wildcards, omit the filters object entirely
}
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(searchPayload),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
setSearchPerformed(true)
} else {
console.error("Search failed:", result.error)
setResults([])
setSearchPerformed(true)
}
} catch (error) {
console.error("Search error:", error)
setResults([])
setSearchPerformed(true)
} finally {
setLoading(false)
}
}, [query, parsedFilterData])
// Update query when global filter changes
useEffect(() => {
if (parsedFilterData?.query) {
setQuery(parsedFilterData.query)
}
}, [parsedFilterData])
// Auto-refresh search when filter changes (if search was already performed)
useEffect(() => {
if (searchPerformed && query.trim()) {
handleSearch()
}
}, [parsedFilterData]) // Only depend on parsedFilterData to avoid infinite loop
return (
<div className="space-y-8">
{/* Hero Section */}
<div className="space-y-4">
<div className="mb-4">
<h1 className="text-4xl font-bold tracking-tight text-white">
Search
</h1>
</div>
<p className="text-xl text-muted-foreground">
Find documents using hybrid search
</p>
<p className="text-sm text-muted-foreground max-w-2xl">
Enter your search query to find relevant documents using AI-powered semantic search combined with keyword matching across your document collection.
</p>
</div>
{/* Search Interface */}
<Card className="w-full bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Search Documents
{selectedFilter && (
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
Filter: {selectedFilter.name}
</span>
)}
</CardTitle>
<CardDescription>
Enter your search query to find relevant documents using hybrid search (semantic + keyword)
{selectedFilter && (
<span className="block text-blue-400 text-xs mt-1">
Using knowledge filter: {selectedFilter.name}
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="space-y-3">
<Label htmlFor="search-query" className="font-medium">
Search Query
</Label>
<div className="flex gap-2">
<Input
id="search-query"
type="text"
placeholder="e.g., 'financial reports from Q4' or 'user authentication setup'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20 flex-1"
/>
<Button
type="submit"
disabled={!query.trim() || loading}
className="h-12 px-6 transition-all duration-200"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Searching...
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
Search
</>
)}
</Button>
</div>
</div>
</form>
{/* Search Results */}
{searchPerformed && (
<div className="space-y-4">
{/* Search Results Header */}
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold flex items-center gap-2">
<Zap className="h-6 w-6 text-yellow-400" />
Search Results
</h2>
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''} returned
</span>
</div>
</div>
{/* Results */}
<div>
{results.length === 0 ? (
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
<Search className="h-8 w-8 text-muted-foreground/50" />
</div>
<p className="text-lg font-medium text-muted-foreground">
No documents found
</p>
<p className="text-sm text-muted-foreground/70 max-w-md mx-auto">
Try adjusting your search terms or modify your knowledge filter settings.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{results.map((result, index) => (
<Card key={index} className="bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/70 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/10">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
<FileText className="h-4 w-4 text-blue-400" />
</div>
<span className="truncate">{result.filename}</span>
</CardTitle>
<div className="flex items-center gap-2">
<div className="px-2 py-1 rounded-md bg-green-500/20 border border-green-500/30">
<span className="text-xs font-medium text-green-400">
{result.score.toFixed(2)}
</span>
</div>
</div>
</div>
<CardDescription className="flex items-center gap-4 text-sm">
<span className="px-2 py-1 rounded bg-muted/50 text-muted-foreground">
{result.mimetype}
</span>
<span className="text-muted-foreground">
Page {result.page}
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-l-2 border-blue-400/50 pl-4 py-2 bg-muted/20 rounded-r-lg">
<p className="text-sm leading-relaxed text-foreground/90">
{result.text}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
)}
{/* Empty State */}
{!searchPerformed && (
<div className="h-32 flex items-center justify-center">
<p className="text-muted-foreground/50 text-sm">
Enter a search query above to get started
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
export default function ProtectedSearchPage() {
return (
<ProtectedRoute>
<SearchPage />
</ProtectedRoute>
)
}

View file

@ -8,11 +8,15 @@ import { Navigation } from "@/components/navigation"
import { ModeToggle } from "@/components/mode-toggle"
import { UserNav } from "@/components/user-nav"
import { TaskNotificationMenu } from "@/components/task-notification-menu"
import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown"
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"
import { useTask } from "@/contexts/task-context"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const { tasks, isMenuOpen, toggleMenu } = useTask()
const { selectedFilter, setSelectedFilter, isPanelOpen } = useKnowledgeFilter()
// List of paths that should not show navigation
const authPaths = ['/login', '/auth/callback']
@ -44,6 +48,11 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
</div>
<div className="flex flex-1 items-center justify-end space-x-2">
<nav className="flex items-center space-x-2">
{/* Knowledge Filter Dropdown */}
<KnowledgeFilterDropdown
selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter}
/>
{/* Task Notification Bell */}
<Button
variant="ghost"
@ -74,7 +83,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
<div className="hidden md:flex md:w-72 md:flex-col md:fixed md:top-14 md:bottom-0 md:left-0 z-[80] border-r border-border/40">
<Navigation />
</div>
<main className={`md:pl-72 ${isMenuOpen ? 'md:pr-80' : ''}`}>
<main className={`md:pl-72 ${(isMenuOpen || isPanelOpen) ? 'md:pr-80' : ''}`}>
<div className="flex flex-col h-[calc(100vh-3.6rem)]">
<div className="flex-1 overflow-y-auto scrollbar-hide">
<div className="container py-6 lg:py-8">
@ -84,6 +93,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
</div>
</main>
<TaskNotificationMenu />
<KnowledgeFilterPanel />
</div>
)
}

View file

@ -0,0 +1,126 @@
"use client"
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface KnowledgeFilter {
id: string
name: string
description: string
query_data: string
owner: string
created_at: string
updated_at: string
}
interface ParsedQueryData {
query: string
filters: {
data_sources: string[]
document_types: string[]
owners: string[]
}
limit: number
scoreThreshold: number
}
interface KnowledgeFilterContextType {
selectedFilter: KnowledgeFilter | null
parsedFilterData: ParsedQueryData | null
setSelectedFilter: (filter: KnowledgeFilter | null) => void
clearFilter: () => void
isPanelOpen: boolean
openPanel: () => void
closePanel: () => void
closePanelOnly: () => void
}
const KnowledgeFilterContext = createContext<KnowledgeFilterContextType | undefined>(undefined)
export function useKnowledgeFilter() {
const context = useContext(KnowledgeFilterContext)
if (context === undefined) {
throw new Error('useKnowledgeFilter must be used within a KnowledgeFilterProvider')
}
return context
}
interface KnowledgeFilterProviderProps {
children: ReactNode
}
export function KnowledgeFilterProvider({ children }: KnowledgeFilterProviderProps) {
const [selectedFilter, setSelectedFilterState] = useState<KnowledgeFilter | null>(null)
const [parsedFilterData, setParsedFilterData] = useState<ParsedQueryData | null>(null)
const [isPanelOpen, setIsPanelOpen] = useState(false)
const setSelectedFilter = (filter: KnowledgeFilter | null) => {
setSelectedFilterState(filter)
if (filter) {
try {
const parsed = JSON.parse(filter.query_data) as ParsedQueryData
setParsedFilterData(parsed)
// Store in localStorage for persistence across page reloads
localStorage.setItem('selectedKnowledgeFilter', JSON.stringify(filter))
// Auto-open panel when filter is selected
setIsPanelOpen(true)
} catch (error) {
console.error('Error parsing filter data:', error)
setParsedFilterData(null)
}
} else {
setParsedFilterData(null)
localStorage.removeItem('selectedKnowledgeFilter')
setIsPanelOpen(false)
}
}
const clearFilter = () => {
setSelectedFilter(null)
}
const openPanel = () => {
setIsPanelOpen(true)
}
const closePanel = () => {
setSelectedFilter(null) // This will also close the panel
}
const closePanelOnly = () => {
setIsPanelOpen(false) // Close panel but keep filter selected
}
// Load persisted filter on mount
useEffect(() => {
try {
const saved = localStorage.getItem('selectedKnowledgeFilter')
if (saved) {
const filter = JSON.parse(saved) as KnowledgeFilter
setSelectedFilter(filter)
}
} catch (error) {
console.error('Error loading persisted filter:', error)
localStorage.removeItem('selectedKnowledgeFilter')
}
}, [])
const value: KnowledgeFilterContextType = {
selectedFilter,
parsedFilterData,
setSelectedFilter,
clearFilter,
isPanelOpen,
openPanel,
closePanel,
closePanelOnly,
}
return (
<KnowledgeFilterContext.Provider value={value}>
{children}
</KnowledgeFilterContext.Provider>
)
}

View file

@ -34,7 +34,16 @@ async def create_knowledge_filter(request: Request, knowledge_filter_service, se
}
result = await knowledge_filter_service.create_knowledge_filter(filter_doc, user_id=user.user_id, jwt_token=jwt_token)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=201) # Created
else:
error_msg = result.get("error", "")
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)
async def search_knowledge_filters(request: Request, knowledge_filter_service, session_manager):
"""Search for knowledge filters by name, description, or query content"""
@ -47,7 +56,16 @@ async def search_knowledge_filters(request: Request, knowledge_filter_service, s
jwt_token = request.cookies.get("auth_token")
result = await knowledge_filter_service.search_knowledge_filters(query, user_id=user.user_id, jwt_token=jwt_token, limit=limit)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=200)
else:
error_msg = result.get("error", "")
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)
async def get_knowledge_filter(request: Request, knowledge_filter_service, session_manager):
"""Get a specific knowledge filter by ID"""
@ -59,7 +77,18 @@ async def get_knowledge_filter(request: Request, knowledge_filter_service, sessi
jwt_token = request.cookies.get("auth_token")
result = await knowledge_filter_service.get_knowledge_filter(filter_id, user_id=user.user_id, jwt_token=jwt_token)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=200)
else:
error_msg = result.get("error", "")
if "not found" in error_msg.lower():
return JSONResponse(result, status_code=404)
elif "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)
async def update_knowledge_filter(request: Request, knowledge_filter_service, session_manager):
"""Update an existing knowledge filter by delete + recreate (due to DLS limitations)"""
@ -99,7 +128,16 @@ async def update_knowledge_filter(request: Request, knowledge_filter_service, se
# Recreate the knowledge filter
result = await knowledge_filter_service.create_knowledge_filter(updated_filter, user_id=user.user_id, jwt_token=jwt_token)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=200) # Updated successfully
else:
error_msg = result.get("error", "")
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)
async def delete_knowledge_filter(request: Request, knowledge_filter_service, session_manager):
"""Delete a knowledge filter"""
@ -111,4 +149,15 @@ async def delete_knowledge_filter(request: Request, knowledge_filter_service, se
jwt_token = request.cookies.get("auth_token")
result = await knowledge_filter_service.delete_knowledge_filter(filter_id, user_id=user.user_id, jwt_token=jwt_token)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=200)
else:
error_msg = result.get("error", "")
if "not found" in error_msg.lower() or "already deleted" in error_msg.lower():
return JSONResponse(result, status_code=404)
elif "access denied" in error_msg.lower() or "insufficient permissions" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)

View file

@ -17,4 +17,13 @@ async def search(request: Request, search_service, session_manager):
jwt_token = request.cookies.get("auth_token")
result = await search_service.search(query, user_id=user.user_id, jwt_token=jwt_token, filters=filters, limit=limit, score_threshold=score_threshold)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=200)
else:
error_msg = result.get("error", "")
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)

View file

@ -10,7 +10,16 @@ async def upload(request: Request, document_service, session_manager):
jwt_token = request.cookies.get("auth_token")
result = await document_service.process_upload_file(upload_file, owner_user_id=user.user_id, jwt_token=jwt_token)
return JSONResponse(result)
# Return appropriate HTTP status codes
if result.get("success"):
return JSONResponse(result, status_code=201) # Created
else:
error_msg = result.get("error", "")
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
return JSONResponse(result, status_code=403)
else:
return JSONResponse(result, status_code=500)
async def upload_path(request: Request, task_service, session_manager):
"""Upload all files from a directory path"""

View file

@ -128,4 +128,10 @@ class KnowledgeFilterService:
return {"success": False, "error": "Failed to delete knowledge filter"}
except Exception as e:
return {"success": False, "error": str(e)}
error_str = str(e)
if "not_found" in error_str or "NotFoundError" in error_str:
return {"success": False, "error": "Knowledge filter not found or already deleted"}
elif "AuthenticationException" in error_str:
return {"success": False, "error": "Access denied: insufficient permissions"}
else:
return {"success": False, "error": f"Delete operation failed: {error_str}"}

View file

@ -25,9 +25,13 @@ class SearchService:
filters = get_search_filters() or {}
limit = get_search_limit()
score_threshold = get_score_threshold()
# Embed the query
resp = await clients.patched_async_client.embeddings.create(model=EMBED_MODEL, input=[query])
query_embedding = resp.data[0].embedding
# Detect wildcard request ("*") to return global facets/stats without semantic search
is_wildcard_match_all = isinstance(query, str) and query.strip() == "*"
# Only embed when not doing match_all
if not is_wildcard_match_all:
resp = await clients.patched_async_client.embeddings.create(model=EMBED_MODEL, input=[query])
query_embedding = resp.data[0].embedding
# Build filter clauses
filter_clauses = []
@ -54,9 +58,16 @@ class SearchService:
# Multiple values filter
filter_clauses.append({"terms": {field_name: values}})
# Hybrid search query structure (semantic + keyword)
search_body = {
"query": {
# Build query body
if is_wildcard_match_all:
# Match all documents; still allow filters to narrow scope
if filter_clauses:
query_block = {"bool": {"filter": filter_clauses}}
else:
query_block = {"match_all": {}}
else:
# Hybrid search query structure (semantic + keyword)
query_block = {
"bool": {
"should": [
{
@ -78,9 +89,13 @@ class SearchService:
}
}
],
"minimum_should_match": 1
"minimum_should_match": 1,
**({"filter": filter_clauses} if filter_clauses else {})
}
},
}
search_body = {
"query": query_block,
"aggs": {
"data_sources": {
"terms": {
@ -105,14 +120,10 @@ class SearchService:
"size": limit
}
# Add score threshold if specified
if score_threshold > 0:
# Add score threshold only for hybrid (not meaningful for match_all)
if not is_wildcard_match_all and score_threshold > 0:
search_body["min_score"] = score_threshold
# Add filter clauses if any exist
if filter_clauses:
search_body["query"]["bool"]["filter"] = filter_clauses
# Authentication required - DLS will handle document filtering automatically
if not user_id:
return {"results": [], "error": "Authentication required"}
@ -137,7 +148,8 @@ class SearchService:
# Return both transformed results and aggregations
return {
"results": chunks,
"aggregations": results.get("aggregations", {})
"aggregations": results.get("aggregations", {}),
"total": (results.get("hits", {}).get("total", {}).get("value") if isinstance(results.get("hits", {}).get("total"), dict) else results.get("hits", {}).get("total"))
}
async def search(self, query: str, user_id: str = None, jwt_token: str = None, filters: Dict[str, Any] = None, limit: int = 10, score_threshold: float = 0) -> Dict[str, Any]:

View file

@ -101,13 +101,14 @@ class SessionManager:
else:
self.users[user_id] = user
# Use provided issuer
# Use OpenSearch-compatible issuer for OIDC validation
oidc_issuer = "http://openrag-backend:8000"
# Create JWT token with OIDC-compliant claims
now = datetime.utcnow()
token_payload = {
# OIDC standard claims
"iss": issuer, # Issuer from request
"iss": oidc_issuer, # Fixed issuer for OpenSearch OIDC
"sub": user_id, # Subject (user ID)
"aud": ["opensearch", "openrag"], # Audience
"exp": now + timedelta(days=7), # Expiration