Merge pull request #54 from langflow-ai/feat/move-filters

Move knowledge filters list to left nav
This commit is contained in:
Cole Goldsmith 2025-09-23 11:10:59 -05:00 committed by GitHub
commit 8b26de02bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 888 additions and 311 deletions

View file

@ -12,6 +12,7 @@ import { Button } from "./ui/button";
import { DeleteConfirmationDialog } from "./confirmation-dialog"; import { DeleteConfirmationDialog } from "./confirmation-dialog";
import { useDeleteDocument } from "@/app/api/mutations/useDeleteDocument"; import { useDeleteDocument } from "@/app/api/mutations/useDeleteDocument";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation";
interface KnowledgeActionsDropdownProps { interface KnowledgeActionsDropdownProps {
filename: string; filename: string;
@ -22,6 +23,7 @@ export const KnowledgeActionsDropdown = ({
}: KnowledgeActionsDropdownProps) => { }: KnowledgeActionsDropdownProps) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteDocumentMutation = useDeleteDocument(); const deleteDocumentMutation = useDeleteDocument();
const router = useRouter();
const handleDelete = async () => { const handleDelete = async () => {
try { try {
@ -43,7 +45,17 @@ export const KnowledgeActionsDropdown = ({
<EllipsisVertical className="h-4 w-4" /> <EllipsisVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={-10}> <DropdownMenuContent side="right" align="start" sideOffset={-10}>
<DropdownMenuItem
className="text-primary focus:text-primary"
onClick={() => {
router.push(
`/knowledge/chunks?filename=${encodeURIComponent(filename)}`
);
}}
>
View chunks
</DropdownMenuItem>
{/* //TODO: Implement rename and sync */} {/* //TODO: Implement rename and sync */}
{/* <DropdownMenuItem {/* <DropdownMenuItem
className="text-primary focus:text-primary" className="text-primary focus:text-primary"

View file

@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Filter, Loader2, Plus, Save, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
useGetFiltersSearchQuery,
type KnowledgeFilter,
} from "@/src/app/api/queries/useGetFiltersSearchQuery";
import { useCreateFilter } from "@/src/app/api/mutations/useCreateFilter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ParsedQueryData {
query: string;
filters: {
data_sources: string[];
document_types: string[];
owners: string[];
};
limit: number;
scoreThreshold: number;
}
interface KnowledgeFilterListProps {
selectedFilter: KnowledgeFilter | null;
onFilterSelect: (filter: KnowledgeFilter | null) => void;
}
export function KnowledgeFilterList({
selectedFilter,
onFilterSelect,
}: KnowledgeFilterListProps) {
const [searchQuery] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [creating, setCreating] = useState(false);
const { data, isFetching: loading } = useGetFiltersSearchQuery(
searchQuery,
20
);
const filters = data || [];
const createFilterMutation = useCreateFilter();
const handleFilterSelect = (filter: KnowledgeFilter) => {
onFilterSelect(filter);
};
const handleCreateNew = () => {
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 result = await createFilterMutation.mutateAsync({
name: createName.trim(),
description: createDescription.trim(),
queryData: JSON.stringify(defaultFilterData),
});
// Select the new filter from API response
onFilterSelect(result.filter);
// Close modal and reset form
setShowCreateModal(false);
setCreateName("");
setCreateDescription("");
} catch (error) {
console.error("Error creating knowledge filter:", error);
} finally {
setCreating(false);
}
};
const handleCancelCreate = () => {
setShowCreateModal(false);
setCreateName("");
setCreateDescription("");
};
const parseQueryData = (queryData: string): ParsedQueryData => {
return JSON.parse(queryData) as ParsedQueryData;
};
return (
<>
<div className="flex flex-col items-center gap-1 px-3 !mb-12 mt-0 h-full overflow-y-auto">
<div className="flex items-center w-full justify-between pl-3">
<div className="text-sm font-medium text-muted-foreground">
Knowledge Filters
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCreateNew}
title="Create New Filter"
className="h-8 px-3 text-muted-foreground"
>
<Plus className="h-3 w-3" />
</Button>
</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 px-3 py-2 w-full rounded-lg hover:bg-accent hover:text-accent-foreground cursor-pointer group transition-colors",
selectedFilter?.id === filter.id &&
"bg-accent text-accent-foreground"
)}
>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center bg-blue-500/20 w-5 h-5 rounded">
<Filter className="h-3 w-3 text-blue-400" />
</div>
<div className="text-sm font-medium truncate group-hover:text-accent-foreground">
{filter.name}
</div>
</div>
{filter.description && (
<div className="text-xs text-muted-foreground group-hover:text-accent-foreground/70 line-clamp-2">
{filter.description}
</div>
)}
<div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground group-hover:text-accent-foreground/70">
{new Date(filter.created_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</div>
<span className="text-xs bg-muted text-muted-foreground px-1 py-0.5 rounded-sm">
{(() => {
const dataSources = parseQueryData(filter.query_data)
.filters.data_sources;
if (dataSources[0] === "*") return "All sources";
const count = dataSources.length;
return `${count} ${count === 1 ? "source" : "sources"}`;
})()}
</span>
</div>
</div>
{selectedFilter?.id === filter.id && (
<Button
variant="ghost"
size="sm"
className="px-0"
onClick={(e) => {
e.stopPropagation();
onFilterSelect(null);
}}
>
<X className="h-4 w-4 flex-shrink-0 opacity-0 group-hover:opacity-100 text-muted-foreground" />
</Button>
)}
</div>
))
)}
</div>
{/* Create Filter Dialog */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a new knowledge filter</DialogTitle>
<DialogDescription>
Save a reusable filter to quickly scope searches across your
knowledge base.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 space-y-2">
<div>
<Label htmlFor="filter-name" className="font-medium mb-2 gap-1">
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 mb-2">
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">
<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>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -1,64 +1,74 @@
"use client" "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 { useState, useEffect } from "react";
import { X, Edit3, Save, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, 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 { useGetSearchAggregations } from "@/src/app/api/queries/useGetSearchAggregations";
interface FacetBucket { interface FacetBucket {
key: string key: string;
count: number count: number;
} }
interface AvailableFacets { interface AvailableFacets {
data_sources: FacetBucket[] data_sources: FacetBucket[];
document_types: FacetBucket[] document_types: FacetBucket[];
owners: FacetBucket[] owners: FacetBucket[];
connector_types: FacetBucket[] connector_types: FacetBucket[];
} }
export function KnowledgeFilterPanel() { export function KnowledgeFilterPanel() {
const { selectedFilter, parsedFilterData, setSelectedFilter, isPanelOpen, closePanelOnly } = useKnowledgeFilter() const {
selectedFilter,
parsedFilterData,
setSelectedFilter,
isPanelOpen,
closePanelOnly,
} = useKnowledgeFilter();
const deleteFilterMutation = useDeleteFilter();
const updateFilterMutation = useUpdateFilter();
// Edit mode states // Edit mode states
const [isEditingMeta, setIsEditingMeta] = useState(false) const [isEditingMeta, setIsEditingMeta] = useState(false);
const [editingName, setEditingName] = useState('') const [editingName, setEditingName] = useState("");
const [editingDescription, setEditingDescription] = useState('') const [editingDescription, setEditingDescription] = useState("");
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false);
// Filter configuration states (mirror search page exactly) // Filter configuration states (mirror search page exactly)
const [query, setQuery] = useState('') const [query, setQuery] = useState("");
const [selectedFilters, setSelectedFilters] = useState({ const [selectedFilters, setSelectedFilters] = useState({
data_sources: ["*"] as string[], // Default to wildcard data_sources: ["*"] as string[], // Default to wildcard
document_types: ["*"] as string[], // Default to wildcard document_types: ["*"] as string[], // Default to wildcard
owners: ["*"] as string[], // Default to wildcard owners: ["*"] as string[], // Default to wildcard
connector_types: ["*"] as string[] // Default to wildcard connector_types: ["*"] as string[], // Default to wildcard
}) });
const [resultLimit, setResultLimit] = useState(10) const [resultLimit, setResultLimit] = useState(10);
const [scoreThreshold, setScoreThreshold] = useState(0) const [scoreThreshold, setScoreThreshold] = useState(0);
// Available facets (loaded from API) // Available facets (loaded from API)
const [availableFacets, setAvailableFacets] = useState<AvailableFacets>({ const [availableFacets, setAvailableFacets] = useState<AvailableFacets>({
data_sources: [], data_sources: [],
document_types: [], document_types: [],
owners: [], owners: [],
connector_types: [] connector_types: [],
}) });
// Load current filter data into controls // Load current filter data into controls
useEffect(() => { useEffect(() => {
if (selectedFilter && parsedFilterData) { if (selectedFilter && parsedFilterData) {
setQuery(parsedFilterData.query || '') setQuery(parsedFilterData.query || "");
// Set the actual filter selections from the saved knowledge filter // Set the actual filter selections from the saved knowledge filter
const filters = parsedFilterData.filters const filters = parsedFilterData.filters;
// Use the exact selections from the saved filter // Use the exact selections from the saved filter
// Empty arrays mean "none selected" not "all selected" // Empty arrays mean "none selected" not "all selected"
@ -66,68 +76,40 @@ export function KnowledgeFilterPanel() {
data_sources: filters.data_sources, data_sources: filters.data_sources,
document_types: filters.document_types, document_types: filters.document_types,
owners: filters.owners, owners: filters.owners,
connector_types: filters.connector_types || ["*"] connector_types: filters.connector_types || ["*"],
} };
console.log("[DEBUG] Loading filter selections:", processedFilters) console.log("[DEBUG] Loading filter selections:", processedFilters);
setSelectedFilters(processedFilters) setSelectedFilters(processedFilters);
setResultLimit(parsedFilterData.limit || 10) setResultLimit(parsedFilterData.limit || 10);
setScoreThreshold(parsedFilterData.scoreThreshold || 0) setScoreThreshold(parsedFilterData.scoreThreshold || 0);
setEditingName(selectedFilter.name) setEditingName(selectedFilter.name);
setEditingDescription(selectedFilter.description || '') setEditingDescription(selectedFilter.description || "");
} }
}, [selectedFilter, parsedFilterData]) }, [selectedFilter, 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,
});
// Load available facets from API
useEffect(() => { useEffect(() => {
if (isPanelOpen) { if (!aggregations) return;
loadAvailableFacets() const facets = {
} data_sources: aggregations.data_sources?.buckets || [],
}, [isPanelOpen]) document_types: aggregations.document_types?.buckets || [],
owners: aggregations.owners?.buckets || [],
const loadAvailableFacets = async () => { connector_types: aggregations.connector_types?.buckets || [],
console.log("[DEBUG] Loading available facets...") };
try { setAvailableFacets(facets);
// Do a search to get facets (similar to search page) }, [aggregations]);
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 // Don't render if panel is closed or no filter selected
if (!isPanelOpen || !selectedFilter || !parsedFilterData) return null if (!isPanelOpen || !selectedFilter || !parsedFilterData) return null;
const selectAllFilters = () => { const selectAllFilters = () => {
// Use wildcards instead of listing all specific items // Use wildcards instead of listing all specific items
@ -135,116 +117,105 @@ export function KnowledgeFilterPanel() {
data_sources: ["*"], data_sources: ["*"],
document_types: ["*"], document_types: ["*"],
owners: ["*"], owners: ["*"],
connector_types: ["*"] connector_types: ["*"],
}) });
} };
const clearAllFilters = () => { const clearAllFilters = () => {
setSelectedFilters({ setSelectedFilters({
data_sources: [], data_sources: [],
document_types: [], document_types: [],
owners: [], owners: [],
connector_types: [] connector_types: [],
}) });
} };
const handleEditMeta = () => { const handleEditMeta = () => {
setIsEditingMeta(true) setIsEditingMeta(true);
} };
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsEditingMeta(false) setIsEditingMeta(false);
setEditingName(selectedFilter.name) setEditingName(selectedFilter.name);
setEditingDescription(selectedFilter.description || '') setEditingDescription(selectedFilter.description || "");
} };
const handleSaveMeta = async () => { const handleSaveMeta = async () => {
if (!editingName.trim()) return if (!editingName.trim()) return;
setIsSaving(true) setIsSaving(true);
try { try {
const response = await fetch(`/api/knowledge-filter/${selectedFilter.id}`, { const result = await updateFilterMutation.mutateAsync({
method: 'PUT', id: selectedFilter.id,
headers: { name: editingName.trim(),
'Content-Type': 'application/json', description: editingDescription.trim(),
}, });
body: JSON.stringify({
name: editingName.trim(),
description: editingDescription.trim(),
}),
})
const result = await response.json() if (result.success && result.filter) {
if (response.ok && result.success) { setSelectedFilter(result.filter);
const updatedFilter = { setIsEditingMeta(false);
...selectedFilter,
name: editingName.trim(),
description: editingDescription.trim(),
updated_at: new Date().toISOString(),
}
setSelectedFilter(updatedFilter)
setIsEditingMeta(false)
} }
} catch (error) { } catch (error) {
console.error('Error updating filter:', error) console.error("Error updating filter:", error);
} finally { } finally {
setIsSaving(false) setIsSaving(false);
} }
} };
const handleSaveConfiguration = async () => { const handleSaveConfiguration = async () => {
const filterData = { const filterData = {
query, query,
filters: selectedFilters, filters: selectedFilters,
limit: resultLimit, limit: resultLimit,
scoreThreshold scoreThreshold,
} };
setIsSaving(true) setIsSaving(true);
try { try {
const response = await fetch(`/api/knowledge-filter/${selectedFilter.id}`, { const result = await updateFilterMutation.mutateAsync({
method: 'PUT', id: selectedFilter.id,
headers: { queryData: JSON.stringify(filterData),
'Content-Type': 'application/json', });
},
body: JSON.stringify({
queryData: JSON.stringify(filterData)
}),
})
const result = await response.json() if (result.success && result.filter) {
if (response.ok && result.success) { setSelectedFilter(result.filter);
// Update the filter in context
const updatedFilter = {
...selectedFilter,
query_data: JSON.stringify(filterData),
updated_at: new Date().toISOString(),
}
setSelectedFilter(updatedFilter)
} }
} catch (error) { } catch (error) {
console.error('Error updating filter configuration:', error) console.error("Error updating filter configuration:", error);
} finally { } finally {
setIsSaving(false) setIsSaving(false);
} }
} };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString("en-US", {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit' minute: "2-digit",
}) });
} };
const handleFilterChange = (facetType: keyof typeof selectedFilters, newValues: string[]) => { const handleFilterChange = (
setSelectedFilters(prev => ({ facetType: keyof typeof selectedFilters,
newValues: string[]
) => {
setSelectedFilters((prev) => ({
...prev, ...prev,
[facetType]: newValues [facetType]: newValues,
})) }));
} };
const handleDeleteFilter = async () => {
const result = await deleteFilterMutation.mutateAsync({
id: selectedFilter.id,
});
if (result.success) {
setSelectedFilter(null);
closePanelOnly();
}
};
return ( return (
<div className="fixed right-0 top-14 bottom-0 w-80 bg-background border-l border-border/40 z-40 overflow-y-auto"> <div className="fixed right-0 top-14 bottom-0 w-80 bg-background border-l border-border/40 z-40 overflow-y-auto">
@ -252,7 +223,6 @@ export function KnowledgeFilterPanel() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Settings className="h-5 w-5" />
Knowledge Filter Knowledge Filter
</CardTitle> </CardTitle>
<Button <Button
@ -264,9 +234,6 @@ export function KnowledgeFilterPanel() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<CardDescription>
Configure your knowledge filter settings
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@ -301,7 +268,7 @@ export function KnowledgeFilterPanel() {
className="flex-1" className="flex-1"
> >
<Save className="h-3 w-3 mr-1" /> <Save className="h-3 w-3 mr-1" />
{isSaving ? 'Saving...' : 'Save'} {isSaving ? "Saving..." : "Save"}
</Button> </Button>
<Button <Button
onClick={handleCancelEdit} onClick={handleCancelEdit}
@ -315,9 +282,11 @@ export function KnowledgeFilterPanel() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-lg">{selectedFilter.name}</h3> <h3 className="font-semibold text-lg">
{selectedFilter.name}
</h3>
{selectedFilter.description && ( {selectedFilter.description && (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{selectedFilter.description} {selectedFilter.description}
@ -336,7 +305,10 @@ export function KnowledgeFilterPanel() {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Created {formatDate(selectedFilter.created_at)} Created {formatDate(selectedFilter.created_at)}
{selectedFilter.updated_at !== selectedFilter.created_at && ( {selectedFilter.updated_at !== selectedFilter.created_at && (
<span> Updated {formatDate(selectedFilter.updated_at)}</span> <span>
{" "}
Updated {formatDate(selectedFilter.updated_at)}
</span>
)} )}
</div> </div>
</div> </div>
@ -345,14 +317,15 @@ export function KnowledgeFilterPanel() {
{/* Search Query */} {/* Search Query */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search-query" className="text-sm font-medium">Search Query</Label> <Label htmlFor="search-query" className="text-sm font-medium">
<Input Search Query
</Label>
<Textarea
id="search-query" id="search-query"
type="text"
placeholder="e.g., 'financial reports from Q4'" placeholder="e.g., 'financial reports from Q4'"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="bg-background/50 border-border/50" rows={3}
/> />
</div> </div>
@ -361,13 +334,15 @@ export function KnowledgeFilterPanel() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Data Sources</Label> <Label className="text-sm font-medium">Data Sources</Label>
<MultiSelect <MultiSelect
options={(availableFacets.data_sources || []).map(bucket => ({ options={(availableFacets.data_sources || []).map((bucket) => ({
value: bucket.key, value: bucket.key,
label: bucket.key, label: bucket.key,
count: bucket.count count: bucket.count,
}))} }))}
value={selectedFilters.data_sources} value={selectedFilters.data_sources}
onValueChange={(values) => handleFilterChange('data_sources', values)} onValueChange={(values) =>
handleFilterChange("data_sources", values)
}
placeholder="Select data sources..." placeholder="Select data sources..."
allOptionLabel="All Data Sources" allOptionLabel="All Data Sources"
/> />
@ -376,13 +351,17 @@ export function KnowledgeFilterPanel() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Document Types</Label> <Label className="text-sm font-medium">Document Types</Label>
<MultiSelect <MultiSelect
options={(availableFacets.document_types || []).map(bucket => ({ options={(availableFacets.document_types || []).map(
value: bucket.key, (bucket) => ({
label: bucket.key, value: bucket.key,
count: bucket.count label: bucket.key,
}))} count: bucket.count,
})
)}
value={selectedFilters.document_types} value={selectedFilters.document_types}
onValueChange={(values) => handleFilterChange('document_types', values)} onValueChange={(values) =>
handleFilterChange("document_types", values)
}
placeholder="Select document types..." placeholder="Select document types..."
allOptionLabel="All Document Types" allOptionLabel="All Document Types"
/> />
@ -391,13 +370,13 @@ export function KnowledgeFilterPanel() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Owners</Label> <Label className="text-sm font-medium">Owners</Label>
<MultiSelect <MultiSelect
options={(availableFacets.owners || []).map(bucket => ({ options={(availableFacets.owners || []).map((bucket) => ({
value: bucket.key, value: bucket.key,
label: bucket.key, label: bucket.key,
count: bucket.count count: bucket.count,
}))} }))}
value={selectedFilters.owners} value={selectedFilters.owners}
onValueChange={(values) => handleFilterChange('owners', values)} onValueChange={(values) => handleFilterChange("owners", values)}
placeholder="Select owners..." placeholder="Select owners..."
allOptionLabel="All Owners" allOptionLabel="All Owners"
/> />
@ -406,13 +385,17 @@ export function KnowledgeFilterPanel() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Sources</Label> <Label className="text-sm font-medium">Sources</Label>
<MultiSelect <MultiSelect
options={(availableFacets.connector_types || []).map(bucket => ({ options={(availableFacets.connector_types || []).map(
value: bucket.key, (bucket) => ({
label: bucket.key, value: bucket.key,
count: bucket.count label: bucket.key,
}))} count: bucket.count,
})
)}
value={selectedFilters.connector_types} value={selectedFilters.connector_types}
onValueChange={(values) => handleFilterChange('connector_types', values)} onValueChange={(values) =>
handleFilterChange("connector_types", values)
}
placeholder="Select sources..." placeholder="Select sources..."
allOptionLabel="All Sources" allOptionLabel="All Sources"
/> />
@ -442,18 +425,23 @@ export function KnowledgeFilterPanel() {
<div className="space-y-4 pt-4 border-t border-border/50"> <div className="space-y-4 pt-4 border-t border-border/50">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">Limit</Label> <Label className="text-sm font-medium text-nowrap">
Response limit
</Label>
<Input <Input
type="number" type="number"
min="1" min="1"
max="1000" max="1000"
value={resultLimit} value={resultLimit}
onChange={(e) => { onChange={(e) => {
const newLimit = Math.max(1, Math.min(1000, parseInt(e.target.value) || 1)) const newLimit = Math.max(
setResultLimit(newLimit) 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" 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' }} style={{ width: "70px" }}
/> />
</div> </div>
<Slider <Slider
@ -469,16 +457,20 @@ export function KnowledgeFilterPanel() {
{/* Score Threshold Control - exactly like search page */} {/* Score Threshold Control - exactly like search page */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">Score Threshold</Label> <Label className="text-sm font-medium text-nowrap">
Score threshold
</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
max="5" max="5"
step="0.1" step="0.1"
value={scoreThreshold} value={scoreThreshold}
onChange={(e) => setScoreThreshold(parseFloat(e.target.value) || 0)} 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" 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' }} style={{ width: "70px" }}
/> />
</div> </div>
<Slider <Slider
@ -493,7 +485,7 @@ export function KnowledgeFilterPanel() {
</div> </div>
{/* Save Configuration Button */} {/* Save Configuration Button */}
<div className="pt-4 border-t border-border/50"> <div className="flex flex-col gap-3 pt-4 border-t border-border/50">
<Button <Button
onClick={handleSaveConfiguration} onClick={handleSaveConfiguration}
disabled={isSaving} disabled={isSaving}
@ -512,10 +504,17 @@ export function KnowledgeFilterPanel() {
</> </>
)} )}
</Button> </Button>
<Button
variant="destructive"
className="w-full"
onClick={handleDeleteFilter}
>
Delete Filter
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }

View file

@ -15,6 +15,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { EndpointType } from "@/contexts/chat-context"; import { EndpointType } from "@/contexts/chat-context";
import { useLoadingStore } from "@/stores/loadingStore"; import { useLoadingStore } from "@/stores/loadingStore";
import { KnowledgeFilterList } from "./knowledge-filter-list";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
interface RawConversation { interface RawConversation {
response_id: string; response_id: string;
@ -74,6 +76,8 @@ export function Navigation() {
const [previousConversationCount, setPreviousConversationCount] = useState(0); const [previousConversationCount, setPreviousConversationCount] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();
const handleNewConversation = () => { const handleNewConversation = () => {
setLoadingNewConversation(true); setLoadingNewConversation(true);
refreshConversations(); refreshConversations();
@ -194,6 +198,7 @@ export function Navigation() {
]; ];
const isOnChatPage = pathname === "/" || pathname === "/chat"; const isOnChatPage = pathname === "/" || pathname === "/chat";
const isOnKnowledgePage = pathname.startsWith("/knowledge");
const createDefaultPlaceholder = useCallback(() => { const createDefaultPlaceholder = useCallback(() => {
return { return {
@ -310,7 +315,7 @@ export function Navigation() {
]); ]);
return ( return (
<div className="space-y-4 py-4 flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
<div className="px-3 py-2 flex-shrink-0"> <div className="px-3 py-2 flex-shrink-0">
<div className="space-y-1"> <div className="space-y-1">
{routes.map((route) => ( {routes.map((route) => (
@ -344,6 +349,13 @@ export function Navigation() {
</div> </div>
</div> </div>
{isOnKnowledgePage && (
<KnowledgeFilterList
selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter}
/>
)}
{/* Chat Page Specific Sections */} {/* Chat Page Specific Sections */}
{isOnChatPage && ( {isOnChatPage && (
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">

View file

@ -1,18 +1,18 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-background px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "primary-input placeholder:font-mono placeholder:text-placeholder-foreground min-h-fit",
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Textarea } export { Textarea };

View file

@ -28,6 +28,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.86.0", "@tanstack/react-query": "^5.86.0",
"ag-grid-community": "^34.2.0", "ag-grid-community": "^34.2.0",
@ -2317,6 +2318,14 @@
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
} }
}, },
"node_modules/@tailwindcss/line-clamp": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
"integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==",
"peerDependencies": {
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
"node_modules/@tailwindcss/typography": { "node_modules/@tailwindcss/typography": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",

View file

@ -29,6 +29,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.86.0", "@tanstack/react-query": "^5.86.0",
"ag-grid-community": "^34.2.0", "ag-grid-community": "^34.2.0",

View file

@ -50,16 +50,31 @@ async function proxyRequest(
try { try {
let body: string | ArrayBuffer | undefined = undefined; let body: string | ArrayBuffer | undefined = undefined;
let willSendBody = false;
if (request.method !== 'GET' && request.method !== 'HEAD') { if (request.method !== 'GET' && request.method !== 'HEAD') {
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
const contentLength = request.headers.get('content-length');
// For file uploads (multipart/form-data), preserve binary data // For file uploads (multipart/form-data), preserve binary data
if (contentType.includes('multipart/form-data')) { if (contentType.includes('multipart/form-data')) {
body = await request.arrayBuffer(); const buf = await request.arrayBuffer();
if (buf && buf.byteLength > 0) {
body = buf;
willSendBody = true;
}
} else { } else {
// For JSON and other text-based content, use text // For JSON and other text-based content, use text
body = await request.text(); const text = await request.text();
if (text && text.length > 0) {
body = text;
willSendBody = true;
}
}
// Guard against incorrect non-zero content-length when there is no body
if (!willSendBody && contentLength) {
// We'll drop content-length/header below
} }
} }
@ -67,18 +82,29 @@ async function proxyRequest(
// Copy relevant headers from the original request // Copy relevant headers from the original request
for (const [key, value] of request.headers.entries()) { for (const [key, value] of request.headers.entries()) {
if (!key.toLowerCase().startsWith('host') && const lower = key.toLowerCase();
!key.toLowerCase().startsWith('x-forwarded') && if (
!key.toLowerCase().startsWith('x-real-ip')) { lower.startsWith('host') ||
headers.set(key, value); lower.startsWith('x-forwarded') ||
lower.startsWith('x-real-ip') ||
lower === 'content-length' ||
(!willSendBody && lower === 'content-type')
) {
continue;
} }
headers.set(key, value);
} }
const response = await fetch(backendUrl, { const init: RequestInit = {
method: request.method, method: request.method,
headers, headers,
body, };
}); if (willSendBody) {
// Convert ArrayBuffer to Uint8Array to satisfy BodyInit in all environments
const bodyInit: BodyInit = typeof body === 'string' ? body : new Uint8Array(body as ArrayBuffer);
init.body = bodyInit;
}
const response = await fetch(backendUrl, init);
const responseBody = await response.text(); const responseBody = await response.text();
const responseHeaders = new Headers(); const responseHeaders = new Headers();

View file

@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KnowledgeFilter } from "../queries/useGetFiltersSearchQuery";
export interface CreateFilterRequest {
name: string;
description?: string;
queryData: string; // stringified ParsedQueryData
}
export interface CreateFilterResponse {
success: boolean;
filter: KnowledgeFilter;
message?: string;
}
async function createFilter(
data: CreateFilterRequest,
): Promise<CreateFilterResponse> {
const response = await fetch("/api/knowledge-filter", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: data.name,
description: data.description ?? "",
queryData: data.queryData,
}),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMessage = (json && (json.error as string)) || "Failed to create knowledge filter";
throw new Error(errorMessage);
}
return json as CreateFilterResponse;
}
export const useCreateFilter = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createFilter,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["knowledge-filters"]});
},
});
};

View file

@ -0,0 +1,39 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
export interface DeleteFilterRequest {
id: string;
}
export interface DeleteFilterResponse {
success: boolean;
message?: string;
}
async function deleteFilter(
data: DeleteFilterRequest,
): Promise<DeleteFilterResponse> {
const response = await fetch(`/api/knowledge-filter/${data.id}`, {
method: "DELETE",
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMessage = (json && (json.error as string)) || "Failed to delete knowledge filter";
throw new Error(errorMessage);
}
return (json as DeleteFilterResponse) || { success: true };
}
export const useDeleteFilter = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteFilter,
onSuccess: () => {
// Invalidate filters queries so UI refreshes automatically
queryClient.invalidateQueries({ queryKey: ["knowledge-filters"] });
},
});
};

View file

@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KnowledgeFilter } from "../queries/useGetFiltersSearchQuery";
export interface UpdateFilterRequest {
id: string;
name?: string;
description?: string;
queryData?: string; // stringified ParsedQueryData
}
export interface UpdateFilterResponse {
success: boolean;
filter: KnowledgeFilter;
message?: string;
}
async function updateFilter(data: UpdateFilterRequest): Promise<UpdateFilterResponse> {
// Build a body with only provided fields
const body: Record<string, unknown> = {};
if (typeof data.name !== "undefined") body.name = data.name;
if (typeof data.description !== "undefined") body.description = data.description;
if (typeof data.queryData !== "undefined") body.queryData = data.queryData;
const response = await fetch(`/api/knowledge-filter/${data.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMessage = (json && (json.error as string)) || "Failed to update knowledge filter";
throw new Error(errorMessage);
}
return json as UpdateFilterResponse;
}
export const useUpdateFilter = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateFilter,
onSuccess: () => {
// Refresh any knowledge filter lists/searches
queryClient.invalidateQueries({ queryKey: ["knowledge-filters"] });
},
});
};

View file

@ -0,0 +1,47 @@
import {
useQuery,
useQueryClient,
type UseQueryOptions,
} from "@tanstack/react-query";
export interface KnowledgeFilter {
id: string;
name: string;
description: string;
query_data: string;
owner: string;
created_at: string;
updated_at: string;
}
export const useGetFiltersSearchQuery = (
search: string,
limit = 20,
options?: Omit<UseQueryOptions<KnowledgeFilter[]>, "queryKey" | "queryFn">
) => {
const queryClient = useQueryClient();
async function getFilters(): Promise<KnowledgeFilter[]> {
const response = await fetch("/api/knowledge-filter/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: search, limit }),
});
const json = await response.json();
if (!response.ok || !json.success) {
// ensure we always return a KnowledgeFilter[] to satisfy the return type
return [];
}
return (json.filters || []) as KnowledgeFilter[];
}
return useQuery<KnowledgeFilter[]>(
{
queryKey: ["knowledge-filters", search, limit],
queryFn: getFilters,
...options,
},
queryClient
);
};

View file

@ -0,0 +1,47 @@
import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
export interface FacetBucket {
key: string;
count: number;
}
export interface SearchAggregations {
data_sources?: { buckets: FacetBucket[] };
document_types?: { buckets: FacetBucket[] };
owners?: { buckets: FacetBucket[] };
connector_types?: { buckets: FacetBucket[] };
}
type Options = Omit<UseQueryOptions<SearchAggregations>, "queryKey" | "queryFn">;
export const useGetSearchAggregations = (
query: string,
limit: number,
scoreThreshold: number,
options?: Options
) => {
const queryClient = useQueryClient();
async function fetchAggregations(): Promise<SearchAggregations> {
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, limit, scoreThreshold }),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((json && json.error) || "Failed to load search aggregations");
}
return (json.aggregations || {}) as SearchAggregations;
}
return useQuery<SearchAggregations>({
queryKey: ["search-aggregations", query, limit, scoreThreshold],
queryFn: fetchAggregations,
placeholderData: prev => prev,
...options,
}, queryClient);
};

View file

@ -162,7 +162,7 @@
} }
.side-bar-arrangement { .side-bar-arrangement {
@apply flex h-full w-[14.5rem] flex-col overflow-hidden border-r scrollbar-hide; @apply flex h-full w-[18rem] flex-col overflow-hidden border-r scrollbar-hide;
} }
.side-bar-search-div-placement { .side-bar-search-div-placement {

View file

@ -4,25 +4,18 @@ import {
Building2, Building2,
Cloud, Cloud,
HardDrive, HardDrive,
Loader2,
Search, Search,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react"; import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
import { import { useCallback, useState, useRef, ChangeEvent } from "react";
type FormEvent,
useCallback,
useEffect,
useState,
useRef,
} from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SiGoogledrive } from "react-icons/si"; import { SiGoogledrive } from "react-icons/si";
import { TbBrandOnedrive } from "react-icons/tb"; import { TbBrandOnedrive } from "react-icons/tb";
import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
import { ProtectedRoute } from "@/components/protected-route"; import { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context"; import { useTask } from "@/contexts/task-context";
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
@ -59,39 +52,22 @@ function getSourceIcon(connectorType?: string) {
function SearchPage() { function SearchPage() {
const router = useRouter(); const router = useRouter();
const { isMenuOpen } = useTask(); const { isMenuOpen } = useTask();
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter(); const { selectedFilter, setSelectedFilter, parsedFilterData, isPanelOpen } =
const [query, setQuery] = useState(""); useKnowledgeFilter();
const [queryInputText, setQueryInputText] = useState("");
const [selectedRows, setSelectedRows] = useState<File[]>([]); const [selectedRows, setSelectedRows] = useState<File[]>([]);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
const deleteDocumentMutation = useDeleteDocument(); const deleteDocumentMutation = useDeleteDocument();
const { const { data = [], isFetching } = useGetSearchQuery(
data = [], parsedFilterData?.query || "*",
isFetching, parsedFilterData
refetch: refetchSearch,
} = useGetSearchQuery(query, parsedFilterData);
// Update query when global filter changes
useEffect(() => {
if (parsedFilterData?.query) {
setQueryInputText(parsedFilterData.query);
}
}, [parsedFilterData]);
const handleSearch = useCallback(
(e?: FormEvent<HTMLFormElement>) => {
if (e) e.preventDefault();
if (query.trim() === queryInputText.trim()) {
refetchSearch();
return;
}
setQuery(queryInputText);
},
[queryInputText, refetchSearch, query]
); );
const handleTableSearch = (e: ChangeEvent<HTMLInputElement>) => {
gridRef.current?.api.setGridOption("quickFilterText", e.target.value);
};
const fileResults = data as File[]; const fileResults = data as File[];
const gridRef = useRef<AgGridReact>(null); const gridRef = useRef<AgGridReact>(null);
@ -147,6 +123,7 @@ function SearchPage() {
{ {
field: "avgScore", field: "avgScore",
headerName: "Avg score", headerName: "Avg score",
initialFlex: 0.5,
cellRenderer: ({ value }: CustomCellRendererProps<File>) => { cellRenderer: ({ value }: CustomCellRendererProps<File>) => {
return ( return (
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded"> <span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
@ -167,9 +144,8 @@ function SearchPage() {
}, },
colId: "actions", colId: "actions",
filter: false, filter: false,
width: 60, minWidth: 0,
minWidth: 60, width: 40,
maxWidth: 60,
resizable: false, resizable: false,
sortable: false, sortable: false,
initialFlex: 0, initialFlex: 0,
@ -244,19 +220,29 @@ function SearchPage() {
</div> </div>
{/* Search Input Area */} {/* Search Input Area */}
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]"> <div className="flex-shrink-0 mb-6 xl:max-w-[75%]">
<form onSubmit={handleSearch} className="flex gap-3"> <form className="flex gap-3">
<Input <div className="primary-input min-h-10 !flex items-center flex-nowrap gap-2 focus-within:border-foreground transition-colors !py-0">
name="search-query" {selectedFilter?.name && (
id="search-query" <div className="flex items-center gap-1 bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded max-w-[300px]">
type="text" <span className="truncate">{selectedFilter?.name}</span>
defaultValue={parsedFilterData?.query} <X
value={queryInputText} aria-label="Remove filter"
onChange={(e) => setQueryInputText(e.target.value)} className="h-4 w-4 flex-shrink-0 cursor-pointer"
placeholder="Search your documents..." onClick={() => setSelectedFilter(null)}
className="flex-1 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 focus-visible:ring-1 focus-visible:ring-ring" />
/> </div>
<Button )}
<input
className="bg-transparent w-full h-full focus:outline-none focus-visible:outline-none placeholder:font-mono"
name="search-query"
id="search-query"
type="text"
placeholder="Search your documents..."
onChange={handleTableSearch}
/>
</div>
{/* <Button
type="submit" type="submit"
variant="outline" variant="outline"
className="rounded-lg p-0 flex-shrink-0" className="rounded-lg p-0 flex-shrink-0"
@ -266,7 +252,7 @@ function SearchPage() {
) : ( ) : (
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
)} )}
</Button> </Button> */}
{/* //TODO: Implement sync button */} {/* //TODO: Implement sync button */}
{/* <Button {/* <Button
type="button" type="button"
@ -276,15 +262,16 @@ function SearchPage() {
> >
Sync Sync
</Button> */} </Button> */}
<Button {selectedRows.length > 0 && (
type="button" <Button
variant="destructive" type="button"
className="rounded-lg flex-shrink-0" variant="destructive"
onClick={() => setShowBulkDeleteDialog(true)} className="rounded-lg flex-shrink-0"
disabled={selectedRows.length === 0} onClick={() => setShowBulkDeleteDialog(true)}
> >
<Trash2 className="h-4 w-4" /> Delete <Trash2 className="h-4 w-4" /> Delete
</Button> </Button>
)}
</form> </form>
</div> </div>
<AgGridReact <AgGridReact
@ -298,8 +285,8 @@ function SearchPage() {
rowMultiSelectWithClick={false} rowMultiSelectWithClick={false}
suppressRowClickSelection={true} suppressRowClickSelection={true}
getRowId={(params) => params.data.filename} getRowId={(params) => params.data.filename}
domLayout="autoHeight"
onSelectionChanged={onSelectionChanged} onSelectionChanged={onSelectionChanged}
suppressHorizontalScroll={false}
noRowsOverlayComponent={() => ( noRowsOverlayComponent={() => (
<div className="text-center"> <div className="text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" /> <Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />

View file

@ -1,10 +1,8 @@
"use client"; "use client";
import { Bell, Loader2 } from "lucide-react"; import { Bell, Loader2 } from "lucide-react";
import Image from "next/image";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown";
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"; import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
import { Navigation } from "@/components/navigation"; import { Navigation } from "@/components/navigation";
import { TaskNotificationMenu } from "@/components/task-notification-menu"; import { TaskNotificationMenu } from "@/components/task-notification-menu";
@ -20,8 +18,7 @@ import Logo from "@/components/logo/logo";
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 } = const { isPanelOpen } = useKnowledgeFilter();
useKnowledgeFilter();
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({ const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode, enabled: isAuthenticated || isNoAuthMode,
@ -36,7 +33,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
(task) => (task) =>
task.status === "pending" || task.status === "pending" ||
task.status === "running" || task.status === "running" ||
task.status === "processing", task.status === "processing"
); );
// Show loading state when backend isn't ready // Show loading state when backend isn't ready
@ -70,10 +67,10 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
<div className="header-end-division"> <div className="header-end-division">
<div className="header-end-display"> <div className="header-end-display">
{/* Knowledge Filter Dropdown */} {/* Knowledge Filter Dropdown */}
<KnowledgeFilterDropdown {/* <KnowledgeFilterDropdown
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter} onFilterSelect={setSelectedFilter}
/> /> */}
{/* GitHub Star Button */} {/* GitHub Star Button */}
{/* <GitHubStarButton repo="phact/openrag" /> */} {/* <GitHubStarButton repo="phact/openrag" /> */}
@ -115,10 +112,10 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
isPanelOpen isPanelOpen
? "md:pr-80" ? "md:pr-80"
: // Only KF panel open: 320px : // Only KF panel open: 320px
"md:pr-6" // Neither open: 24px "md:pr-0" // Neither open: 24px
}`} }`}
> >
<div className="container py-6 lg:py-8">{children}</div> <div className="container py-6 lg:py-8 px-4 lg:px-6">{children}</div>
</main> </main>
<TaskNotificationMenu /> <TaskNotificationMenu />
<KnowledgeFilterPanel /> <KnowledgeFilterPanel />

View file

@ -4,6 +4,7 @@ import tailwindcssTypography from "@tailwindcss/typography";
import { fontFamily } from "tailwindcss/defaultTheme"; import { fontFamily } from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin"; import plugin from "tailwindcss/plugin";
import tailwindcssAnimate from "tailwindcss-animate"; import tailwindcssAnimate from "tailwindcss-animate";
import tailwindcssLineClamp from "@tailwindcss/line-clamp";
const config = { const config = {
darkMode: ["class"], darkMode: ["class"],
@ -175,6 +176,7 @@ const config = {
}, },
plugins: [ plugins: [
tailwindcssAnimate, tailwindcssAnimate,
tailwindcssLineClamp,
tailwindcssForms({ tailwindcssForms({
strategy: "class", strategy: "class",
}), }),

View file

@ -19,10 +19,18 @@ class KnowledgeFilterService:
# Index the knowledge filter document # Index the knowledge filter document
result = await opensearch_client.index( result = await opensearch_client.index(
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_doc["id"], body=filter_doc index=KNOWLEDGE_FILTERS_INDEX_NAME,
id=filter_doc["id"],
body=filter_doc,
refresh="wait_for",
) )
if result.get("result") == "created": if result.get("result") == "created":
# Extra safety: ensure visibility in subsequent searches
try:
await opensearch_client.indices.refresh(index=KNOWLEDGE_FILTERS_INDEX_NAME)
except Exception:
pass
return {"success": True, "id": filter_doc["id"], "filter": filter_doc} return {"success": True, "id": filter_doc["id"], "filter": filter_doc}
else: else:
return {"success": False, "error": "Failed to create knowledge filter"} return {"success": False, "error": "Failed to create knowledge filter"}
@ -138,11 +146,19 @@ class KnowledgeFilterService:
# Update the document # Update the document
result = await opensearch_client.update( result = await opensearch_client.update(
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id, body={"doc": updates} index=KNOWLEDGE_FILTERS_INDEX_NAME,
id=filter_id,
body={"doc": updates},
refresh="wait_for",
) )
if result.get("result") in ["updated", "noop"]: if result.get("result") in ["updated", "noop"]:
# Get the updated document # Get the updated document
# Ensure visibility before fetching/returning
try:
await opensearch_client.indices.refresh(index=KNOWLEDGE_FILTERS_INDEX_NAME)
except Exception:
pass
updated_doc = await opensearch_client.get( updated_doc = await opensearch_client.get(
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id
) )
@ -164,10 +180,17 @@ class KnowledgeFilterService:
) )
result = await opensearch_client.delete( result = await opensearch_client.delete(
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id index=KNOWLEDGE_FILTERS_INDEX_NAME,
id=filter_id,
refresh="wait_for",
) )
if result.get("result") == "deleted": if result.get("result") == "deleted":
# Extra safety: ensure visibility in subsequent searches
try:
await opensearch_client.indices.refresh(index=KNOWLEDGE_FILTERS_INDEX_NAME)
except Exception:
pass
return { return {
"success": True, "success": True,
"message": "Knowledge filter deleted successfully", "message": "Knowledge filter deleted successfully",
@ -230,7 +253,10 @@ class KnowledgeFilterService:
} }
result = await opensearch_client.update( result = await opensearch_client.update(
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id, body=update_body index=KNOWLEDGE_FILTERS_INDEX_NAME,
id=filter_id,
body=update_body,
refresh="wait_for",
) )
if result.get("result") in ["updated", "noop"]: if result.get("result") in ["updated", "noop"]: