- )}
- {!requiresConfirm && data.tool && data.next === "confirm" && (
-
- );
-}
+ );
+});
+
+LLMResponse.displayName = 'LLMResponse';
+
+export default LLMResponse;
diff --git a/frontend/src/components/LoadingIndicator.jsx b/frontend/src/components/LoadingIndicator.jsx
index 46eeb02..dfb38c6 100644
--- a/frontend/src/components/LoadingIndicator.jsx
+++ b/frontend/src/components/LoadingIndicator.jsx
@@ -1,11 +1,24 @@
-import React from "react";
+import React, { memo } from "react";
-export default function LoadingIndicator() {
- return (
-
+ {[0, 1, 2].map((i) => (
+
0 ? `delay-${i}00` : ''}`}
+ />
+ ))}
+ Loading...
+
+ );
+});
+
+LoadingIndicator.displayName = 'LoadingIndicator';
+
+export default LoadingIndicator;
diff --git a/frontend/src/components/MessageBubble.jsx b/frontend/src/components/MessageBubble.jsx
index 59c6b84..f3245a1 100644
--- a/frontend/src/components/MessageBubble.jsx
+++ b/frontend/src/components/MessageBubble.jsx
@@ -1,50 +1,53 @@
-import React from "react";
+import React, { memo } from "react";
-export default function MessageBubble({ message, fallback = "", isUser = false }) {
- const bubbleStyle = isUser
- ? "bg-blue-600 text-white self-end"
- : "bg-gray-300 text-gray-900 dark:bg-gray-600 dark:text-gray-100";
+const MessageBubble = memo(({ message, fallback = "", isUser = false }) => {
+ const displayText = message.response?.trim() ? message.response : fallback;
- const displayText = message.response?.trim() ? message.response : fallback;
+ if (displayText.startsWith("###")) {
+ return null;
+ }
- if (displayText.startsWith("###")) {
- return null;
- }
+ const renderTextWithLinks = (text) => {
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ const parts = text.split(urlRegex);
- // Function to detect and render URLs as links
- const renderTextWithLinks = (text) => {
- const urlRegex = /(https?:\/\/[^\s]+)/g;
- const parts = text.split(urlRegex);
+ return parts.map((part, index) => {
+ if (urlRegex.test(part)) {
+ return (
+
+ {part}
+
+ );
+ }
+ return part;
+ });
+ };
- return parts.map((part, index) => {
- if (urlRegex.test(part)) {
- return (
-
- {part}
-
- );
- }
- return part;
- });
- };
+ return (
+
+ {renderTextWithLinks(displayText)}
+
+ );
+});
- return (
-
- {renderTextWithLinks(displayText)}
-
- );
-}
+MessageBubble.displayName = 'MessageBubble';
+
+export default MessageBubble;
diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx
index 693f313..e6d6387 100644
--- a/frontend/src/components/NavBar.jsx
+++ b/frontend/src/components/NavBar.jsx
@@ -1,11 +1,20 @@
-import React from "react";
+import React, { memo } from "react";
-export default function NavBar({ title }) {
- return (
-
- {title}
- {/* ...any additional nav items... */}
-
- );
-}
+const NavBar = memo(({ title }) => {
+ return (
+
+ );
+});
+
+NavBar.displayName = 'NavBar';
+
+export default NavBar;
diff --git a/frontend/src/pages/App.jsx b/frontend/src/pages/App.jsx
index 7e53144..94ef16a 100644
--- a/frontend/src/pages/App.jsx
+++ b/frontend/src/pages/App.jsx
@@ -1,74 +1,153 @@
-import React, { useEffect, useState, useRef } from "react";
+import React, { useEffect, useState, useRef, useCallback } from "react";
import NavBar from "../components/NavBar";
import ChatWindow from "../components/ChatWindow";
+import { apiService } from "../services/api";
const POLL_INTERVAL = 500; // 0.5 seconds
+const INITIAL_ERROR_STATE = { visible: false, message: '' };
+const DEBOUNCE_DELAY = 300; // 300ms debounce for user input
+
+function useDebounce(value, delay) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
export default function App() {
const containerRef = useRef(null);
const inputRef = useRef(null);
+ const pollingRef = useRef(null);
+ const scrollTimeoutRef = useRef(null);
+
const [conversation, setConversation] = useState([]);
- const [lastMessage, setLastMessage] = useState(null); // New state for tracking the last message
+ const [lastMessage, setLastMessage] = useState(null);
const [userInput, setUserInput] = useState("");
const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(INITIAL_ERROR_STATE);
const [done, setDone] = useState(true);
- useEffect(() => {
- // Poll /get-conversation-history every 0.5 seconds
- const intervalId = setInterval(async () => {
- try {
- const res = await fetch("http://127.0.0.1:8000/get-conversation-history");
- if (res.ok) {
- const data = await res.json();
- const newConversation = data.messages || [];
- setConversation(newConversation);
+ const debouncedUserInput = useDebounce(userInput, DEBOUNCE_DELAY);
- if (newConversation.length > 0) {
- const lastMsg = newConversation[newConversation.length - 1];
- setLoading(lastMsg.actor !== "agent");
- setDone(lastMsg.response.next === "done");
+ // Error handling utility with auto-dismiss
+ const handleError = useCallback((error, context) => {
+ console.error(`${context}:`, error);
+ const errorMessage = error.status === 400
+ ? error.message
+ : `Error ${context.toLowerCase()}. Please try again.`;
+
+ setError({
+ visible: true,
+ message: errorMessage
+ });
+
+ const timer = setTimeout(() => setError(INITIAL_ERROR_STATE), 3000);
+ return () => clearTimeout(timer);
+ }, []);
- // Only scroll if the last message changes
- if (!lastMessage || lastMsg.response.response !== lastMessage.response.response) {
- setLastMessage(lastMsg); // Update the last message
- }
- } else {
- setLoading(false);
- setDone(true);
- setLastMessage(null); // Clear last message if no messages
- }
+ const fetchConversationHistory = useCallback(async () => {
+ try {
+ const data = await apiService.getConversationHistory();
+ const newConversation = data.messages || [];
+
+ setConversation(prevConversation => {
+ // Only update if there are actual changes
+ if (JSON.stringify(prevConversation) !== JSON.stringify(newConversation)) {
+ return newConversation;
}
- } catch (err) {
- console.error("Error fetching conversation history:", err);
+ return prevConversation;
+ });
+
+ if (newConversation.length > 0) {
+ const lastMsg = newConversation[newConversation.length - 1];
+ const isAgentMessage = lastMsg.actor === "agent";
+
+ setLoading(!isAgentMessage);
+ setDone(lastMsg.response.next === "done");
+
+ setLastMessage(prevLastMessage => {
+ if (!prevLastMessage || lastMsg.response.response !== prevLastMessage.response.response) {
+ return lastMsg;
+ }
+ return prevLastMessage;
+ });
+ } else {
+ setLoading(false);
+ setDone(true);
+ setLastMessage(null);
}
- }, POLL_INTERVAL);
+ } catch (err) {
+ handleError(err, "fetching conversation");
+ }
+ }, [handleError]);
- return () => clearInterval(intervalId);
- }, [lastMessage]);
+ // Setup polling with cleanup
+ useEffect(() => {
+ pollingRef.current = setInterval(fetchConversationHistory, POLL_INTERVAL);
+
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ };
+ }, [fetchConversationHistory]);
+
+ const scrollToBottom = useCallback(() => {
+ if (containerRef.current) {
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ }
+
+ scrollTimeoutRef.current = setTimeout(() => {
+ const element = containerRef.current;
+ element.scrollTop = element.scrollHeight;
+ scrollTimeoutRef.current = null;
+ }, 100);
+ }
+ }, []);
+
+ const handleContentChange = useCallback(() => {
+ scrollToBottom();
+ }, [scrollToBottom]);
useEffect(() => {
- if (containerRef.current && lastMessage) {
- containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ if (lastMessage) {
+ scrollToBottom();
}
- }, [lastMessage]); // Scroll only when the last message changes
+ }, [lastMessage, scrollToBottom]);
useEffect(() => {
- if (inputRef.current) {
- inputRef.current.focus(); // Ensure the input box retains focus
+ if (inputRef.current && !loading && !done) {
+ inputRef.current.focus();
}
- }, [userInput, loading, done]);
+
+ return () => {
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ }
+ };
+ }, [loading, done]);
const handleSendMessage = async () => {
- if (!userInput.trim()) return;
+ const trimmedInput = userInput.trim();
+ if (!trimmedInput) return;
+
try {
setLoading(true);
- await fetch(
- `http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent(userInput)}`,
- { method: "POST" }
- );
+ setError(INITIAL_ERROR_STATE);
+ await apiService.sendMessage(trimmedInput);
setUserInput("");
} catch (err) {
- console.error("Error sending prompt:", err);
+ handleError(err, "sending message");
setLoading(false);
}
};
@@ -76,29 +155,25 @@ export default function App() {
const handleConfirm = async () => {
try {
setLoading(true);
- await fetch("http://127.0.0.1:8000/confirm", { method: "POST" });
+ setError(INITIAL_ERROR_STATE);
+ await apiService.confirm();
} catch (err) {
- console.error("Confirm error:", err);
+ handleError(err, "confirming action");
setLoading(false);
}
};
const handleStartNewChat = async () => {
try {
- await fetch(
- `http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent("I'd like to travel for an event.")}`,
- { method: "POST" }
- );
- setConversation([]); // Clear local state
- setLastMessage(null); // Reset last message
+ setError(INITIAL_ERROR_STATE);
+ setLoading(true);
+ await apiService.sendMessage("I'd like to travel for an event.");
+ setConversation([]);
+ setLastMessage(null);
} catch (err) {
- console.error("Error ending chat:", err);
- }
- };
-
- const handleKeyPress = (e) => {
- if (e.key === "Enter") {
- handleSendMessage();
+ handleError(err, "starting new chat");
+ } finally {
+ setLoading(false);
}
};
@@ -106,19 +181,28 @@ export default function App() {
- {/* Centered content, but no manual bottom margin */}
+ {error.visible && (
+
+ {error.message}
+
+ )}
+
- {/* Scrollable chat area */}
-
+ flex flex-col overflow-hidden">
+
{done && (
-
+
Chat ended
)}
@@ -126,41 +210,49 @@ export default function App() {
- {/* Fixed bottom input */}
-
-
+ border-t border-gray-300 dark:border-gray-700 shadow-lg
+ transition-all duration-200"
+ style={{ zIndex: 10 }}>
+
+
+
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 0000000..af93d49
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,75 @@
+const API_BASE_URL = 'http://127.0.0.1:8000';
+
+class ApiError extends Error {
+ constructor(message, status) {
+ super(message);
+ this.status = status;
+ this.name = 'ApiError';
+ }
+}
+
+async function handleResponse(response) {
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new ApiError(
+ errorData.message || 'An error occurred',
+ response.status
+ );
+ }
+ return response.json();
+}
+
+export const apiService = {
+ async getConversationHistory() {
+ try {
+ const res = await fetch(`${API_BASE_URL}/get-conversation-history`);
+ return handleResponse(res);
+ } catch (error) {
+ throw new ApiError(
+ 'Failed to fetch conversation history',
+ error.status || 500
+ );
+ }
+ },
+
+ async sendMessage(message) {
+ if (!message?.trim()) {
+ throw new ApiError('Message cannot be empty', 400);
+ }
+
+ try {
+ const res = await fetch(
+ `${API_BASE_URL}/send-prompt?prompt=${encodeURIComponent(message)}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }
+ );
+ return handleResponse(res);
+ } catch (error) {
+ throw new ApiError(
+ 'Failed to send message',
+ error.status || 500
+ );
+ }
+ },
+
+ async confirm() {
+ try {
+ const res = await fetch(`${API_BASE_URL}/confirm`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+ return handleResponse(res);
+ } catch (error) {
+ throw new ApiError(
+ 'Failed to confirm action',
+ error.status || 500
+ );
+ }
+ }
+};
\ No newline at end of file