diff --git a/backend/src/database/models.py b/backend/src/database/models.py index daa4333a..4fb262bb 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -24,7 +24,10 @@ class Conversation(Base): ) messages: Mapped[list["Message"]] = relationship( - "Message", back_populates="conversation", cascade="all, delete-orphan" + "Message", + back_populates="conversation", + cascade="all, delete-orphan", + order_by="Message.created_at", ) def __repr__(self) -> str: diff --git a/frontend/nextjs-frontend/app/globals.css b/frontend/nextjs-frontend/app/globals.css index 69830837..15a56385 100644 --- a/frontend/nextjs-frontend/app/globals.css +++ b/frontend/nextjs-frontend/app/globals.css @@ -1,5 +1,8 @@ @import 'tailwindcss'; +/* Enable class-based dark mode for Tailwind v4 */ +@variant dark (&:where(.dark, .dark *)); + :root { --foreground-rgb: 0, 0, 0; --background-start-rgb: 255, 255, 255; diff --git a/frontend/nextjs-frontend/app/layout.tsx b/frontend/nextjs-frontend/app/layout.tsx index 4f7f97ca..dad37de7 100644 --- a/frontend/nextjs-frontend/app/layout.tsx +++ b/frontend/nextjs-frontend/app/layout.tsx @@ -19,7 +19,7 @@ export default function RootLayout({ return ( - +
{children} diff --git a/frontend/nextjs-frontend/app/page.tsx b/frontend/nextjs-frontend/app/page.tsx index 1bf81f6c..ab7be60c 100644 --- a/frontend/nextjs-frontend/app/page.tsx +++ b/frontend/nextjs-frontend/app/page.tsx @@ -1,17 +1,15 @@ 'use client'; -import { useState, useEffect } from 'react'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; import { PaperAirplaneIcon, SunIcon, MoonIcon, BoltIcon, - PlusIcon, - TrashIcon, Bars3Icon, - ArrowLeftIcon, } from '@heroicons/react/24/solid'; import ChatHistory from '../components/ChatHistory'; -import MessageList from '../components/MessageList'; +import MessageList, { ChatMessage } from '../components/MessageList'; import SourceList from '../components/SourceList'; import SuggestedQuestions from '../components/SuggestedQuestions'; import { useTheme } from 'next-themes'; @@ -24,200 +22,436 @@ import './globals.css'; import '../styles/markdown-table.css'; import CopyButton from '../components/CopyButton'; -const CHAT_ENDPOINT = process.env.NEXT_PUBLIC_PROXY_ENDPOINT; +const API_BASE_URL = process.env.NEXT_PUBLIC_PROXY_ENDPOINT; -interface Message { - question: string; - answer: string; - sources: string[]; - timestamp: number; +interface ContextSource { + source?: string; + context?: string; } -interface Thread { - id: string; - title: string; - messages: Message[]; - // suggestedQuestions: string[]; + +interface ConversationListItem { + uuid: string; + title: string | null; + created_at: string; + updated_at: string; } -interface ContextSource { - source: string; - context: string; +interface ConversationMessageResponse { + id: number; + conversation_id: number; + role: 'user' | 'assistant'; + content: string; + context_sources?: unknown; + tools?: string[] | null; + created_at: string; +} + +interface ConversationDetailResponse extends ConversationListItem { + messages: ConversationMessageResponse[]; } -interface ApiResponse { +interface AgentResponsePayload { response: string; - context_sources: ContextSource[]; + context_sources?: unknown; tools?: string[]; } +interface ConversationSummary { + sessionId: string; + title: string; + updatedAt: string; +} + +const mapConversationSummary = ( + item: ConversationListItem +): ConversationSummary => ({ + sessionId: item.uuid, + title: item.title ?? 'Untitled conversation', + updatedAt: item.updated_at, +}); + +const normalizeContextSources = (raw: unknown): ContextSource[] => { + const normalizeEntry = (entry: unknown): ContextSource[] => { + if (!entry) { + return []; + } + + if (typeof entry === 'string') { + return [{ source: entry, context: '' }]; + } + + if (Array.isArray(entry)) { + return entry.flatMap((nested) => normalizeEntry(nested)); + } + + if (typeof entry === 'object') { + const record = entry as Record; + + if (Array.isArray(record.sources)) { + return record.sources.flatMap((nested) => normalizeEntry(nested)); + } + + const sourceCandidate = + (typeof record.source === 'string' && record.source) || + (typeof record.url === 'string' && record.url) || + (typeof record.href === 'string' && record.href) || + ''; + + const contextCandidate = + typeof record.context === 'string' + ? record.context + : typeof record.snippet === 'string' + ? record.snippet + : ''; + + if (!sourceCandidate && !contextCandidate) { + return Object.values(record).flatMap((nested) => + normalizeEntry(nested) + ); + } + + return [ + { + source: sourceCandidate, + context: contextCandidate, + }, + ]; + } + + return []; + }; + + return normalizeEntry(raw); +}; + export default function Home() { - const [threads, setThreads] = useState([]); - const [currentThread, setCurrentThread] = useState(null); + const [conversations, setConversations] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [currentTitle, setCurrentTitle] = useState( + 'Untitled conversation' + ); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const { theme, setTheme } = useTheme(); const [isLoading, setIsLoading] = useState(false); const [responseTime, setResponseTime] = useState(null); const [error, setError] = useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [mounted, setMounted] = useState(false); const { width } = useWindowSize(); const isMobile = width !== undefined && width <= 768; - useEffect(() => { - setIsSidebarOpen(!isMobile); - }, [isMobile]); - - useEffect(() => { - const storedThreads = sessionStorage.getItem('chatThreads'); - if (storedThreads) { - setThreads(JSON.parse(storedThreads)); + const ensureApiBase = useCallback(() => { + if (!API_BASE_URL) { + throw new Error( + 'NEXT_PUBLIC_PROXY_ENDPOINT is not defined. Set it to your backend URL.' + ); } + return API_BASE_URL; }, []); - useEffect(() => { - console.log('Current theme:', theme); - }, [theme]); + const fetchConversations = useCallback(async () => { + try { + const baseUrl = ensureApiBase(); + const response = await fetch(`${baseUrl}/conversations`); - useEffect(() => { - setTheme('dark'); - }, [setTheme]); // Add setTheme to the dependency array + if (!response.ok) { + throw new Error(`Failed to fetch conversations (${response.status}).`); + } - const handleCloseSidebar = () => { - setIsSidebarOpen(false); - }; + const data: ConversationListItem[] = await response.json(); + setConversations(data.map(mapConversationSummary)); + } catch (fetchError) { + console.error('Failed to load conversations:', fetchError); + setError( + fetchError instanceof Error + ? fetchError.message + : 'Unable to load conversations.' + ); + } + }, [ensureApiBase]); + + const loadConversation = useCallback( + async (sessionId: string) => { + if (!sessionId) { + throw new Error('Conversation identifier is missing.'); + } + + try { + const baseUrl = ensureApiBase(); + const response = await fetch(`${baseUrl}/conversations/${sessionId}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Conversation not found.'); + } + throw new Error(`Failed to load conversation (${response.status}).`); + } + + const data: ConversationDetailResponse = await response.json(); + + setCurrentSessionId(data.uuid); + setCurrentTitle(data.title ?? 'Untitled conversation'); + setMessages( + data.messages.map((message) => ({ + id: String(message.id), + role: message.role, + content: message.content, + contextSources: normalizeContextSources(message.context_sources), + createdAt: message.created_at, + })) + ); + setError(null); + } catch (conversationError) { + console.error('Failed to load conversation:', conversationError); + setError( + conversationError instanceof Error + ? conversationError.message + : 'Unable to load the selected conversation.' + ); + } + }, + [ensureApiBase] + ); + + const createConversation = useCallback(async () => { + const baseUrl = ensureApiBase(); + const response = await fetch(`${baseUrl}/conversations`, { + method: 'POST', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to create conversation (${response.status}): ${ + errorText || response.statusText + }` + ); + } + + const data: ConversationDetailResponse = await response.json(); + const summary = mapConversationSummary(data); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (input.trim()) { - setIsLoading(true); - setError(null); - const startTime = Date.now(); + setConversations((prev) => [ + summary, + ...prev.filter( + (conversation) => conversation.sessionId !== summary.sessionId + ), + ]); + setCurrentSessionId(summary.sessionId); + setCurrentTitle(summary.title ?? 'Untitled conversation'); + setMessages([]); + setError(null); + + return summary; + }, [ensureApiBase]); + + const sendMessage = useCallback( + async (prompt: string) => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) { + return; + } + + let optimisticMessage: ChatMessage | null = null; try { - // Get the last 4 messages from the current thread - const lastFourMessages = currentThread - ? currentThread.messages.slice(-4) - : []; - - // Construct the chat history - const chatHistory = lastFourMessages.map((msg) => ({ - User: msg.question, - AI: msg.answer, - })); - - if (!CHAT_ENDPOINT) { - throw new Error('CHAT_ENDPOINT is not defined'); + setIsLoading(true); + setError(null); + + let sessionId = currentSessionId; + if (!sessionId) { + const conversation = await createConversation(); + sessionId = conversation.sessionId; + } + + if (!sessionId) { + throw new Error('Unable to determine the conversation identifier.'); } - const response = await fetch(`${CHAT_ENDPOINT}/ui/chat`, { - method: 'POST', - headers: { - accept: 'application/json', - 'Content-Type': 'application/json', - Origin: window.location.origin, - }, - body: JSON.stringify({ - query: input, - list_context: true, - list_sources: true, - chat_history: chatHistory, - }), - mode: 'cors', - }); + optimisticMessage = { + id: `local-${Date.now()}`, + role: 'user', + content: trimmedPrompt, + contextSources: [], + createdAt: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, optimisticMessage as ChatMessage]); + setInput(''); + + const baseUrl = ensureApiBase(); + const startTime = Date.now(); + const response = await fetch( + `${baseUrl}/conversations/agent-retriever`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: trimmedPrompt, + conversation_uuid: sessionId, + list_context: true, + list_sources: true, + }), + } + ); if (!response.ok) { const errorText = await response.text(); throw new Error( - `HTTP error! status: ${response.status}, message: ${errorText}` + `Agent request failed (${response.status}): ${ + errorText || response.statusText + }` ); } - const data: ApiResponse = await response.json(); - const endTime = Date.now(); - setResponseTime(endTime - startTime); - + const data: AgentResponsePayload = await response.json(); if (!data.response) { - throw new Error('No answer received from the API'); + throw new Error('No response received from the assistant.'); } - const newMessage = { - question: input, - answer: data.response, - // logic here is get the context and sources, get sources from them, fitler out the blank sources from them - sources: - data.context_sources - ?.map((cs) => cs.source) - .filter((source) => source && source.trim() !== '') || [], - timestamp: Date.now(), - }; - const updatedThread = currentThread - ? { - ...currentThread, - messages: [...currentThread.messages, newMessage], - // suggestedQuestions: currentThread.suggestedQuestions, - } - : { - id: Date.now().toString(), - title: input, - messages: [newMessage], - // suggestedQuestions: [], - }; - - setCurrentThread(updatedThread); - setThreads((prev) => { - const updated = currentThread - ? prev.map((t) => (t.id === currentThread.id ? updatedThread : t)) - : [updatedThread, ...prev]; - sessionStorage.setItem('chatThreads', JSON.stringify(updated)); - return updated; - }); - setInput(''); + const endTime = Date.now(); + setResponseTime(endTime - startTime); - setTimeout(() => window.scrollTo(0, document.body.scrollHeight), 100); - } catch (error) { - console.error('Error fetching response:', error); - if (error instanceof Error) { - if (error.message.includes('CORS')) { - setError( - 'CORS error: The server is not configured to accept requests from this origin. Please contact the API administrator.' - ); - } else { - setError(error.message); - } + await Promise.all([loadConversation(sessionId), fetchConversations()]); + } catch (sendError) { + console.error('Error sending message:', sendError); + if (optimisticMessage) { + setMessages((prev) => + prev.filter((message) => message.id !== optimisticMessage?.id) + ); + } + if ( + sendError instanceof TypeError && + sendError.message === 'Failed to fetch' + ) { + setError( + 'Failed to reach the backend. Verify NEXT_PUBLIC_PROXY_ENDPOINT and CORS settings.' + ); } else { - setError('An unknown error occurred'); + setError( + sendError instanceof Error + ? sendError.message + : 'Failed to send the message.' + ); } } finally { setIsLoading(false); } + }, + [ + currentSessionId, + createConversation, + ensureApiBase, + fetchConversations, + loadConversation, + ] + ); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + setIsSidebarOpen(!isMobile); + }, [isMobile]); + + useEffect(() => { + fetchConversations(); + }, [fetchConversations]); + + const handleCloseSidebar = () => { + setIsSidebarOpen(false); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + void sendMessage(input); + }; + + const handleNewConversation = async () => { + try { + await createConversation(); + await fetchConversations(); + } catch (newConversationError) { + console.error( + 'Unable to create a new conversation:', + newConversationError + ); + setError( + newConversationError instanceof Error + ? newConversationError.message + : 'Unable to create a new conversation.' + ); } }; - const handleNewChat = () => { - setCurrentThread(null); + const handleSelectConversation = async (sessionId: string) => { + await loadConversation(sessionId); + if (isMobile) { + setIsSidebarOpen(false); + } }; - const handleSelectThread = (threadId: string) => { - const selected = threads.find((t) => t.id === threadId); - if (selected) setCurrentThread(selected); + const handleDeleteConversation = async (sessionId: string) => { + const conversation = conversations.find((c) => c.sessionId === sessionId); + const confirmed = window.confirm( + `Delete "${conversation?.title || 'Untitled conversation'}"? This cannot be undone.` + ); + + if (!confirmed) return; + + try { + const baseUrl = ensureApiBase(); + const response = await fetch(`${baseUrl}/conversations/${sessionId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Conversation not found.'); + } + throw new Error(`Failed to delete conversation (${response.status}).`); + } + + setConversations((prev) => + prev.filter((conversation) => conversation.sessionId !== sessionId) + ); + + if (currentSessionId === sessionId) { + setCurrentSessionId(null); + setMessages([]); + setCurrentTitle('Untitled conversation'); + } + } catch (deleteError) { + console.error('Failed to delete conversation:', deleteError); + setError( + deleteError instanceof Error + ? deleteError.message + : 'Unable to delete the selected conversation.' + ); + } }; const handleSuggestedQuestion = (question: string) => { setInput(question); - handleSubmit({ - preventDefault: () => {}, - } as React.FormEvent); + void sendMessage(question); }; - const handleDeleteThread = (threadId: string) => { - setThreads((prev) => { - const updated = prev.filter((t) => t.id !== threadId); - sessionStorage.setItem('chatThreads', JSON.stringify(updated)); - return updated; - }); - if (currentThread?.id === threadId) { - setCurrentThread(null); - } - }; + const latestUserMessage = useMemo( + () => [...messages].reverse().find((message) => message.role === 'user'), + [messages] + ); + + const latestAssistantMessage = useMemo( + () => + [...messages].reverse().find((message) => message.role === 'assistant'), + [messages] + ); + + const latestSources = latestAssistantMessage?.contextSources || []; return (
@@ -228,10 +462,11 @@ export default function Home() { } bg-white dark:bg-gray-800 w-64`} > @@ -250,20 +485,26 @@ export default function Home() { )} -

- ORAssistant -

+
+

+ ORAssistant +

+

+ {currentTitle} +

+