579 lines
19 KiB
TypeScript
579 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { X, Edit3, Save, Settings, 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 { MultiSelect } from "@/components/ui/multi-select";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
|
import { useDeleteFilter } from "@/app/api/mutations/useDeleteFilter";
|
|
|
|
interface FacetBucket {
|
|
key: string;
|
|
count: number;
|
|
}
|
|
|
|
interface AvailableFacets {
|
|
data_sources: FacetBucket[];
|
|
document_types: FacetBucket[];
|
|
owners: FacetBucket[];
|
|
connector_types: FacetBucket[];
|
|
}
|
|
|
|
export function KnowledgeFilterPanel() {
|
|
const {
|
|
selectedFilter,
|
|
parsedFilterData,
|
|
setSelectedFilter,
|
|
isPanelOpen,
|
|
closePanelOnly,
|
|
} = useKnowledgeFilter();
|
|
const deleteFilterMutation = useDeleteFilter();
|
|
|
|
// 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
|
|
connector_types: ["*"] as string[], // Default to wildcard
|
|
});
|
|
const [resultLimit, setResultLimit] = useState(10);
|
|
const [scoreThreshold, setScoreThreshold] = useState(0);
|
|
|
|
// Available facets (loaded from API)
|
|
const [availableFacets, setAvailableFacets] = useState<AvailableFacets>({
|
|
data_sources: [],
|
|
document_types: [],
|
|
owners: [],
|
|
connector_types: [],
|
|
});
|
|
|
|
// 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,
|
|
connector_types: filters.connector_types || ["*"],
|
|
};
|
|
|
|
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 || [],
|
|
connector_types: result.aggregations.connector_types?.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 selectAllFilters = () => {
|
|
// Use wildcards instead of listing all specific items
|
|
setSelectedFilters({
|
|
data_sources: ["*"],
|
|
document_types: ["*"],
|
|
owners: ["*"],
|
|
connector_types: ["*"],
|
|
});
|
|
};
|
|
|
|
const clearAllFilters = () => {
|
|
setSelectedFilters({
|
|
data_sources: [],
|
|
document_types: [],
|
|
owners: [],
|
|
connector_types: [],
|
|
});
|
|
};
|
|
|
|
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 handleFilterChange = (
|
|
facetType: keyof typeof selectedFilters,
|
|
newValues: string[]
|
|
) => {
|
|
setSelectedFilters((prev) => ({
|
|
...prev,
|
|
[facetType]: newValues,
|
|
}));
|
|
};
|
|
|
|
const handleDeleteFilter = async () => {
|
|
const result = await deleteFilterMutation.mutateAsync({
|
|
id: selectedFilter.id,
|
|
});
|
|
if (result.success) {
|
|
setSelectedFilter(null);
|
|
closePanelOnly();
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Filter Dropdowns */}
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">Data Sources</Label>
|
|
<MultiSelect
|
|
options={(availableFacets.data_sources || []).map((bucket) => ({
|
|
value: bucket.key,
|
|
label: bucket.key,
|
|
count: bucket.count,
|
|
}))}
|
|
value={selectedFilters.data_sources}
|
|
onValueChange={(values) =>
|
|
handleFilterChange("data_sources", values)
|
|
}
|
|
placeholder="Select data sources..."
|
|
allOptionLabel="All Data Sources"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">Document Types</Label>
|
|
<MultiSelect
|
|
options={(availableFacets.document_types || []).map(
|
|
(bucket) => ({
|
|
value: bucket.key,
|
|
label: bucket.key,
|
|
count: bucket.count,
|
|
})
|
|
)}
|
|
value={selectedFilters.document_types}
|
|
onValueChange={(values) =>
|
|
handleFilterChange("document_types", values)
|
|
}
|
|
placeholder="Select document types..."
|
|
allOptionLabel="All Document Types"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">Owners</Label>
|
|
<MultiSelect
|
|
options={(availableFacets.owners || []).map((bucket) => ({
|
|
value: bucket.key,
|
|
label: bucket.key,
|
|
count: bucket.count,
|
|
}))}
|
|
value={selectedFilters.owners}
|
|
onValueChange={(values) => handleFilterChange("owners", values)}
|
|
placeholder="Select owners..."
|
|
allOptionLabel="All Owners"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">Sources</Label>
|
|
<MultiSelect
|
|
options={(availableFacets.connector_types || []).map(
|
|
(bucket) => ({
|
|
value: bucket.key,
|
|
label: bucket.key,
|
|
count: bucket.count,
|
|
})
|
|
)}
|
|
value={selectedFilters.connector_types}
|
|
onValueChange={(values) =>
|
|
handleFilterChange("connector_types", values)
|
|
}
|
|
placeholder="Select sources..."
|
|
allOptionLabel="All Sources"
|
|
/>
|
|
</div>
|
|
|
|
{/* 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="h-6 text-xs text-right px-2 bg-muted/30 !border-0 rounded ml-auto focus:ring-0 focus:outline-none"
|
|
style={{ width: "70px" }}
|
|
/>
|
|
</div>
|
|
<Slider
|
|
value={[resultLimit]}
|
|
onValueChange={(values) => setResultLimit(values[0])}
|
|
max={1000}
|
|
min={1}
|
|
step={1}
|
|
className="w-full"
|
|
/>
|
|
</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="5"
|
|
step="0.1"
|
|
value={scoreThreshold}
|
|
onChange={(e) =>
|
|
setScoreThreshold(parseFloat(e.target.value) || 0)
|
|
}
|
|
className="h-6 text-xs text-right px-2 bg-muted/30 !border-0 rounded ml-auto focus:ring-0 focus:outline-none"
|
|
style={{ width: "70px" }}
|
|
/>
|
|
</div>
|
|
<Slider
|
|
value={[scoreThreshold]}
|
|
onValueChange={(values) => setScoreThreshold(values[0])}
|
|
max={5}
|
|
min={0}
|
|
step={0.1}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save Configuration Button */}
|
|
<div className="flex flex-col gap-3 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>
|
|
<Button
|
|
variant="destructive"
|
|
className="w-full"
|
|
onClick={handleDeleteFilter}
|
|
>
|
|
Delete Filter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|