diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx index bc18f511..adc03658 100644 --- a/frontend/components/navigation.tsx +++ b/frontend/components/navigation.tsx @@ -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, diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 87bcba97..69be28b9 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -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([]) const [input, setInput] = useState("") const [loading, setLoading] = useState(false) @@ -68,6 +79,51 @@ function ChatPage() { const inputRef = useRef(null) const { addTask } = useTask() + // Context-related state + const [selectedFilters, setSelectedFilters] = useState({ + data_sources: [], + document_types: [], + owners: [] + }) + const [resultLimit, setResultLimit] = useState(10) + const [scoreThreshold, setScoreThreshold] = useState(0) + const [loadedContextName, setLoadedContextName] = useState(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() {
Chat + {loadedContextName && ( + + Context: {loadedContextName} + + )}
{/* Async Mode Toggle */} @@ -1016,6 +1085,11 @@ function ChatPage() { Chat with AI about your indexed documents using {endpoint === "chat" ? "Chat" : "Langflow"} endpoint {asyncMode ? " with real-time streaming" : ""} + {loadedContextName && ( + + Using search context with configured filters and settings + + )} diff --git a/frontend/src/app/contexts/page.tsx b/frontend/src/app/contexts/page.tsx new file mode 100644 index 00000000..966f0bb1 --- /dev/null +++ b/frontend/src/app/contexts/page.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState("") + const [selectedContext, setSelectedContext] = useState(null) + const [parsedQueryData, setParsedQueryData] = useState(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 ( +
+ {/* Hero Section */} +
+
+

+ Contexts +

+
+

+ Manage your saved search contexts +

+

+ View and manage your saved search queries, filters, and configurations for quick access to your most important searches. +

+
+ + {/* Search Interface */} + + + + + Search Contexts + + + Search through your saved search contexts by name or description + + + +
+
+ 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" + /> + +
+
+ +
+ {/* Context List */} +
+ {contexts.length === 0 ? ( + + +
+
+ +
+

+ No contexts found +

+

+ Create your first context by saving a search configuration from the search page. +

+
+
+
+ ) : ( +
+ {contexts.map((context) => ( + handleContextClick(context)} + > + +
+
+

{context.name}

+ {context.description && ( +

{context.description}

+ )} +
+
+ + Created {formatDate(context.created_at)} +
+ {context.updated_at !== context.created_at && ( +
+ + Updated {formatDate(context.updated_at)} +
+ )} +
+
+
+
+
+ ))} +
+ )} +
+ + {/* Context Detail Panel */} + {selectedContext && parsedQueryData && ( +
+
+

+ + Context Details +

+
+ +
+ {/* Query Information */} +
+ +
+

{parsedQueryData.query}

+
+
+ + {/* Filters */} + {(parsedQueryData.filters.data_sources.length > 0 || + parsedQueryData.filters.document_types.length > 0 || + parsedQueryData.filters.owners.length > 0) && ( +
+ + + {parsedQueryData.filters.data_sources.length > 0 && ( +
+ +
+ {parsedQueryData.filters.data_sources.map((source, index) => ( +
+ {source} +
+ ))} +
+
+ )} + + {parsedQueryData.filters.document_types.length > 0 && ( +
+ +
+ {parsedQueryData.filters.document_types.map((type, index) => ( +
+ {type} +
+ ))} +
+
+ )} + + {parsedQueryData.filters.owners.length > 0 && ( +
+ +
+ {parsedQueryData.filters.owners.map((owner, index) => ( +
+ {owner} +
+ ))} +
+
+ )} +
+ )} + + {/* Search Settings */} +
+ + +
+
+ + {parsedQueryData.limit} +
+ +
+ + {parsedQueryData.scoreThreshold} +
+
+
+ + {/* Action Buttons */} +
+ + + +
+
+
+ )} +
+
+
+
+ ) +} + +export default function ProtectedContextsPage() { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2c73b59c..e2f2ad55 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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([]) @@ -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(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() { Search Documents + {loadedContextName && ( + + Context: {loadedContextName} + + )} Enter your search query to find relevant documents using hybrid search (semantic + keyword) + {loadedContextName && ( + + Search configuration loaded from saved context + + )} @@ -370,12 +491,7 @@ function SearchPage() { onClick={() => setSidebarOpen(!sidebarOpen)} className="flex items-center gap-2" > - - {getSelectedFilterCount() > 0 && ( - - {getSelectedFilterCount()} - - )} + )}
@@ -479,32 +595,14 @@ function SearchPage() { - {/* Right Sidebar - Filters */} + {/* Right Sidebar - Settings */} {((facets.data_sources?.length ?? 0) > 0 || (facets.document_types?.length ?? 0) > 0 || (facets.owners?.length ?? 0) > 0) && sidebarOpen && (

- - Filters + + Search Configuration

-
- - -
@@ -530,6 +628,26 @@ function SearchPage() { onToggle={() => toggleSection('owners')} /> + {/* All/None buttons - moved below facets */} +
+ + +
+ {/* Result Limit Control */}
@@ -723,6 +841,35 @@ function SearchPage() { />
+ + {/* Save Context Button */} +
+ +
)} @@ -740,6 +887,76 @@ function SearchPage() { )} + + {/* Save Context Modal */} + {showSaveModal && ( +
+
+

Save Search Context

+ +
+
+ + setContextTitle(e.target.value)} + className="mt-1" + /> +
+ +
+ + setContextDescription(e.target.value)} + className="mt-1" + /> +
+
+ +
+ + +
+
+
+ )} ) }