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 { Navigation } from "@/components/navigation";
|
||||||
import { ModeToggle } from "@/components/mode-toggle";
|
import { ModeToggle } from "@/components/mode-toggle";
|
||||||
|
import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown";
|
||||||
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
|
|
||||||
interface NavigationLayoutProps {
|
interface NavigationLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavigationLayout({ children }: NavigationLayoutProps) {
|
export function NavigationLayout({ children }: NavigationLayoutProps) {
|
||||||
|
const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full relative">
|
<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">
|
<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">
|
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||||
{/* Search component could go here */}
|
{/* Search component could go here */}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center">
|
<nav className="flex items-center space-x-2">
|
||||||
|
<KnowledgeFilterDropdown
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterSelect={setSelectedFilter}
|
||||||
|
/>
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
|
|
@ -10,16 +10,16 @@ export function Navigation() {
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
label: "Ingest",
|
label: "Knowledge Sources",
|
||||||
icon: Settings,
|
icon: Database,
|
||||||
href: "/admin",
|
href: "/knowledge-sources",
|
||||||
active: pathname === "/admin",
|
active: pathname === "/" || pathname === "/knowledge-sources",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Search",
|
label: "Search",
|
||||||
icon: Search,
|
icon: Search,
|
||||||
href: "/",
|
href: "/search",
|
||||||
active: pathname === "/",
|
active: pathname === "/search",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Chat",
|
label: "Chat",
|
||||||
|
|
@ -27,18 +27,6 @@ export function Navigation() {
|
||||||
href: "/chat",
|
href: "/chat",
|
||||||
active: pathname === "/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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ function AuthCallbackContent() {
|
||||||
await refreshAuth()
|
await refreshAuth()
|
||||||
|
|
||||||
// Get redirect URL from login page
|
// Get redirect URL from login page
|
||||||
const redirectTo = searchParams.get('redirect') || '/'
|
const redirectTo = searchParams.get('redirect') || '/knowledge-sources'
|
||||||
|
|
||||||
// Clean up localStorage
|
// Clean up localStorage
|
||||||
localStorage.removeItem('connecting_connector_id')
|
localStorage.removeItem('connecting_connector_id')
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react"
|
import { useState, useRef, useEffect } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
|
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
|
||||||
import { ProtectedRoute } from "@/components/protected-route"
|
import { ProtectedRoute } from "@/components/protected-route"
|
||||||
import { useTask } from "@/contexts/task-context"
|
import { useTask } from "@/contexts/task-context"
|
||||||
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
|
|
@ -56,7 +56,6 @@ interface RequestBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatPage() {
|
function ChatPage() {
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -78,51 +77,9 @@ function ChatPage() {
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const { addTask } = useTask()
|
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 = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
|
@ -279,20 +236,36 @@ function ChatPage() {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Update input when global filter query changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (parsedFilterData?.query) {
|
||||||
|
setInput(parsedFilterData.query)
|
||||||
|
}
|
||||||
|
}, [parsedFilterData])
|
||||||
|
|
||||||
const handleSSEStream = async (userMessage: Message) => {
|
const handleSSEStream = async (userMessage: Message) => {
|
||||||
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
|
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
|
||||||
|
|
||||||
try {
|
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,
|
prompt: userMessage.content,
|
||||||
stream: true,
|
stream: true,
|
||||||
...(hasFilters && { filters: selectedFilters }),
|
...(parsedFilterData?.filters && (() => {
|
||||||
limit: resultLimit,
|
const filters = parsedFilterData.filters
|
||||||
scoreThreshold: scoreThreshold
|
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
|
// Add previous_response_id if we have one for this endpoint
|
||||||
|
|
@ -748,15 +721,24 @@ function ChatPage() {
|
||||||
try {
|
try {
|
||||||
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
|
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,
|
prompt: userMessage.content,
|
||||||
...(hasFilters && { filters: selectedFilters }),
|
...(parsedFilterData?.filters && (() => {
|
||||||
limit: resultLimit,
|
const filters = parsedFilterData.filters
|
||||||
scoreThreshold: scoreThreshold
|
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
|
// Add previous_response_id if we have one for this endpoint
|
||||||
|
|
@ -1042,9 +1024,9 @@ function ChatPage() {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MessageCircle className="h-5 w-5" />
|
<MessageCircle className="h-5 w-5" />
|
||||||
<CardTitle>Chat</CardTitle>
|
<CardTitle>Chat</CardTitle>
|
||||||
{loadedContextName && (
|
{selectedFilter && (
|
||||||
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
|
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
|
||||||
Context: {loadedContextName}
|
Context: {selectedFilter.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1093,9 +1075,9 @@ function ChatPage() {
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Chat with AI about your indexed documents using {endpoint === "chat" ? "Chat" : "Langflow"} endpoint
|
Chat with AI about your indexed documents using {endpoint === "chat" ? "Chat" : "Langflow"} endpoint
|
||||||
{asyncMode ? " with real-time streaming" : ""}
|
{asyncMode ? " with real-time streaming" : ""}
|
||||||
{loadedContextName && (
|
{selectedFilter && (
|
||||||
<span className="block text-blue-400 text-xs mt-1">
|
<span className="block text-blue-400 text-xs mt-1">
|
||||||
Using search context with configured filters and settings
|
Using knowledge filter: {selectedFilter.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
|
||||||
|
|
@ -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 { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { AuthProvider } from "@/contexts/auth-context";
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
import { TaskProvider } from "@/contexts/task-context";
|
import { TaskProvider } from "@/contexts/task-context";
|
||||||
|
import { KnowledgeFilterProvider } from "@/contexts/knowledge-filter-context";
|
||||||
import { LayoutWrapper } from "@/components/layout-wrapper";
|
import { LayoutWrapper } from "@/components/layout-wrapper";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
|
@ -40,9 +41,11 @@ export default function RootLayout({
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<TaskProvider>
|
<TaskProvider>
|
||||||
<LayoutWrapper>
|
<KnowledgeFilterProvider>
|
||||||
{children}
|
<LayoutWrapper>
|
||||||
</LayoutWrapper>
|
{children}
|
||||||
|
</LayoutWrapper>
|
||||||
|
</KnowledgeFilterProvider>
|
||||||
</TaskProvider>
|
</TaskProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ function LoginPageContent() {
|
||||||
const { isLoading, isAuthenticated, login } = useAuth()
|
const { isLoading, isAuthenticated, login } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirect = searchParams.get('redirect') || '/'
|
const redirect = searchParams.get('redirect') || '/knowledge-sources'
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,971 +1,24 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useEffect } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
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 { 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 { ProtectedRoute } from "@/components/protected-route"
|
import { ProtectedRoute } from "@/components/protected-route"
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
interface SearchResult {
|
function HomePage() {
|
||||||
filename: string
|
const router = useRouter()
|
||||||
mimetype: string
|
|
||||||
page: number
|
|
||||||
text: string
|
|
||||||
score: number
|
|
||||||
source_url?: string
|
|
||||||
owner?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const filterId = searchParams.get('filterId')
|
// Redirect to knowledge sources page - the new home page
|
||||||
if (filterId) {
|
router.replace("/knowledge-sources")
|
||||||
loadKnowledgeFilter(filterId)
|
}, [router])
|
||||||
}
|
|
||||||
}, [searchParams, loadKnowledgeFilter])
|
|
||||||
|
|
||||||
const handleFilterChange = async (facetType: keyof SelectedFilters, value: string, checked: boolean) => {
|
return null
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProtectedSearchPage() {
|
export default function ProtectedHomePage() {
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<SearchPage />
|
<HomePage />
|
||||||
</ProtectedRoute>
|
</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 { ModeToggle } from "@/components/mode-toggle"
|
||||||
import { UserNav } from "@/components/user-nav"
|
import { UserNav } from "@/components/user-nav"
|
||||||
import { TaskNotificationMenu } from "@/components/task-notification-menu"
|
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 { useTask } from "@/contexts/task-context"
|
||||||
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
|
||||||
|
|
||||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { tasks, isMenuOpen, toggleMenu } = useTask()
|
const { tasks, isMenuOpen, toggleMenu } = useTask()
|
||||||
|
const { selectedFilter, setSelectedFilter, isPanelOpen } = useKnowledgeFilter()
|
||||||
|
|
||||||
// List of paths that should not show navigation
|
// List of paths that should not show navigation
|
||||||
const authPaths = ['/login', '/auth/callback']
|
const authPaths = ['/login', '/auth/callback']
|
||||||
|
|
@ -44,6 +48,11 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
<nav className="flex items-center space-x-2">
|
<nav className="flex items-center space-x-2">
|
||||||
|
{/* Knowledge Filter Dropdown */}
|
||||||
|
<KnowledgeFilterDropdown
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterSelect={setSelectedFilter}
|
||||||
|
/>
|
||||||
{/* Task Notification Bell */}
|
{/* Task Notification Bell */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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">
|
<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 />
|
<Navigation />
|
||||||
</div>
|
</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 flex-col h-[calc(100vh-3.6rem)]">
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||||
<div className="container py-6 lg:py-8">
|
<div className="container py-6 lg:py-8">
|
||||||
|
|
@ -84,6 +93,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<TaskNotificationMenu />
|
<TaskNotificationMenu />
|
||||||
|
<KnowledgeFilterPanel />
|
||||||
</div>
|
</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)
|
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):
|
async def search_knowledge_filters(request: Request, knowledge_filter_service, session_manager):
|
||||||
"""Search for knowledge filters by name, description, or query content"""
|
"""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")
|
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)
|
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):
|
async def get_knowledge_filter(request: Request, knowledge_filter_service, session_manager):
|
||||||
"""Get a specific knowledge filter by ID"""
|
"""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")
|
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)
|
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):
|
async def update_knowledge_filter(request: Request, knowledge_filter_service, session_manager):
|
||||||
"""Update an existing knowledge filter by delete + recreate (due to DLS limitations)"""
|
"""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
|
# Recreate the knowledge filter
|
||||||
result = await knowledge_filter_service.create_knowledge_filter(updated_filter, user_id=user.user_id, jwt_token=jwt_token)
|
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):
|
async def delete_knowledge_filter(request: Request, knowledge_filter_service, session_manager):
|
||||||
"""Delete a knowledge filter"""
|
"""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")
|
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)
|
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")
|
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)
|
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")
|
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)
|
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):
|
async def upload_path(request: Request, task_service, session_manager):
|
||||||
"""Upload all files from a directory path"""
|
"""Upload all files from a directory path"""
|
||||||
|
|
|
||||||
|
|
@ -128,4 +128,10 @@ class KnowledgeFilterService:
|
||||||
return {"success": False, "error": "Failed to delete knowledge filter"}
|
return {"success": False, "error": "Failed to delete knowledge filter"}
|
||||||
|
|
||||||
except Exception as e:
|
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 {}
|
filters = get_search_filters() or {}
|
||||||
limit = get_search_limit()
|
limit = get_search_limit()
|
||||||
score_threshold = get_score_threshold()
|
score_threshold = get_score_threshold()
|
||||||
# Embed the query
|
# Detect wildcard request ("*") to return global facets/stats without semantic search
|
||||||
resp = await clients.patched_async_client.embeddings.create(model=EMBED_MODEL, input=[query])
|
is_wildcard_match_all = isinstance(query, str) and query.strip() == "*"
|
||||||
query_embedding = resp.data[0].embedding
|
|
||||||
|
# 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
|
# Build filter clauses
|
||||||
filter_clauses = []
|
filter_clauses = []
|
||||||
|
|
@ -54,9 +58,16 @@ class SearchService:
|
||||||
# Multiple values filter
|
# Multiple values filter
|
||||||
filter_clauses.append({"terms": {field_name: values}})
|
filter_clauses.append({"terms": {field_name: values}})
|
||||||
|
|
||||||
# Hybrid search query structure (semantic + keyword)
|
# Build query body
|
||||||
search_body = {
|
if is_wildcard_match_all:
|
||||||
"query": {
|
# 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": {
|
"bool": {
|
||||||
"should": [
|
"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": {
|
"aggs": {
|
||||||
"data_sources": {
|
"data_sources": {
|
||||||
"terms": {
|
"terms": {
|
||||||
|
|
@ -105,14 +120,10 @@ class SearchService:
|
||||||
"size": limit
|
"size": limit
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add score threshold if specified
|
# Add score threshold only for hybrid (not meaningful for match_all)
|
||||||
if score_threshold > 0:
|
if not is_wildcard_match_all and score_threshold > 0:
|
||||||
search_body["min_score"] = score_threshold
|
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
|
# Authentication required - DLS will handle document filtering automatically
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return {"results": [], "error": "Authentication required"}
|
return {"results": [], "error": "Authentication required"}
|
||||||
|
|
@ -137,7 +148,8 @@ class SearchService:
|
||||||
# Return both transformed results and aggregations
|
# Return both transformed results and aggregations
|
||||||
return {
|
return {
|
||||||
"results": chunks,
|
"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]:
|
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:
|
else:
|
||||||
self.users[user_id] = user
|
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
|
# Create JWT token with OIDC-compliant claims
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
token_payload = {
|
token_payload = {
|
||||||
# OIDC standard claims
|
# OIDC standard claims
|
||||||
"iss": issuer, # Issuer from request
|
"iss": oidc_issuer, # Fixed issuer for OpenSearch OIDC
|
||||||
"sub": user_id, # Subject (user ID)
|
"sub": user_id, # Subject (user ID)
|
||||||
"aud": ["opensearch", "openrag"], # Audience
|
"aud": ["opensearch", "openrag"], # Audience
|
||||||
"exp": now + timedelta(days=7), # Expiration
|
"exp": now + timedelta(days=7), # Expiration
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue