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}
+
+