594 lines
No EOL
20 KiB
TypeScript
594 lines
No EOL
20 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { 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 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
|
|
|
|
// Use the exact selections from the saved filter
|
|
// Empty arrays mean "none selected" not "all selected"
|
|
const processedFilters = {
|
|
data_sources: filters.data_sources,
|
|
document_types: filters.document_types,
|
|
owners: 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 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
|
|
|
|
// "All" is selected if it contains wildcard OR if no specific selections are made
|
|
const isAllSelected = selectedFilters[facetType].includes("*")
|
|
|
|
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>
|
|
)
|
|
} |