start creating a new filters component

This commit is contained in:
Cole Goldsmith 2025-09-18 16:33:16 -05:00
parent b223f183ee
commit e33adda6fb
7 changed files with 428 additions and 1 deletions

View file

@ -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 (
<>
<div className="flex flex-col items-center gap-1 px-3">
<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}
className="h-8 px-3"
>
<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">
<Filter className="h-4 w-4" />
<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 count = parseQueryData(filter.query_data).filters
.data_sources.length;
return `${count} ${count === 1 ? "source" : "sources"}`;
})()}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => deleteFilter(filter.id, e)}
className="opacity-0 group-hover:opacity-100 h-6 w-6 p-0 bg-transparent hover:bg-gray-700 hover:text-white transition-all duration-200 border border-transparent hover:border-gray-600"
>
<X className="h-3 w-3 text-gray-400 hover:text-white" />
</Button>
</div>
))
)}
</div>
{/* Create Filter Dialog */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Knowledge Filter</DialogTitle>
<DialogDescription>
Save a reusable filter to quickly scope searches across your
knowledge base.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-col gap-2 space-y-2">
<div>
<Label htmlFor="filter-name" className="font-medium">
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">
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>
</div>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -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<HTMLInputElement>(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() {
</div>
</div>
{isOnKnowledgePage && (
<KnowledgeFilterList
selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter}
/>
)}
{/* Chat Page Specific Sections */}
{isOnChatPage && (
<div className="flex-1 min-h-0 flex flex-col">

View file

@ -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",

View file

@ -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",

View file

@ -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<UseQueryOptions, "queryKey" | "queryFn">
) => {
const queryClient = useQueryClient();
async function getFilters(): Promise<KnowledgeFilter[]> {
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;
};

View file

@ -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 {

View file

@ -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",
}),