contexts ui, search context, chat with contexts

This commit is contained in:
phact 2025-08-12 15:02:12 -04:00
parent 2675c2ff72
commit c6decdccea
4 changed files with 676 additions and 32 deletions

View file

@ -2,7 +2,7 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Search, Settings, MessageCircle, PlugZap } from "lucide-react"
import { Search, Settings, MessageCircle, PlugZap, BookOpenCheck } from "lucide-react"
import { cn } from "@/lib/utils"
export function Navigation() {
@ -27,6 +27,12 @@ export function Navigation() {
href: "/chat",
active: pathname === "/chat",
},
{
label: "Contexts",
icon: BookOpenCheck,
href: "/contexts",
active: pathname.startsWith("/contexts"),
},
{
label: "Connectors",
icon: PlugZap,

View file

@ -1,6 +1,7 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@ -39,13 +40,23 @@ interface ToolCallResult {
type EndpointType = "chat" | "langflow"
interface SelectedFilters {
data_sources: string[]
document_types: string[]
owners: string[]
}
interface RequestBody {
prompt: string
stream?: boolean
previous_response_id?: string
filters?: SelectedFilters
limit?: number
scoreThreshold?: number
}
function ChatPage() {
const searchParams = useSearchParams()
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
@ -68,6 +79,51 @@ function ChatPage() {
const inputRef = useRef<HTMLInputElement>(null)
const { addTask } = useTask()
// Context-related state
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({
data_sources: [],
document_types: [],
owners: []
})
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const [loadedContextName, setLoadedContextName] = useState<string | null>(null)
// Load context if contextId is provided in URL
useEffect(() => {
const contextId = searchParams.get('contextId')
if (contextId) {
loadContext(contextId)
}
}, [searchParams])
const loadContext = async (contextId: string) => {
try {
const response = await fetch(`/api/contexts/${contextId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
const result = await response.json()
if (response.ok && result.success) {
const context = result.context
const parsedQueryData = JSON.parse(context.query_data)
// Load the context data into state
setSelectedFilters(parsedQueryData.filters)
setResultLimit(parsedQueryData.limit)
setScoreThreshold(parsedQueryData.scoreThreshold)
setLoadedContextName(context.name)
} else {
console.error("Failed to load context:", result.error)
}
} catch (error) {
console.error("Error loading context:", error)
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
@ -229,7 +285,10 @@ function ChatPage() {
try {
const requestBody: RequestBody = {
prompt: userMessage.content,
stream: true
stream: true,
filters: selectedFilters,
limit: resultLimit,
scoreThreshold: scoreThreshold
}
// Add previous_response_id if we have one for this endpoint
@ -685,7 +744,12 @@ function ChatPage() {
try {
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"
const requestBody: RequestBody = { prompt: userMessage.content }
const requestBody: RequestBody = {
prompt: userMessage.content,
filters: selectedFilters,
limit: resultLimit,
scoreThreshold: scoreThreshold
}
// Add previous_response_id if we have one for this endpoint
const currentResponseId = previousResponseIds[endpoint]
@ -970,6 +1034,11 @@ function ChatPage() {
<div className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
<CardTitle>Chat</CardTitle>
{loadedContextName && (
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
Context: {loadedContextName}
</span>
)}
</div>
<div className="flex items-center gap-4">
{/* Async Mode Toggle */}
@ -1016,6 +1085,11 @@ function ChatPage() {
<CardDescription>
Chat with AI about your indexed documents using {endpoint === "chat" ? "Chat" : "Langflow"} endpoint
{asyncMode ? " with real-time streaming" : ""}
{loadedContextName && (
<span className="block text-blue-400 text-xs mt-1">
Using search context with configured filters and settings
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4 min-h-0">

View file

@ -0,0 +1,347 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
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 { Search, Loader2, BookOpenCheck, Settings, Calendar, MessageCircle } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
interface Context {
id: string
name: string
description: string
query_data: string
owner: string
created_at: string
updated_at: string
}
interface ParsedQueryData {
query: string
filters: {
data_sources: string[]
document_types: string[]
owners: string[]
}
limit: number
scoreThreshold: number
}
function ContextsPage() {
const router = useRouter()
const [contexts, setContexts] = useState<Context[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [selectedContext, setSelectedContext] = useState<Context | null>(null)
const [parsedQueryData, setParsedQueryData] = useState<ParsedQueryData | null>(null)
const loadContexts = async (query = "") => {
setLoading(true)
try {
const response = await fetch("/api/contexts/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: 50
}),
})
const result = await response.json()
if (response.ok && result.success) {
setContexts(result.contexts)
} else {
console.error("Failed to load contexts:", result.error)
setContexts([])
}
} catch (error) {
console.error("Error loading contexts:", error)
setContexts([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadContexts()
}, [])
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
await loadContexts(searchQuery)
}
const handleContextClick = (context: Context) => {
setSelectedContext(context)
try {
const parsed = JSON.parse(context.query_data) as ParsedQueryData
setParsedQueryData(parsed)
} catch (error) {
console.error("Error parsing query data:", error)
setParsedQueryData(null)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const handleSearchWithContext = () => {
if (!selectedContext) return
router.push(`/?contextId=${selectedContext.id}`)
}
const handleChatWithContext = () => {
if (!selectedContext) return
router.push(`/chat?contextId=${selectedContext.id}`)
}
return (
<div className="space-y-8">
{/* Hero Section */}
<div className="space-y-4">
<div className="mb-4">
<h1 className="text-4xl font-bold tracking-tight text-white">
Contexts
</h1>
</div>
<p className="text-xl text-muted-foreground">
Manage your saved search contexts
</p>
<p className="text-sm text-muted-foreground max-w-2xl">
View and manage your saved search queries, filters, and configurations for quick access to your most important searches.
</p>
</div>
{/* Search Interface */}
<Card className="w-full bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpenCheck className="h-5 w-5" />
Search Contexts
</CardTitle>
<CardDescription>
Search through your saved search contexts by name or description
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-2">
<Input
type="text"
placeholder="Search contexts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20 flex-1"
/>
<Button
type="submit"
disabled={loading}
className="h-12 px-6 transition-all duration-200"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Searching...
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
Search
</>
)}
</Button>
</div>
</form>
<div className="flex gap-6">
{/* Context List */}
<div className="flex-1 space-y-4">
{contexts.length === 0 ? (
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
<BookOpenCheck className="h-8 w-8 text-muted-foreground/50" />
</div>
<p className="text-lg font-medium text-muted-foreground">
No contexts found
</p>
<p className="text-sm text-muted-foreground/70 max-w-md mx-auto">
Create your first context by saving a search configuration from the search page.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{contexts.map((context) => (
<Card
key={context.id}
className={`bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/70 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/10 cursor-pointer ${
selectedContext?.id === context.id ? 'ring-2 ring-blue-500/50 bg-card/70' : ''
}`}
onClick={() => handleContextClick(context)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<h3 className="font-semibold text-lg">{context.name}</h3>
{context.description && (
<p className="text-sm text-muted-foreground">{context.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Created {formatDate(context.created_at)}</span>
</div>
{context.updated_at !== context.created_at && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Updated {formatDate(context.updated_at)}</span>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Context Detail Panel */}
{selectedContext && parsedQueryData && (
<div className="w-64 space-y-6 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Settings className="h-5 w-5" />
Context Details
</h2>
</div>
<div className="space-y-6">
{/* Query Information */}
<div className="space-y-2">
<Label className="text-sm font-medium">Query</Label>
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-sm">{parsedQueryData.query}</p>
</div>
</div>
{/* Filters */}
{(parsedQueryData.filters.data_sources.length > 0 ||
parsedQueryData.filters.document_types.length > 0 ||
parsedQueryData.filters.owners.length > 0) && (
<div className="space-y-4">
<Label className="text-sm font-medium">Filters</Label>
{parsedQueryData.filters.data_sources.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Data Sources</Label>
<div className="space-y-1">
{parsedQueryData.filters.data_sources.map((source, index) => (
<div key={index} className="px-2 py-1 bg-muted/30 rounded text-xs">
{source}
</div>
))}
</div>
</div>
)}
{parsedQueryData.filters.document_types.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Document Types</Label>
<div className="space-y-1">
{parsedQueryData.filters.document_types.map((type, index) => (
<div key={index} className="px-2 py-1 bg-muted/30 rounded text-xs">
{type}
</div>
))}
</div>
</div>
)}
{parsedQueryData.filters.owners.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Owners</Label>
<div className="space-y-1">
{parsedQueryData.filters.owners.map((owner, index) => (
<div key={index} className="px-2 py-1 bg-muted/30 rounded text-xs">
{owner}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Search Settings */}
<div className="space-y-4 pt-4 border-t border-border/50">
<Label className="text-sm font-medium">Search Settings</Label>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-xs text-muted-foreground">Limit</Label>
<span className="text-sm font-mono">{parsedQueryData.limit}</span>
</div>
<div className="flex justify-between items-center">
<Label className="text-xs text-muted-foreground">Score Threshold</Label>
<span className="text-sm font-mono">{parsedQueryData.scoreThreshold}</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-2 pt-4 border-t border-border/50">
<Button
onClick={handleSearchWithContext}
className="w-full flex items-center gap-2"
variant="default"
>
<Search className="h-4 w-4" />
Search with Context
</Button>
<Button
onClick={handleChatWithContext}
className="w-full flex items-center gap-2"
variant="outline"
>
<MessageCircle className="h-4 w-4" />
Chat with Context
</Button>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ProtectedContextsPage() {
return (
<ProtectedRoute>
<ContextsPage />
</ProtectedRoute>
)
}

View file

@ -1,13 +1,14 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useSearchParams } from "next/navigation"
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 { Checkbox } from "@/components/ui/checkbox"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Search, Loader2, FileText, Zap, ChevronDown, ChevronUp, Filter, X } from "lucide-react"
import { Search, Loader2, FileText, Zap, ChevronDown, ChevronUp, Filter, X, Settings, Save } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
interface SearchResult {
@ -61,6 +62,7 @@ interface SelectedFilters {
}
function SearchPage() {
const searchParams = useSearchParams()
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
@ -79,7 +81,52 @@ function SearchPage() {
const [sidebarOpen, setSidebarOpen] = useState(true)
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const [showSaveModal, setShowSaveModal] = useState(false)
const [contextTitle, setContextTitle] = useState("")
const [contextDescription, setContextDescription] = useState("")
const [savingContext, setSavingContext] = useState(false)
const [loadedContextName, setLoadedContextName] = useState<string | null>(null)
// Load context if contextId is provided in URL
useEffect(() => {
const contextId = searchParams.get('contextId')
if (contextId) {
loadContext(contextId)
}
}, [searchParams])
const loadContext = async (contextId: string) => {
try {
const response = await fetch(`/api/contexts/${contextId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
const result = await response.json()
if (response.ok && result.success) {
const context = result.context
const parsedQueryData = JSON.parse(context.query_data)
// Load the context data into state
setQuery(parsedQueryData.query)
setSelectedFilters(parsedQueryData.filters)
setResultLimit(parsedQueryData.limit)
setScoreThreshold(parsedQueryData.scoreThreshold)
setLoadedContextName(context.name)
// Automatically perform the search
setTimeout(() => {
handleSearch()
}, 100)
} else {
console.error("Failed to load context:", result.error)
}
} catch (error) {
console.error("Error loading context:", error)
}
}
const handleSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
@ -229,6 +276,70 @@ function SearchPage() {
selectedFilters.owners.length
}
const handleSaveContext = async () => {
const contextId = searchParams.get('contextId')
// If no contextId present and no title, we need the modal
if (!contextId && !contextTitle.trim()) return
setSavingContext(true)
try {
const contextData = {
query,
filters: selectedFilters,
limit: resultLimit,
scoreThreshold
}
let response;
if (contextId) {
// Update existing context (upsert)
response = await fetch(`/api/contexts/${contextId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
queryData: JSON.stringify(contextData)
}),
})
} else {
// Create new context
response = await fetch("/api/contexts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: contextTitle,
description: contextDescription,
queryData: JSON.stringify(contextData)
}),
})
}
const result = await response.json()
if (response.ok && result.success) {
if (!contextId) {
// Reset modal state only if we were creating a new context
setShowSaveModal(false)
setContextTitle("")
setContextDescription("")
}
console.log(contextId ? "Context updated successfully:" : "Context saved successfully:", result)
} else {
console.error(contextId ? "Failed to update context:" : "Failed to save context:", result.error)
}
} catch (error) {
console.error(contextId ? "Error updating context:" : "Error saving context:", error)
} finally {
setSavingContext(false)
}
}
const FacetSection = ({
title,
buckets,
@ -306,9 +417,19 @@ function SearchPage() {
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Search Documents
{loadedContextName && (
<span className="text-sm font-normal text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
Context: {loadedContextName}
</span>
)}
</CardTitle>
<CardDescription>
Enter your search query to find relevant documents using hybrid search (semantic + keyword)
{loadedContextName && (
<span className="block text-blue-400 text-xs mt-1">
Search configuration loaded from saved context
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -370,12 +491,7 @@ function SearchPage() {
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
{getSelectedFilterCount() > 0 && (
<span className="bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded">
{getSelectedFilterCount()}
</span>
)}
<Settings className="h-4 w-4" />
</Button>
)}
</div>
@ -479,32 +595,14 @@ function SearchPage() {
</div>
</div>
{/* Right Sidebar - Filters */}
{/* Right Sidebar - Settings */}
{((facets.data_sources?.length ?? 0) > 0 || (facets.document_types?.length ?? 0) > 0 || (facets.owners?.length ?? 0) > 0) && sidebarOpen && (
<div className="w-64 space-y-6 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Filter className="h-5 w-5" />
Filters
<Settings className="h-5 w-5" />
Search Configuration
</h2>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={selectAllFilters}
className="text-xs h-auto px-2 py-1"
>
All
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-xs h-auto px-2 py-1"
>
None
</Button>
</div>
</div>
<div className="space-y-6">
@ -530,6 +628,26 @@ function SearchPage() {
onToggle={() => toggleSection('owners')}
/>
{/* All/None buttons - moved below facets */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllFilters}
className="h-auto px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 border-border/50"
>
All
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAllFilters}
className="h-auto px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 border-border/50"
>
None
</Button>
</div>
{/* Result Limit Control */}
<div className="space-y-4 pt-4 border-t border-border/50">
<div className="space-y-2">
@ -723,6 +841,35 @@ function SearchPage() {
/>
</div>
</div>
{/* Save Context Button */}
<div className="pt-4 border-t border-border/50">
<Button
onClick={() => {
const contextId = searchParams.get('contextId')
if (contextId) {
handleSaveContext()
} else {
setShowSaveModal(true)
}
}}
disabled={!searchPerformed || !query.trim() || savingContext}
className="w-full flex items-center gap-2"
variant="outline"
>
{savingContext ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{searchParams.get('contextId') ? 'Updating...' : 'Saving...'}
</>
) : (
<>
<Save className="h-4 w-4" />
{searchParams.get('contextId') ? 'Update Context' : 'Save Context'}
</>
)}
</Button>
</div>
</div>
</div>
)}
@ -740,6 +887,76 @@ function SearchPage() {
)}
</CardContent>
</Card>
{/* Save Context Modal */}
{showSaveModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Save Search Context</h3>
<div className="space-y-4">
<div>
<Label htmlFor="context-title" className="font-medium">
Title <span className="text-red-400">*</span>
</Label>
<Input
id="context-title"
type="text"
placeholder="Enter a title for this search context"
value={contextTitle}
onChange={(e) => setContextTitle(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="context-description" className="font-medium">
Description (optional)
</Label>
<Input
id="context-description"
type="text"
placeholder="Brief description of this search context"
value={contextDescription}
onChange={(e) => setContextDescription(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
onClick={() => {
setShowSaveModal(false)
setContextTitle("")
setContextDescription("")
}}
disabled={savingContext}
>
Cancel
</Button>
<Button
onClick={handleSaveContext}
disabled={!contextTitle.trim() || savingContext}
className="flex items-center gap-2"
>
{savingContext ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
Save Context
</>
)}
</Button>
</div>
</div>
</div>
)}
</div>
)
}