misc ux improvements

This commit is contained in:
phact 2025-08-21 02:32:28 -04:00
parent a25ff0d51b
commit c3b5b33f5c
14 changed files with 1622 additions and 249 deletions

View file

@ -1,14 +1,14 @@
"use client"
import { useState, useEffect } from 'react'
import { X, Edit3, Save, Settings, ChevronDown, ChevronUp, RefreshCw } from 'lucide-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 { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { MultiSelect } from '@/components/ui/multi-select'
import { Slider } from '@/components/ui/slider'
import { useKnowledgeFilter } from '@/contexts/knowledge-filter-context'
@ -21,6 +21,7 @@ interface AvailableFacets {
data_sources: FacetBucket[]
document_types: FacetBucket[]
owners: FacetBucket[]
connector_types: FacetBucket[]
}
export function KnowledgeFilterPanel() {
@ -37,21 +38,18 @@ export function KnowledgeFilterPanel() {
const [selectedFilters, setSelectedFilters] = useState({
data_sources: ["*"] 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
})
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const [openSections, setOpenSections] = useState({
data_sources: true,
document_types: true,
owners: true
})
// Available facets (loaded from API)
const [availableFacets, setAvailableFacets] = useState<AvailableFacets>({
data_sources: [],
document_types: [],
owners: []
owners: [],
connector_types: []
})
// Load current filter data into controls
@ -67,7 +65,8 @@ export function KnowledgeFilterPanel() {
const processedFilters = {
data_sources: filters.data_sources,
document_types: filters.document_types,
owners: filters.owners
owners: filters.owners,
connector_types: filters.connector_types || ["*"]
}
console.log("[DEBUG] Loading filter selections:", processedFilters)
@ -111,7 +110,8 @@ export function KnowledgeFilterPanel() {
const facets = {
data_sources: result.aggregations.data_sources?.buckets || [],
document_types: result.aggregations.document_types?.buckets || [],
owners: result.aggregations.owners?.buckets || []
owners: result.aggregations.owners?.buckets || [],
connector_types: result.aggregations.connector_types?.buckets || []
}
console.log("[DEBUG] Setting facets:", facets)
setAvailableFacets(facets)
@ -126,12 +126,6 @@ export function KnowledgeFilterPanel() {
// Don't render if panel is closed or no filter selected
if (!isPanelOpen || !selectedFilter || !parsedFilterData) return null
const toggleSection = (section: keyof typeof openSections) => {
setOpenSections(prev => ({
...prev,
[section]: !prev[section]
}))
}
@ -140,7 +134,8 @@ export function KnowledgeFilterPanel() {
setSelectedFilters({
data_sources: ["*"],
document_types: ["*"],
owners: ["*"]
owners: ["*"],
connector_types: ["*"]
})
}
@ -148,7 +143,8 @@ export function KnowledgeFilterPanel() {
setSelectedFilters({
data_sources: [],
document_types: [],
owners: []
owners: [],
connector_types: []
})
}
@ -243,119 +239,11 @@ export function KnowledgeFilterPanel() {
})
}
const FacetSection = ({
title,
buckets,
facetType,
isOpen,
onToggle
}: {
title: string
buckets: FacetBucket[]
facetType: keyof typeof selectedFilters
isOpen: boolean
onToggle: () => void
}) => {
if (!buckets || buckets.length === 0) return null
// "All" is selected if it contains wildcard OR if no specific selections are made
const isAllSelected = selectedFilters[facetType].includes("*")
const handleAllToggle = (checked: boolean) => {
if (checked) {
// Select "All" - clear specific selections and add wildcard
setSelectedFilters(prev => ({
...prev,
[facetType]: ["*"]
}))
} else {
// Unselect "All" - remove wildcard but keep any specific selections
setSelectedFilters(prev => ({
...prev,
[facetType]: prev[facetType].filter(item => item !== "*")
}))
}
}
const handleSpecificToggle = (value: string, checked: boolean) => {
setSelectedFilters(prev => {
let newValues = [...prev[facetType]]
// Remove wildcard if selecting specific items
newValues = newValues.filter(item => item !== "*")
if (checked) {
newValues.push(value)
} else {
newValues = newValues.filter(item => item !== value)
}
return {
...prev,
[facetType]: newValues
}
})
}
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-0 h-auto font-medium text-left">
<span className="text-sm font-medium">{title}</span>
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mt-3">
{/* "All" wildcard option */}
<div className="flex items-center space-x-2 pb-2 border-b border-border/30">
<Checkbox
id={`${facetType}-all`}
checked={isAllSelected}
onCheckedChange={handleAllToggle}
/>
<Label
htmlFor={`${facetType}-all`}
className="text-sm font-medium flex-1 cursor-pointer flex items-center justify-between"
>
<span>All {title}</span>
<span className="text-xs text-blue-500 bg-blue-500/10 px-1.5 py-0.5 rounded ml-2 flex-shrink-0">
*
</span>
</Label>
</div>
{/* Individual items - disabled if "All" is selected */}
{buckets.map((bucket, index) => {
const isSelected = selectedFilters[facetType].includes(bucket.key)
const isDisabled = isAllSelected
return (
<div key={index} className={`flex items-center space-x-2 ${isDisabled ? 'opacity-50' : ''}`}>
<Checkbox
id={`${facetType}-${index}`}
checked={isSelected}
disabled={isDisabled}
onCheckedChange={(checked) =>
handleSpecificToggle(bucket.key, checked as boolean)
}
/>
<Label
htmlFor={`${facetType}-${index}`}
className={`text-sm font-normal flex-1 flex items-center justify-between ${isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className="truncate" title={bucket.key}>
{bucket.key}
</span>
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded ml-2 flex-shrink-0">
{bucket.count}
</span>
</Label>
</div>
)
})}
</CollapsibleContent>
</Collapsible>
)
const handleFilterChange = (facetType: keyof typeof selectedFilters, newValues: string[]) => {
setSelectedFilters(prev => ({
...prev,
[facetType]: newValues
}))
}
return (
@ -468,29 +356,67 @@ export function KnowledgeFilterPanel() {
/>
</div>
{/* Facet Sections - exactly like search page */}
<div className="space-y-6">
<FacetSection
title="Data Sources"
buckets={availableFacets.data_sources || []}
facetType="data_sources"
isOpen={openSections.data_sources}
onToggle={() => toggleSection('data_sources')}
/>
<FacetSection
title="Document Types"
buckets={availableFacets.document_types || []}
facetType="document_types"
isOpen={openSections.document_types}
onToggle={() => toggleSection('document_types')}
/>
<FacetSection
title="Owners"
buckets={availableFacets.owners || []}
facetType="owners"
isOpen={openSections.owners}
onToggle={() => toggleSection('owners')}
/>
{/* Filter Dropdowns */}
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Data Sources</Label>
<MultiSelect
options={(availableFacets.data_sources || []).map(bucket => ({
value: bucket.key,
label: bucket.key,
count: bucket.count
}))}
value={selectedFilters.data_sources}
onValueChange={(values) => handleFilterChange('data_sources', values)}
placeholder="Select data sources..."
allOptionLabel="All Data Sources"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Document Types</Label>
<MultiSelect
options={(availableFacets.document_types || []).map(bucket => ({
value: bucket.key,
label: bucket.key,
count: bucket.count
}))}
value={selectedFilters.document_types}
onValueChange={(values) => handleFilterChange('document_types', values)}
placeholder="Select document types..."
allOptionLabel="All Document Types"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Owners</Label>
<MultiSelect
options={(availableFacets.owners || []).map(bucket => ({
value: bucket.key,
label: bucket.key,
count: bucket.count
}))}
value={selectedFilters.owners}
onValueChange={(values) => handleFilterChange('owners', values)}
placeholder="Select owners..."
allOptionLabel="All Owners"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Sources</Label>
<MultiSelect
options={(availableFacets.connector_types || []).map(bucket => ({
value: bucket.key,
label: bucket.key,
count: bucket.count
}))}
value={selectedFilters.connector_types}
onValueChange={(values) => handleFilterChange('connector_types', values)}
placeholder="Select sources..."
allOptionLabel="All Sources"
/>
</div>
{/* All/None buttons */}
<div className="flex gap-2">
@ -526,16 +452,17 @@ export function KnowledgeFilterPanel() {
const newLimit = Math.max(1, Math.min(1000, parseInt(e.target.value) || 1))
setResultLimit(newLimit)
}}
className="w-16 h-6 text-xs text-center"
className="h-6 text-xs text-right px-2 bg-muted/30 !border-0 rounded ml-auto focus:ring-0 focus:outline-none"
style={{ width: '70px' }}
/>
</div>
<input
type="range"
min={1}
<Slider
value={[resultLimit]}
onValueChange={(values) => setResultLimit(values[0])}
max={1000}
value={resultLimit}
onChange={(e) => setResultLimit(parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
min={1}
step={1}
className="w-full"
/>
</div>
@ -546,21 +473,21 @@ export function KnowledgeFilterPanel() {
<Input
type="number"
min="0"
max="1"
step="0.01"
max="5"
step="0.1"
value={scoreThreshold}
onChange={(e) => setScoreThreshold(parseFloat(e.target.value) || 0)}
className="w-16 h-6 text-xs text-center"
className="h-6 text-xs text-right px-2 bg-muted/30 !border-0 rounded ml-auto focus:ring-0 focus:outline-none"
style={{ width: '70px' }}
/>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={scoreThreshold}
onChange={(e) => setScoreThreshold(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
<Slider
value={[scoreThreshold]}
onValueChange={(values) => setScoreThreshold(values[0])}
max={5}
min={0}
step={0.1}
className="w-full"
/>
</div>
</div>

View file

@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Empty
ref={ref}
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View file

@ -0,0 +1,230 @@
"use client"
import * as React from "react"
import { X, ChevronDown, Check } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
interface Option {
value: string
label: string
count?: number
}
interface MultiSelectProps {
options: Option[]
value: string[]
onValueChange: (value: string[]) => void
placeholder?: string
className?: string
maxSelection?: number
searchPlaceholder?: string
showAllOption?: boolean
allOptionLabel?: string
}
export function MultiSelect({
options,
value,
onValueChange,
placeholder = "Select items...",
className,
maxSelection,
searchPlaceholder = "Search...",
showAllOption = true,
allOptionLabel = "All"
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState("")
const isAllSelected = value.includes("*")
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchValue.toLowerCase())
)
const handleSelect = (optionValue: string) => {
if (optionValue === "*") {
// Toggle "All" selection
if (isAllSelected) {
onValueChange([])
} else {
onValueChange(["*"])
}
} else {
let newValue: string[]
if (value.includes(optionValue)) {
// Remove the item
newValue = value.filter(v => v !== optionValue && v !== "*")
} else {
// Add the item and remove "All" if present
newValue = [...value.filter(v => v !== "*"), optionValue]
// Check max selection limit
if (maxSelection && newValue.length > maxSelection) {
return
}
}
onValueChange(newValue)
}
}
const handleRemove = (optionValue: string) => {
if (optionValue === "*") {
onValueChange([])
} else {
onValueChange(value.filter(v => v !== optionValue))
}
}
const getDisplayText = () => {
if (isAllSelected) {
return allOptionLabel
}
if (value.length === 0) {
return placeholder
}
// Extract the noun from placeholder (e.g., "Select data sources..." -> "data sources")
const noun = placeholder.toLowerCase().replace('select ', '').replace('...', '')
return `${value.length} ${noun}`
}
const getSelectedBadges = () => {
if (isAllSelected) {
return [
<Badge
key="all"
variant="secondary"
className="mr-1 mb-1"
>
{allOptionLabel}
<Button
variant="ghost"
size="sm"
className="ml-1 h-auto p-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
handleRemove("*")
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
]
}
return value.map(val => {
const option = options.find(opt => opt.value === val)
return (
<Badge
key={val}
variant="secondary"
className="mr-1 mb-1"
>
{option?.label || val}
<Button
variant="ghost"
size="sm"
className="ml-1 h-auto p-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
handleRemove(val)
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)
})
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between min-h-[40px] h-auto text-left",
className
)}
>
<span className="text-foreground text-sm">
{getDisplayText()}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandEmpty>No items found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="max-h-64">
{showAllOption && (
<CommandItem
key="all"
onSelect={() => handleSelect("*")}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
isAllSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="flex-1">{allOptionLabel}</span>
<span className="text-xs text-blue-500 bg-blue-500/10 px-1.5 py-0.5 rounded ml-2">
*
</span>
</CommandItem>
)}
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
disabled={isAllSelected}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
<span className="flex-1">{option.label}</span>
{option.count !== undefined && (
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded ml-2">
{option.count}
</span>
)}
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted/40">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View file

@ -11,21 +11,26 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
@ -1206,6 +1211,114 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@ -1434,6 +1547,147 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
@ -1611,6 +1865,45 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@ -3154,6 +3447,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -6035,6 +6344,15 @@
"react": "^19.1.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View file

@ -12,21 +12,26 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"

View file

@ -84,7 +84,7 @@ function AuthCallbackContent() {
await refreshAuth()
// Get redirect URL from login page
const redirectTo = searchParams.get('redirect') || '/knowledge-sources'
const redirectTo = searchParams.get('redirect') || '/chat'
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')

View file

@ -1,13 +1,14 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { useState, useRef, useEffect, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload, AtSign, Plus } from "lucide-react"
import { Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload, AtSign, Plus, X } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { useTask } from "@/contexts/task-context"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
import { useAuth } from "@/contexts/auth-context"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
interface Message {
role: "user" | "assistant"
@ -81,11 +82,19 @@ function ChatPage() {
}>({ chat: null, langflow: null })
const [isUploading, setIsUploading] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false)
const [availableFilters, setAvailableFilters] = useState<any[]>([])
const [filterSearchTerm, setFilterSearchTerm] = useState("")
const [selectedFilterIndex, setSelectedFilterIndex] = useState(0)
const [isFilterHighlighted, setIsFilterHighlighted] = useState(false)
const [dropdownDismissed, setDropdownDismissed] = useState(false)
const dragCounterRef = useRef(0)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const { addTask } = useTask()
const { selectedFilter, parsedFilterData } = useKnowledgeFilter()
const fileInputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const { addTask, isMenuOpen } = useTask()
const { selectedFilter, parsedFilterData, isPanelOpen, setSelectedFilter } = useKnowledgeFilter()
@ -235,15 +244,104 @@ function ChatPage() {
}
}
const handleFilePickerClick = () => {
fileInputRef.current?.click()
}
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
handleFileUpload(files[0])
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const loadAvailableFilters = async () => {
try {
const response = await fetch("/api/knowledge-filter/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "",
limit: 20
}),
})
const result = await response.json()
if (response.ok && result.success) {
setAvailableFilters(result.filters)
} else {
console.error("Failed to load knowledge filters:", result.error)
setAvailableFilters([])
}
} catch (error) {
console.error('Failed to load knowledge filters:', error)
setAvailableFilters([])
}
}
const handleFilterDropdownToggle = () => {
if (!isFilterDropdownOpen) {
loadAvailableFilters()
}
setIsFilterDropdownOpen(!isFilterDropdownOpen)
}
const handleFilterSelect = (filter: any) => {
setSelectedFilter(filter)
setIsFilterDropdownOpen(false)
setFilterSearchTerm("")
setIsFilterHighlighted(false)
// Remove the @searchTerm from the input and replace with filter pill
const words = input.split(' ')
const lastWord = words[words.length - 1]
if (lastWord.startsWith('@')) {
// Remove the @search term
words.pop()
setInput(words.join(' ') + (words.length > 0 ? ' ' : ''))
}
}
useEffect(() => {
scrollToBottom()
}, [messages, streamingMessage])
// Reset selected index when search term changes
useEffect(() => {
setSelectedFilterIndex(0)
}, [filterSearchTerm])
// Auto-focus the input on component mount
useEffect(() => {
inputRef.current?.focus()
}, [])
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isFilterDropdownOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!inputRef.current?.contains(event.target as Node)) {
setIsFilterDropdownOpen(false)
setFilterSearchTerm("")
setSelectedFilterIndex(0)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isFilterDropdownOpen])
const handleSSEStream = async (userMessage: Message) => {
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
@ -720,6 +818,7 @@ function ChatPage() {
setMessages(prev => [...prev, userMessage])
setInput("")
setLoading(true)
setIsFilterHighlighted(false)
if (asyncMode) {
await handleSSEStream(userMessage)
@ -1034,16 +1133,16 @@ function ChatPage() {
}
return (
<div className="fixed inset-0 md:left-72 md:right-6 top-[53px] flex flex-col">
<div className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
isMenuOpen && isPanelOpen ? 'md:right-[704px]' : // Both open: 384px (menu) + 320px (KF panel)
isMenuOpen ? 'md:right-96' : // Only menu open: 384px
isPanelOpen ? 'md:right-80' : // Only KF panel open: 320px
'md:right-6' // Neither open: 24px
}`}>
{/* Debug header - only show in debug mode */}
{isDebugMode && (
<div className="flex items-center justify-between mb-6 px-6 pt-6">
<div className="flex items-center gap-2">
{selectedFilter && (
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
Context: {selectedFilter.name}
</span>
)}
</div>
<div className="flex items-center gap-4">
{/* Async Mode Toggle */}
@ -1226,39 +1325,247 @@ function ChatPage() {
<div className="flex-shrink-0 p-6 pb-8 flex justify-center">
<div className="w-full max-w-[75%]">
<form onSubmit={handleSubmit} className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (input.trim() && !loading) {
// Trigger form submission by finding the form and calling submit
const form = e.currentTarget.closest('form')
if (form) {
form.requestSubmit()
<div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring">
{selectedFilter && (
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
isFilterHighlighted
? 'bg-blue-500/40 text-blue-300 ring-2 ring-blue-400/50'
: 'bg-blue-500/20 text-blue-400'
}`}>
@filter:{selectedFilter.name}
<button
type="button"
onClick={() => {
setSelectedFilter(null)
setIsFilterHighlighted(false)
}}
className="ml-1 hover:bg-blue-500/30 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</span>
</div>
)}
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
const newValue = e.target.value
setInput(newValue)
// Clear filter highlight when user starts typing
if (isFilterHighlighted) {
setIsFilterHighlighted(false)
}
// Find if there's an @ at the start of the last word
const words = newValue.split(' ')
const lastWord = words[words.length - 1]
if (lastWord.startsWith('@') && !dropdownDismissed) {
const searchTerm = lastWord.slice(1) // Remove the @
console.log('Setting search term:', searchTerm)
setFilterSearchTerm(searchTerm)
setSelectedFilterIndex(0)
if (!isFilterDropdownOpen) {
loadAvailableFilters()
setIsFilterDropdownOpen(true)
}
} else if (isFilterDropdownOpen) {
// Close dropdown if @ is no longer present
console.log('Closing dropdown - no @ found')
setIsFilterDropdownOpen(false)
setFilterSearchTerm("")
}
// Reset dismissed flag when user moves to a different word
if (dropdownDismissed && !lastWord.startsWith('@')) {
setDropdownDismissed(false)
}
}}
onKeyDown={(e) => {
// Handle backspace for filter clearing
if (e.key === 'Backspace' && selectedFilter && input.trim() === '') {
e.preventDefault()
if (isFilterHighlighted) {
// Second backspace - remove the filter
setSelectedFilter(null)
setIsFilterHighlighted(false)
} else {
// First backspace - highlight the filter
setIsFilterHighlighted(true)
}
return
}
if (isFilterDropdownOpen) {
const filteredFilters = availableFilters.filter(filter =>
filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase())
)
if (e.key === 'Escape') {
e.preventDefault()
setIsFilterDropdownOpen(false)
setFilterSearchTerm("")
setSelectedFilterIndex(0)
setDropdownDismissed(true)
// Keep focus on the textarea so user can continue typing normally
inputRef.current?.focus()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedFilterIndex(prev =>
prev < filteredFilters.length - 1 ? prev + 1 : 0
)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedFilterIndex(prev =>
prev > 0 ? prev - 1 : filteredFilters.length - 1
)
return
}
if (e.key === 'Enter') {
// Check if we're at the end of an @ mention (space before cursor or end of input)
const cursorPos = e.currentTarget.selectionStart || 0
const textBeforeCursor = input.slice(0, cursorPos)
const words = textBeforeCursor.split(' ')
const lastWord = words[words.length - 1]
if (lastWord.startsWith('@') && filteredFilters[selectedFilterIndex]) {
e.preventDefault()
handleFilterSelect(filteredFilters[selectedFilterIndex])
return
}
}
if (e.key === ' ') {
// Select filter on space if we're typing an @ mention
const cursorPos = e.currentTarget.selectionStart || 0
const textBeforeCursor = input.slice(0, cursorPos)
const words = textBeforeCursor.split(' ')
const lastWord = words[words.length - 1]
if (lastWord.startsWith('@') && filteredFilters[selectedFilterIndex]) {
e.preventDefault()
handleFilterSelect(filteredFilters[selectedFilterIndex])
return
}
}
}
}
}}
placeholder="Type to ask a question..."
disabled={loading}
className="w-full bg-muted/20 rounded-lg border border-border/50 px-4 py-4 min-h-[100px] focus-visible:ring-1 focus-visible:ring-ring resize-none outline-none"
rows={1}
if (e.key === 'Enter' && !e.shiftKey && !isFilterDropdownOpen) {
e.preventDefault()
if (input.trim() && !loading) {
// Trigger form submission by finding the form and calling submit
const form = e.currentTarget.closest('form')
if (form) {
form.requestSubmit()
}
}
}
}}
placeholder="Type to ask a question..."
disabled={loading}
className={`w-full bg-transparent px-4 ${selectedFilter ? 'py-2 pb-4' : 'py-4'} min-h-[100px] focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleFilterDropdownToggle}
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
>
<AtSign className="h-4 w-4" />
</Button>
{isFilterDropdownOpen && (
<div ref={dropdownRef} className="absolute bottom-14 left-0 w-64 bg-popover border border-border rounded-md shadow-md z-50 p-2">
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
onClick={() => handleFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? 'bg-muted/50' : ''
}`}
>
<span>No filter</span>
{!selectedFilter && (
<div className="w-2 h-2 rounded-full bg-blue-500" />
)}
</button>
)}
{availableFilters
.filter(filter =>
filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase())
)
.map((filter, index) => (
<button
key={filter.id}
onClick={() => handleFilterSelect(filter)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? 'bg-muted/50' : ''
}`}
>
<div>
<div className="font-medium">{filter.name}</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<div className="w-2 h-2 rounded-full bg-blue-500" />
)}
</button>
))}
{availableFilters.filter(filter =>
filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase())
).length === 0 && filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match "{filterSearchTerm}"
</div>
)}
</>
)}
</div>
</div>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleFilePickerClick}
disabled={isUploading}
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
>
<Plus className="h-4 w-4" />

View file

@ -5,13 +5,16 @@ import { useState, useEffect, useCallback, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Search, Loader2, FileText } from "lucide-react"
import { Search, Loader2, FileText, HardDrive } from "lucide-react"
import { TbBrandOnedrive } from "react-icons/tb"
import { SiGoogledrive, SiSharepoint, SiAmazonaws } from "react-icons/si"
import { ProtectedRoute } from "@/components/protected-route"
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"
import { useTask } from "@/contexts/task-context"
type FacetBucket = { key: string; count: number }
interface SearchResult {
interface ChunkResult {
filename: string
mimetype: string
page: number
@ -19,19 +22,58 @@ interface SearchResult {
score: number
source_url?: string
owner?: string
owner_name?: string
owner_email?: string
file_size?: number
connector_type?: string
}
interface FileResult {
filename: string
mimetype: string
chunkCount: number
avgScore: number
source_url?: string
owner?: string
owner_name?: string
owner_email?: string
lastModified?: string
size?: number
connector_type?: string
}
interface SearchResponse {
results: SearchResult[]
results: ChunkResult[]
files?: FileResult[]
error?: string
}
function SearchPage() {
// Function to get the appropriate icon for a connector type
function getSourceIcon(connectorType?: string) {
switch (connectorType) {
case 'google_drive':
return <SiGoogledrive className="h-4 w-4 text-foreground" />
case 'onedrive':
return <TbBrandOnedrive className="h-4 w-4 text-foreground" />
case 'sharepoint':
return <SiSharepoint className="h-4 w-4 text-foreground" />
case 's3':
return <SiAmazonaws className="h-4 w-4 text-foreground" />
case 'local':
default:
return <HardDrive className="h-4 w-4 text-muted-foreground" />
}
}
const { parsedFilterData } = useKnowledgeFilter()
function SearchPage() {
const { isMenuOpen } = useTask()
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter()
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [chunkResults, setChunkResults] = useState<ChunkResult[]>([])
const [fileResults, setFileResults] = useState<FileResult[]>([])
const [viewMode, setViewMode] = useState<'files' | 'chunks'>('files')
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [searchPerformed, setSearchPerformed] = useState(false)
const prevFilterDataRef = useRef<string>("")
@ -39,7 +81,7 @@ function SearchPage() {
const [statsLoading, setStatsLoading] = useState<boolean>(false)
const [totalDocs, setTotalDocs] = useState<number>(0)
const [totalChunks, setTotalChunks] = useState<number>(0)
const [facetStats, setFacetStats] = useState<{ data_sources: FacetBucket[]; document_types: FacetBucket[]; owners: FacetBucket[] } | null>(null)
const [facetStats, setFacetStats] = useState<{ data_sources: FacetBucket[]; document_types: FacetBucket[]; owners: FacetBucket[]; connector_types: FacetBucket[] } | null>(null)
const handleSearch = useCallback(async (e?: React.FormEvent) => {
if (e) e.preventDefault()
@ -63,10 +105,15 @@ function SearchPage() {
const searchPayload: SearchPayload = {
query,
limit: parsedFilterData?.limit || 10,
limit: parsedFilterData?.limit || (query.trim() === "*" ? 50 : 10), // Higher limit for wildcard searches
scoreThreshold: parsedFilterData?.scoreThreshold || 0
}
// Debug logging for wildcard searches
if (query.trim() === "*") {
console.log("Wildcard search - parsedFilterData:", parsedFilterData)
}
// Add filters from global context if available and not wildcards
if (parsedFilterData?.filters) {
const filters = parsedFilterData.filters
@ -75,7 +122,8 @@ function SearchPage() {
const hasSpecificFilters =
!filters.data_sources.includes("*") ||
!filters.document_types.includes("*") ||
!filters.owners.includes("*")
!filters.owners.includes("*") ||
(filters.connector_types && !filters.connector_types.includes("*"))
if (hasSpecificFilters) {
const processedFilters: SearchPayload['filters'] = {}
@ -90,6 +138,9 @@ function SearchPage() {
if (!filters.owners.includes("*")) {
processedFilters.owners = filters.owners
}
if (filters.connector_types && !filters.connector_types.includes("*")) {
processedFilters.connector_types = filters.connector_types
}
// Only add filters object if it has any actual filters
if (Object.keys(processedFilters).length > 0) {
@ -110,16 +161,80 @@ function SearchPage() {
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
const chunks = result.results || []
// Debug logging for wildcard searches
if (query.trim() === "*") {
console.log("Wildcard search results:", {
chunks: chunks.length,
totalFromBackend: result.total,
searchPayload,
firstChunk: chunks[0]
})
}
setChunkResults(chunks)
// Group chunks by filename to create file results
const fileMap = new Map<string, {
filename: string
mimetype: string
chunks: ChunkResult[]
totalScore: number
source_url?: string
owner?: string
owner_name?: string
owner_email?: string
file_size?: number
connector_type?: string
}>()
chunks.forEach(chunk => {
const existing = fileMap.get(chunk.filename)
if (existing) {
existing.chunks.push(chunk)
existing.totalScore += chunk.score
} else {
fileMap.set(chunk.filename, {
filename: chunk.filename,
mimetype: chunk.mimetype,
chunks: [chunk],
totalScore: chunk.score,
source_url: chunk.source_url,
owner: chunk.owner,
owner_name: chunk.owner_name,
owner_email: chunk.owner_email,
file_size: chunk.file_size,
connector_type: chunk.connector_type
})
}
})
const files: FileResult[] = Array.from(fileMap.values()).map(file => ({
filename: file.filename,
mimetype: file.mimetype,
chunkCount: file.chunks.length,
avgScore: file.totalScore / file.chunks.length,
source_url: file.source_url,
owner: file.owner,
owner_name: file.owner_name,
owner_email: file.owner_email,
size: file.file_size,
connector_type: file.connector_type
}))
setFileResults(files)
setSearchPerformed(true)
} else {
console.error("Search failed:", result.error)
setResults([])
setChunkResults([])
setFileResults([])
setSearchPerformed(true)
}
} catch (error) {
console.error("Search error:", error)
setResults([])
setChunkResults([])
setFileResults([])
setSearchPerformed(true)
} finally {
setLoading(false)
@ -177,7 +292,7 @@ function SearchPage() {
const searchPayload: SearchPayload = {
query: '*',
limit: 0,
limit: 50, // Get more results to ensure we have owner mapping data
scoreThreshold: parsedFilterData?.scoreThreshold || 0
}
@ -189,7 +304,8 @@ function SearchPage() {
const hasSpecificFilters =
!filters.data_sources.includes("*") ||
!filters.document_types.includes("*") ||
!filters.owners.includes("*")
!filters.owners.includes("*") ||
(filters.connector_types && !filters.connector_types.includes("*"))
if (hasSpecificFilters) {
const processedFilters: SearchPayload['filters'] = {}
@ -204,6 +320,9 @@ function SearchPage() {
if (!filters.owners.includes("*")) {
processedFilters.owners = filters.owners
}
if (filters.connector_types && !filters.connector_types.includes("*")) {
processedFilters.connector_types = filters.connector_types
}
// Only add filters object if it has any actual filters
if (Object.keys(processedFilters).length > 0) {
@ -222,11 +341,15 @@ function SearchPage() {
const aggs = result.aggregations || {}
const toBuckets = (agg: { buckets?: Array<{ key: string | number; doc_count: number }> }): FacetBucket[] =>
(agg?.buckets || []).map(b => ({ key: String(b.key), count: b.doc_count }))
// Now we can aggregate directly on owner names since they're keyword fields
const dataSourceBuckets = toBuckets(aggs.data_sources)
setFacetStats({
data_sources: dataSourceBuckets.slice(0, 10),
document_types: toBuckets(aggs.document_types).slice(0, 10),
owners: toBuckets(aggs.owners).slice(0, 10)
owners: toBuckets(aggs.owners).slice(0, 10),
connector_types: toBuckets(aggs.connector_types || {}).slice(0, 10)
})
setTotalDocs(dataSourceBuckets.length)
setTotalChunks(Number(result.total || 0))
@ -247,7 +370,12 @@ function SearchPage() {
return (
<div className="fixed inset-0 md:left-72 md:right-6 top-[53px] flex flex-col">
<div className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
isMenuOpen && isPanelOpen ? 'md:right-[704px]' : // Both open: 384px (menu) + 320px (KF panel)
isMenuOpen ? 'md:right-96' : // Only menu open: 384px
isPanelOpen ? 'md:right-80' : // Only KF panel open: 320px
'md:right-6' // Neither open: 24px
}`}>
<div className="flex-1 flex flex-col min-h-0 px-6 py-6">
{/* Search Input Area */}
<div className="flex-shrink-0 mb-6">
@ -279,7 +407,7 @@ function SearchPage() {
<div className="flex-1 overflow-y-auto">
{searchPerformed ? (
<div className="space-y-4">
{results.length === 0 ? (
{fileResults.length === 0 && chunkResults.length === 0 ? (
<div className="text-center py-12">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">No documents found</p>
@ -289,29 +417,143 @@ function SearchPage() {
</div>
) : (
<>
<div className="text-sm text-muted-foreground mb-4">
{results.length} result{results.length !== 1 ? 's' : ''} found
{/* View Toggle and Results Count */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{viewMode === 'files' ? fileResults.length : chunkResults.length} {viewMode === 'files' ? 'file' : 'result'}{(viewMode === 'files' ? fileResults.length : chunkResults.length) !== 1 ? 's' : ''} found
</div>
<div className="flex gap-2">
<Button
variant={viewMode === 'files' ? 'default' : 'outline'}
size="sm"
onClick={() => {setViewMode('files'); setSelectedFile(null)}}
>
Files
</Button>
<Button
variant={viewMode === 'chunks' ? 'default' : 'outline'}
size="sm"
onClick={() => {setViewMode('chunks'); setSelectedFile(null)}}
>
Chunks
</Button>
</div>
</div>
{/* Results Display */}
<div className="space-y-4">
{results.map((result, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{result.filename}</span>
{viewMode === 'files' ? (
selectedFile ? (
// Show chunks for selected file
<>
<div className="flex items-center gap-2 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
>
Back to files
</Button>
<span className="text-sm text-muted-foreground">
Chunks from {selectedFile}
</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{result.score.toFixed(2)}
</span>
{chunkResults
.filter(chunk => chunk.filename === selectedFile)
.map((chunk, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{chunk.filename}</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{chunk.mimetype} Page {chunk.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</>
) : (
// Show files table
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50 bg-muted/10">
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Source</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Type</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Size</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Chunks</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Score</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">Owner</th>
</tr>
</thead>
<tbody>
{fileResults.map((file, index) => (
<tr
key={index}
className="border-b border-border/30 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => setSelectedFile(file.filename)}
>
<td className="p-3">
<div className="flex items-center gap-2">
{getSourceIcon(file.connector_type)}
<span className="font-medium truncate" title={file.filename}>
{file.filename}
</span>
</div>
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.mimetype}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.size ? `${Math.round(file.size / 1024)} KB` : '—'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.chunkCount}
</td>
<td className="p-3">
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{file.avgScore.toFixed(2)}
</span>
</td>
<td className="p-3 text-sm text-muted-foreground" title={file.owner_email}>
{file.owner_name || file.owner || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-sm text-muted-foreground mb-2">
{result.mimetype} Page {result.page}
)
) : (
// Show chunks view
chunkResults.map((result, index) => (
<div key={index} className="bg-muted/20 rounded-lg p-4 border border-border/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">{result.filename}</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{result.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{result.mimetype} Page {result.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{result.text}
</p>
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{result.text}
</p>
</div>
))}
))
)}
</div>
</>
)}
@ -334,11 +576,21 @@ function SearchPage() {
<div className="border-t border-border/50 my-6" />
{/* Chunks and breakdown */}
<div className="grid gap-6 md:grid-cols-4">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Total chunks</div>
<div className="text-2xl font-semibold">{statsLoading ? '—' : totalChunks}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Top sources</div>
<div className="flex flex-wrap gap-2">
{(facetStats?.connector_types || []).slice(0,5).map((b) => (
<Badge key={`connector-${b.key}`} variant="secondary">
<span className="capitalize">{b.key}</span> · {b.count}
</Badge>
))}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Top types</div>
<div className="flex flex-wrap gap-2">
@ -355,14 +607,6 @@ function SearchPage() {
))}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Top files</div>
<div className="flex flex-wrap gap-2">
{(facetStats?.data_sources || []).slice(0,5).map((b) => (
<Badge key={`file-${b.key}`} variant="secondary" title={b.key}>{b.key} · {b.count}</Badge>
))}
</div>
</div>
</div>
</div>
</div>

View file

@ -11,7 +11,7 @@ function LoginPageContent() {
const { isLoading, isAuthenticated, login } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/knowledge-sources'
const redirect = searchParams.get('redirect') || '/chat'
// Redirect if already authenticated
useEffect(() => {

View file

@ -88,7 +88,12 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
<div className="side-bar-arrangement bg-background fixed left-0 top-[53px] bottom-0 md:flex hidden">
<Navigation />
</div>
<main className={`md:pl-72 md:pr-6 ${(isMenuOpen || isPanelOpen) ? 'md:pr-[336px]' : ''}`}>
<main className={`md:pl-72 transition-all duration-300 ${
isMenuOpen && isPanelOpen ? 'md:pr-[728px]' : // Both open: 384px (menu) + 320px (KF panel) + 24px (original padding)
isMenuOpen ? 'md:pr-96' : // Only menu open: 384px
isPanelOpen ? 'md:pr-80' : // Only KF panel open: 320px
'md:pr-6' // Neither open: 24px
}`}>
<div className="container py-6 lg:py-8">
{children}
</div>

View file

@ -18,6 +18,7 @@ interface ParsedQueryData {
data_sources: string[]
document_types: string[]
owners: string[]
connector_types: string[]
}
limit: number
scoreThreshold: number