frontend redesign
This commit is contained in:
parent
d780013a2b
commit
d97a2fb7b5
20 changed files with 2397 additions and 1421 deletions
426
frontend/components/knowledge-filter-dropdown.tsx
Normal file
426
frontend/components/knowledge-filter-dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
611
frontend/components/knowledge-filter-panel.tsx
Normal file
611
frontend/components/knowledge-filter-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
730
frontend/src/app/knowledge-sources/page.tsx
Normal file
730
frontend/src/app/knowledge-sources/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
298
frontend/src/app/search/page.tsx
Normal file
298
frontend/src/app/search/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
126
frontend/src/contexts/knowledge-filter-context.tsx
Normal file
126
frontend/src/contexts/knowledge-filter-context.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue