Merge pull request #54 from langflow-ai/feat/move-filters
Move knowledge filters list to left nav
This commit is contained in:
commit
8b26de02bb
18 changed files with 888 additions and 311 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
271
frontend/components/knowledge-filter-list.tsx
Normal file
271
frontend/components/knowledge-filter-list.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
50
frontend/src/app/api/mutations/useCreateFilter.ts
Normal file
50
frontend/src/app/api/mutations/useCreateFilter.ts
Normal 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"]});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
39
frontend/src/app/api/mutations/useDeleteFilter.ts
Normal file
39
frontend/src/app/api/mutations/useDeleteFilter.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
52
frontend/src/app/api/mutations/useUpdateFilter.ts
Normal file
52
frontend/src/app/api/mutations/useUpdateFilter.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
47
frontend/src/app/api/queries/useGetFiltersSearchQuery.ts
Normal file
47
frontend/src/app/api/queries/useGetFiltersSearchQuery.ts
Normal 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
|
||||||
|
);
|
||||||
|
};
|
||||||
47
frontend/src/app/api/queries/useGetSearchAggregations.ts
Normal file
47
frontend/src/app/api/queries/useGetSearchAggregations.ts
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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"]:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue