misc ux improvements
This commit is contained in:
parent
a25ff0d51b
commit
c3b5b33f5c
14 changed files with 1622 additions and 249 deletions
|
|
@ -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>
|
||||
|
|
|
|||
155
frontend/components/ui/command.tsx
Normal file
155
frontend/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
122
frontend/components/ui/dialog.tsx
Normal file
122
frontend/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
230
frontend/components/ui/multi-select.tsx
Normal file
230
frontend/components/ui/multi-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
frontend/components/ui/popover.tsx
Normal file
31
frontend/components/ui/popover.tsx
Normal 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 }
|
||||
28
frontend/components/ui/slider.tsx
Normal file
28
frontend/components/ui/slider.tsx
Normal 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 }
|
||||
318
frontend/package-lock.json
generated
318
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface ParsedQueryData {
|
|||
data_sources: string[]
|
||||
document_types: string[]
|
||||
owners: string[]
|
||||
connector_types: string[]
|
||||
}
|
||||
limit: number
|
||||
scoreThreshold: number
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue