openrag/frontend/components/knowledge-filter-panel.tsx

498 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { X, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
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";
import { useUpdateFilter } from "@/app/api/mutations/useUpdateFilter";
import { useCreateFilter } from "@/app/api/mutations/useCreateFilter";
import { useGetSearchAggregations } from "@/src/app/api/queries/useGetSearchAggregations";
import {
FilterColor,
FilterIconPopover,
IconKey,
} from "@/components/filter-icon-popover";
interface FacetBucket {
key: string;
count: number;
}
interface AvailableFacets {
data_sources: FacetBucket[];
document_types: FacetBucket[];
owners: FacetBucket[];
connector_types: FacetBucket[];
}
export const filterAccentClasses: Record<FilterColor, string> = {
zinc: "bg-muted text-muted-foreground",
pink: "bg-accent-pink text-accent-pink-foreground",
purple: "bg-accent-purple text-accent-purple-foreground",
indigo: "bg-accent-indigo text-accent-indigo-foreground",
emerald: "bg-accent-emerald text-accent-emerald-foreground",
amber: "bg-accent-amber text-accent-amber-foreground",
red: "bg-accent-red text-accent-red-foreground",
};
export function KnowledgeFilterPanel() {
const {
queryOverride,
selectedFilter,
parsedFilterData,
setSelectedFilter,
isPanelOpen,
closePanelOnly,
createMode,
endCreateMode,
} = useKnowledgeFilter();
const deleteFilterMutation = useDeleteFilter();
const updateFilterMutation = useUpdateFilter();
const createFilterMutation = useCreateFilter();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [color, setColor] = useState<FilterColor>("zinc");
const [iconKey, setIconKey] = useState<IconKey>("filter");
const [nameError, setNameError] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
// 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 when a filter is selected
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);
setName(selectedFilter.name);
setDescription(selectedFilter.description || "");
setColor(parsedFilterData.color);
setIconKey(parsedFilterData.icon);
}
}, [selectedFilter, parsedFilterData]);
// Initialize defaults when entering create mode
useEffect(() => {
if (createMode && parsedFilterData) {
setQuery(parsedFilterData.query || "");
setSelectedFilters(parsedFilterData.filters);
setResultLimit(parsedFilterData.limit || 10);
setScoreThreshold(parsedFilterData.scoreThreshold || 0);
setName("");
setDescription("");
setColor(parsedFilterData.color);
setIconKey(parsedFilterData.icon);
}
}, [createMode, parsedFilterData]);
// Load available facets using search aggregations hook
const { data: aggregations } = useGetSearchAggregations("*", 1, 0, {
enabled: isPanelOpen,
placeholderData: prev => prev,
staleTime: 60_000,
gcTime: 5 * 60_000,
});
useEffect(() => {
if (!aggregations) return;
const facets = {
data_sources: aggregations.data_sources?.buckets || [],
document_types: aggregations.document_types?.buckets || [],
owners: aggregations.owners?.buckets || [],
connector_types: aggregations.connector_types?.buckets || [],
};
setAvailableFacets(facets);
}, [aggregations]);
// Don't render if panel is closed or we don't have any data
if (!isPanelOpen || !parsedFilterData) return null;
const handleSaveConfiguration = async () => {
if (!name.trim()) {
setNameError("Name is required");
nameInputRef.current?.focus();
return;
}
const filterData = {
query,
filters: selectedFilters,
limit: resultLimit,
scoreThreshold,
color,
icon: iconKey,
};
setIsSaving(true);
try {
if (createMode) {
const result = await createFilterMutation.mutateAsync({
name: name.trim(),
description: description.trim(),
queryData: JSON.stringify(filterData),
});
if (result.success && result.filter) {
setSelectedFilter(result.filter);
endCreateMode();
}
} else if (selectedFilter) {
const result = await updateFilterMutation.mutateAsync({
id: selectedFilter.id,
name: name.trim(),
description: description.trim(),
queryData: JSON.stringify(filterData),
});
if (result.success && result.filter) {
setSelectedFilter(result.filter);
}
}
} catch (error) {
console.error("Error saving knowledge filter:", 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 () => {
if (!selectedFilter) return;
const result = await deleteFilterMutation.mutateAsync({
id: selectedFilter.id,
});
if (result.success) {
setSelectedFilter(null);
closePanelOnly();
}
};
return (
<div className="h-full bg-background border-l">
<Card className="h-full rounded-none border-0 flex flex-col">
<CardHeader className="pb-3 pt-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
Knowledge Filter
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedFilter(null);
closePanelOnly();
}}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Filter Name and Description */}
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="filter-name" className="gap-1">
Filter name
<span className="text-destructive">*</span>
</Label>
<div className="flex items-center gap-2">
<FilterIconPopover
color={color}
iconKey={iconKey}
onColorChange={setColor}
onIconChange={setIconKey}
/>
<Input
id="filter-name"
value={name}
onChange={e => {
const v = e.target.value;
setName(v);
if (nameError && v.trim()) {
setNameError(null);
}
}}
required
placeholder="Filter name"
ref={nameInputRef}
aria-invalid={!!nameError}
/>
</div>
</div>
{!createMode && selectedFilter?.created_at && (
<div className="space-y-2 text-xs text-right text-muted-foreground">
<span className="text-placeholder-foreground">Created</span>{" "}
{formatDate(selectedFilter.created_at)}
</div>
)}
{createMode && (
<div className="space-y-2 text-xs text-right text-muted-foreground">
<span className="text-placeholder-foreground">Created</span>{" "}
{formatDate(new Date().toISOString())}
</div>
)}
<div className="space-y-2">
<Label htmlFor="filter-description">Description</Label>
<Textarea
id="filter-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Provide a brief description of your knowledge filter..."
rows={3}
/>
</div>
</div>
{/* Search Query */}
<div className="space-y-2">
<Label htmlFor="search-query" className="text-sm font-medium">
Search Query
</Label>
<Textarea
id="search-query"
placeholder="Enter your search query..."
value={query}
className="font-mono placeholder:font-mono"
onChange={e => setQuery(e.target.value)}
rows={2}
disabled={!!queryOverride && !createMode}
/>
</div>
{/* Filter Dropdowns */}
<div className="space-y-4">
<div className="space-y-2">
<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 sources..."
allOptionLabel="All sources"
/>
</div>
<div className="space-y-2">
<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 types..."
allOptionLabel="All types"
/>
</div>
<div className="space-y-2">
<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">
<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 connectors..."
allOptionLabel="All connectors"
/>
</div>
{/* Result Limit Control - exactly like search page */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-nowrap">
Response 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 text-nowrap">
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>
</div>
</CardContent>
<CardFooter className="mt-auto align-bottom justify-end gap-2">
{/* Save Configuration Button */}
{createMode && (
<Button
onClick={closePanelOnly}
disabled={isSaving}
variant="outline"
size="sm"
>
Cancel
</Button>
)}
{!createMode && (
<Button
variant="destructive"
size="sm"
onClick={handleDeleteFilter}
disabled={isSaving}
>
Delete Filter
</Button>
)}
<Button
onClick={handleSaveConfiguration}
disabled={isSaving}
size="sm"
className="relative"
>
{isSaving && (
<>
<RefreshCw className="h-3 w-3 animate-spin" />
Saving...
</>
)}
{!isSaving && (createMode ? "Create Filter" : "Update Filter")}
</Button>
</CardFooter>
</Card>
</div>
);
}