diff --git a/frontend/components/knowledge-filter-list.tsx b/frontend/components/knowledge-filter-list.tsx
new file mode 100644
index 00000000..5815391a
--- /dev/null
+++ b/frontend/components/knowledge-filter-list.tsx
@@ -0,0 +1,340 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent } from "@/components/ui/card";
+
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ ChevronDown,
+ Filter,
+ Search,
+ X,
+ Loader2,
+ Plus,
+ Save,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ useGetFiltersSearchQuery,
+ type KnowledgeFilter,
+} from "@/src/app/api/queries/useGetFiltersSearchQuery";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ 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, setSearchQuery] = useState("");
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [createName, setCreateName] = useState("");
+ const [createDescription, setCreateDescription] = useState("");
+ const [creating, setCreating] = useState(false);
+
+ const {
+ data,
+ isFetching: loading,
+ refetch,
+ } = useGetFiltersSearchQuery(searchQuery, 20, { enabled: true });
+ const filters: KnowledgeFilter[] = (data ?? []) as KnowledgeFilter[];
+
+ const deleteFilter = async (filterId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ try {
+ const response = await fetch(`/api/knowledge-filter/${filterId}`, {
+ method: "DELETE",
+ });
+
+ if (response.ok) {
+ // If this was the selected filter, clear selection
+ if (selectedFilter?.id === filterId) {
+ onFilterSelect(null);
+ }
+ // Refresh list
+ refetch();
+ } else {
+ console.error("Failed to delete knowledge filter");
+ }
+ } catch (error) {
+ console.error("Error deleting knowledge filter:", error);
+ }
+ };
+
+ const handleFilterSelect = (filter: KnowledgeFilter) => {
+ onFilterSelect(filter);
+ };
+
+ const handleClearFilter = () => {
+ onFilterSelect(null);
+ };
+
+ 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 response = await fetch("/api/knowledge-filter", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name: createName.trim(),
+ description: createDescription.trim(),
+ queryData: JSON.stringify(defaultFilterData),
+ }),
+ });
+
+ const result = await response.json();
+ if (response.ok && result.success) {
+ // Create the new filter object
+ const newFilter: KnowledgeFilter = {
+ id: result.filter.id,
+ name: createName.trim(),
+ description: createDescription.trim(),
+ query_data: JSON.stringify(defaultFilterData),
+ owner: result.filter.owner,
+ created_at: result.filter.created_at,
+ updated_at: result.filter.updated_at,
+ };
+
+ // Select the new filter
+ onFilterSelect(newFilter);
+
+ // Close modal and reset form
+ setShowCreateModal(false);
+ setCreateName("");
+ setCreateDescription("");
+ // Refresh list to include newly created filter
+ refetch();
+ } else {
+ console.error("Failed to create knowledge filter:", result.error);
+ }
+ } 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;
+ };
+
+ const getFilterSummary = (filter: KnowledgeFilter): string => {
+ try {
+ const parsed = JSON.parse(filter.query_data) as ParsedQueryData;
+ const parts = [];
+
+ if (parsed.query) parts.push(`"${parsed.query}"`);
+ if (parsed.filters.data_sources.length > 0)
+ parts.push(`${parsed.filters.data_sources.length} sources`);
+ if (parsed.filters.document_types.length > 0)
+ parts.push(`${parsed.filters.document_types.length} types`);
+ if (parsed.filters.owners.length > 0)
+ parts.push(`${parsed.filters.owners.length} owners`);
+
+ return parts.join(" • ") || "No filters";
+ } catch {
+ return "Invalid filter";
+ }
+ };
+
+ return (
+ <>
+
+
+
+ Knowledge Filters
+
+
+
+ {loading ? (
+
+
+
+ Loading...
+
+
+ ) : filters.length === 0 ? (
+
+ {searchQuery ? "No filters found" : "No saved filters"}
+
+ ) : (
+ filters.map((filter) => (
+
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"
+ )}
+ >
+
+
+ {filter.description && (
+
+ {filter.description}
+
+ )}
+
+
+ {new Date(filter.created_at).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+
+ {(() => {
+ const count = parseQueryData(filter.query_data).filters
+ .data_sources.length;
+ return `${count} ${count === 1 ? "source" : "sources"}`;
+ })()}
+
+
+
+
+
+ ))
+ )}
+
+ {/* Create Filter Dialog */}
+
+ >
+ );
+}
diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx
index cf0d66ef..126316ef 100644
--- a/frontend/components/navigation.tsx
+++ b/frontend/components/navigation.tsx
@@ -15,6 +15,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { EndpointType } from "@/contexts/chat-context";
import { useLoadingStore } from "@/stores/loadingStore";
+import { KnowledgeFilterList } from "./knowledge-filter-list";
+import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
interface RawConversation {
response_id: string;
@@ -74,6 +76,8 @@ export function Navigation() {
const [previousConversationCount, setPreviousConversationCount] = useState(0);
const fileInputRef = useRef(null);
+ const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();
+
const handleNewConversation = () => {
setLoadingNewConversation(true);
refreshConversations();
@@ -194,6 +198,7 @@ export function Navigation() {
];
const isOnChatPage = pathname === "/" || pathname === "/chat";
+ const isOnKnowledgePage = pathname.startsWith("/knowledge");
const createDefaultPlaceholder = useCallback(() => {
return {
@@ -344,6 +349,13 @@ export function Navigation() {
+ {isOnKnowledgePage && (
+
+ )}
+
{/* Chat Page Specific Sections */}
{isOnChatPage && (
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index eb0cbb07..26fba763 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -23,6 +23,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tailwindcss/forms": "^0.5.10",
+ "@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.86.0",
"ag-grid-community": "^34.2.0",
@@ -2161,6 +2162,14 @@
"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": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 09dac477..7b196863 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,6 +24,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tailwindcss/forms": "^0.5.10",
+ "@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.86.0",
"ag-grid-community": "^34.2.0",
diff --git a/frontend/src/app/api/queries/useGetFiltersSearchQuery.ts b/frontend/src/app/api/queries/useGetFiltersSearchQuery.ts
new file mode 100644
index 00000000..42cb0a2d
--- /dev/null
+++ b/frontend/src/app/api/queries/useGetFiltersSearchQuery.ts
@@ -0,0 +1,63 @@
+import {
+ type UseQueryOptions,
+ useQuery,
+ useQueryClient,
+} 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 interface FiltersSearchResponse {
+ success: boolean;
+ filters: KnowledgeFilter[];
+ error?: string;
+}
+
+export const useGetFiltersSearchQuery = (
+ search: string,
+ limit = 20,
+ options?: Omit
+) => {
+ const queryClient = useQueryClient();
+
+ async function getFilters(): Promise {
+ try {
+ const response = await fetch("/api/knowledge-filter/search", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ query: search, limit }),
+ });
+
+ const result: FiltersSearchResponse = await response.json();
+ if (response.ok && result.success) {
+ return result.filters || [];
+ }
+ console.error("Failed to load knowledge filters:", result.error);
+ return [];
+ } catch (error) {
+ console.error("Error loading knowledge filters:", error);
+ return [];
+ }
+ }
+
+ const queryResult = useQuery(
+ {
+ queryKey: ["knowledge-filters", search, limit],
+ placeholderData: (prev) => prev,
+ queryFn: getFilters,
+ ...options,
+ },
+ queryClient,
+ );
+
+ return queryResult;
+};
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 12844720..661b4a2d 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -154,7 +154,7 @@
}
.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 {
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index a07cef4f..798f949f 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -4,6 +4,7 @@ import tailwindcssTypography from "@tailwindcss/typography";
import { fontFamily } from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";
import tailwindcssAnimate from "tailwindcss-animate";
+import tailwindcssLineClamp from "@tailwindcss/line-clamp";
const config = {
darkMode: ["class"],
@@ -140,6 +141,7 @@ const config = {
},
plugins: [
tailwindcssAnimate,
+ tailwindcssLineClamp,
tailwindcssForms({
strategy: "class",
}),