refactor using Cursor

This commit is contained in:
Steve Androulakis
2025-01-07 13:19:34 -08:00
parent 99b11099af
commit 1b8b9c9906
8 changed files with 534 additions and 283 deletions

View File

@@ -1,69 +1,106 @@
import React from "react"; import React, { memo, useCallback } from "react";
import LLMResponse from "./LLMResponse"; import LLMResponse from "./LLMResponse";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import LoadingIndicator from "./LoadingIndicator"; import LoadingIndicator from "./LoadingIndicator";
function safeParse(str) { class ChatErrorBoundary extends React.Component {
try { constructor(props) {
return JSON.parse(str); super(props);
} catch (err) { this.state = { hasError: false };
console.error("safeParse error:", err, "Original string:", str); }
return {};
} static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("ChatWindow error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="text-red-500 p-4 text-center">
Something went wrong. Please refresh the page.
</div>
);
}
return this.props.children;
}
} }
export default function ChatWindow({ conversation, loading, onConfirm }) { const safeParse = (str) => {
try {
return typeof str === 'string' ? JSON.parse(str) : str;
} catch (err) {
console.error("safeParse error:", err, "Original string:", str);
return str;
}
};
if (!Array.isArray(conversation)) { const Message = memo(({ msg, idx, isLastMessage, onConfirm, onContentChange }) => {
console.error("ChatWindow expected conversation to be an array, got:", conversation);
return null;
}
const filtered = conversation.filter((msg) => {
const { actor, response } = msg; const { actor, response } = msg;
if (actor === "user") { if (actor === "user") {
return true; return <MessageBubble message={{ response }} isUser />;
} }
if (actor === "agent") { if (actor === "agent") {
const parsed = typeof response === "string" ? safeParse(response) : response; const data = safeParse(response);
return true; // Adjust this logic based on your "next" field. return (
} <LLMResponse
return false;
});
return (
<div className="flex-grow flex flex-col">
{/* Main message container */}
<div className="flex-grow flex flex-col justify-end overflow-y-auto space-y-3">
{filtered.map((msg, idx) => {
const { actor, response } = msg;
if (actor === "user") {
return <MessageBubble key={idx} message={{ response }} isUser />;
} else if (actor === "agent") {
const data =
typeof response === "string" ? safeParse(response) : response;
const isLastMessage = idx === filtered.length - 1;
return (
<LLMResponse
key={idx}
data={data} data={data}
onConfirm={onConfirm} onConfirm={onConfirm}
isLastMessage={isLastMessage} isLastMessage={isLastMessage}
/> onHeightChange={onContentChange}
); />
} );
return null; // Fallback for unsupported actors. }
})}
{/* Loading indicator */}
{loading && (
<div className="pt-2 flex justify-center">
<LoadingIndicator />
</div>
)}
</div>
</div>
);
} return null;
});
Message.displayName = 'Message';
const ChatWindow = memo(({ conversation, loading, onConfirm, onContentChange }) => {
const validateConversation = useCallback((conv) => {
if (!Array.isArray(conv)) {
console.error("ChatWindow expected conversation to be an array, got:", conv);
return [];
}
return conv;
}, []);
const filtered = validateConversation(conversation).filter((msg) => {
const { actor } = msg;
return actor === "user" || actor === "agent";
});
return (
<ChatErrorBoundary>
<div className="flex-grow flex flex-col">
<div className="flex-grow flex flex-col justify-end overflow-y-auto space-y-3">
{filtered.map((msg, idx) => (
<Message
key={`${msg.actor}-${idx}-${typeof msg.response === 'string' ? msg.response : msg.response?.response}`}
msg={msg}
idx={idx}
isLastMessage={idx === filtered.length - 1}
onConfirm={onConfirm}
onContentChange={onContentChange}
/>
))}
{loading && (
<div className="pt-2 flex justify-center">
<LoadingIndicator />
</div>
)}
</div>
</div>
</ChatErrorBoundary>
);
});
ChatWindow.displayName = 'ChatWindow';
export default ChatWindow;

View File

@@ -1,59 +1,65 @@
import React from "react"; import React, { memo } from "react";
export default function ConfirmInline({ data, confirmed, onConfirm }) { const ConfirmInline = memo(({ data, confirmed, onConfirm }) => {
const { args, tool } = data || {}; const { args, tool } = data || {};
if (confirmed) { const renderArgs = () => {
// Once confirmed, show "Running..." state in the same container if (!args) return null;
return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-800"> return (
<div className="text-sm text-gray-600 dark:text-gray-300">
<div>
<strong>Tool:</strong> {tool ?? "Unknown"}
</div>
{args && (
<div className="mt-1"> <div className="mt-1">
<strong>Args:</strong> <strong>Args:</strong>
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap"> <pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap overflow-x-auto">
{JSON.stringify(args, null, 2)} {JSON.stringify(args, null, 2)}
</pre> </pre>
</div> </div>
)} );
</div> };
<div className="mt-2 text-green-600 dark:text-green-400 font-medium">
Running {tool}...
</div>
</div>
);
}
// Not confirmed yet → show confirmation UI if (confirmed) {
return ( return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-800"> <div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded
<div className="text-gray-600 dark:text-gray-300"> bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
<div> <div className="text-sm text-gray-600 dark:text-gray-300">
Agent is ready to run the tool: <strong>{tool ?? "Unknown"}</strong> <div>
<strong>Tool:</strong> {tool ?? "Unknown"}
</div>
{renderArgs()}
</div>
<div className="mt-2 text-green-600 dark:text-green-400 font-medium">
Running {tool}...
</div>
</div>
);
}
return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded
bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
<div className="text-gray-600 dark:text-gray-300">
<div>
Agent is ready to run the tool: <strong>{tool ?? "Unknown"}</strong>
</div>
{renderArgs()}
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Please confirm to proceed.
</div>
</div>
<div className="text-right mt-2">
<button
onClick={onConfirm}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded
transition-colors duration-200 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-opacity-50"
aria-label={`Confirm running ${tool}`}
>
Confirm
</button>
</div>
</div> </div>
{args && ( );
<div className="mt-1 text-sm"> });
With the following parameters
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap"> ConfirmInline.displayName = 'ConfirmInline';
{JSON.stringify(args, null, 2)}
</pre> export default ConfirmInline;
</div>
)}
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Please confirm to proceed.
</div>
</div>
<div className="text-right mt-2">
<button
onClick={onConfirm}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded"
>
Confirm
</button>
</div>
</div>
);
}

View File

@@ -1,44 +1,60 @@
import React, { useState } from "react"; import React, { memo, useEffect } from "react";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import ConfirmInline from "./ConfirmInline"; import ConfirmInline from "./ConfirmInline";
export default function LLMResponse({ data, onConfirm, isLastMessage }) { const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) => {
const [isConfirmed, setIsConfirmed] = useState(false); const [isConfirmed, setIsConfirmed] = React.useState(false);
const responseRef = React.useRef(null);
const handleConfirm = async () => { // Notify parent of height changes when confirm UI appears/changes
if (onConfirm) await onConfirm(); useEffect(() => {
setIsConfirmed(true); if (isLastMessage && responseRef.current && onHeightChange) {
}; onHeightChange();
}
}, [isLastMessage, isConfirmed, onHeightChange]);
// Only requires confirm if data.next === "confirm" AND it's the last message const handleConfirm = async () => {
const requiresConfirm = data.next === "confirm" && isLastMessage; try {
if (onConfirm) await onConfirm();
setIsConfirmed(true);
} catch (error) {
console.error('Error confirming action:', error);
}
};
if (typeof data.response === "object") { const response = typeof data?.response === 'object'
data.response = data.response.response; ? data.response.response
} : data?.response;
let displayText = (data.response || "").trim(); const displayText = (response || '').trim();
if (!displayText && requiresConfirm) { const requiresConfirm = data.next === "confirm" && isLastMessage;
displayText = `Agent is ready to run "${data.tool}". Please confirm.`; const defaultText = requiresConfirm
} ? `Agent is ready to run "${data.tool}". Please confirm.`
: '';
return ( return (
<div className="space-y-2"> <div ref={responseRef} className="space-y-2">
<MessageBubble message={{ response: displayText }} /> <MessageBubble
{requiresConfirm && ( message={{ response: displayText || defaultText }}
<ConfirmInline />
data={data} {requiresConfirm && (
confirmed={isConfirmed} <ConfirmInline
onConfirm={handleConfirm} data={data}
/> confirmed={isConfirmed}
)} onConfirm={handleConfirm}
{!requiresConfirm && data.tool && data.next === "confirm" && ( />
<div className="text-sm text-center text-green-600 dark:text-green-400"> )}
<div> {!requiresConfirm && data.tool && data.next === "confirm" && (
Agent chose tool: <strong>{data.tool ?? "Unknown"}</strong> <div className="text-sm text-center text-green-600 dark:text-green-400">
</div> <div>
Agent chose tool: <strong>{data.tool ?? "Unknown"}</strong>
</div>
</div>
)}
</div> </div>
)} );
</div> });
);
} LLMResponse.displayName = 'LLMResponse';
export default LLMResponse;

View File

@@ -1,11 +1,24 @@
import React from "react"; import React, { memo } from "react";
export default function LoadingIndicator() { const LoadingIndicator = memo(() => {
return ( return (
<div className="flex items-center justify-center space-x-2 pb-4"> <div
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping"></div> className="flex items-center justify-center space-x-2 pb-4"
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping delay-100"></div> role="status"
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping delay-200"></div> aria-label="Loading"
</div> >
); {[0, 1, 2].map((i) => (
} <div
key={i}
className={`w-2 h-2 rounded-full bg-blue-600 animate-ping
${i > 0 ? `delay-${i}00` : ''}`}
/>
))}
<span className="sr-only">Loading...</span>
</div>
);
});
LoadingIndicator.displayName = 'LoadingIndicator';
export default LoadingIndicator;

View File

@@ -1,50 +1,53 @@
import React from "react"; import React, { memo } from "react";
export default function MessageBubble({ message, fallback = "", isUser = false }) { const MessageBubble = memo(({ message, fallback = "", isUser = false }) => {
const bubbleStyle = isUser const displayText = message.response?.trim() ? message.response : fallback;
? "bg-blue-600 text-white self-end"
: "bg-gray-300 text-gray-900 dark:bg-gray-600 dark:text-gray-100";
const displayText = message.response?.trim() ? message.response : fallback; if (displayText.startsWith("###")) {
return null;
}
if (displayText.startsWith("###")) { const renderTextWithLinks = (text) => {
return null; const urlRegex = /(https?:\/\/[^\s]+)/g;
} const parts = text.split(urlRegex);
// Function to detect and render URLs as links return parts.map((part, index) => {
const renderTextWithLinks = (text) => { if (urlRegex.test(part)) {
const urlRegex = /(https?:\/\/[^\s]+)/g; return (
const parts = text.split(urlRegex); <a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 underline"
aria-label={`External link to ${part}`}
>
{part}
</a>
);
}
return part;
});
};
return parts.map((part, index) => { return (
if (urlRegex.test(part)) { <div
return ( className={`
<a inline-block px-4 py-2 mb-1 rounded-lg
key={index} ${isUser
href={part} ? "ml-auto bg-blue-100 dark:bg-blue-900 dark:text-white"
target="_blank" : "mr-auto bg-gray-200 dark:bg-gray-700 dark:text-white"
rel="noopener noreferrer" }
className="text-blue-500 underline" break-words max-w-[75%] transition-colors duration-200
> `}
{part} role="article"
</a> aria-label={`${isUser ? 'User' : 'Agent'} message`}
); >
} {renderTextWithLinks(displayText)}
return part; </div>
}); );
}; });
return ( MessageBubble.displayName = 'MessageBubble';
<div
className={`inline-block px-4 py-2 mb-1 rounded-lg ${ export default MessageBubble;
isUser ? "ml-auto bg-blue-100" : "mr-auto bg-gray-200"
} break-words`}
style={{
whiteSpace: "pre-wrap",
maxWidth: "75%", // or '80%'
}}
>
{renderTextWithLinks(displayText)}
</div>
);
}

View File

@@ -1,11 +1,20 @@
import React from "react"; import React, { memo } from "react";
export default function NavBar({ title }) { const NavBar = memo(({ title }) => {
return ( return (
<header className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70 <header
backdrop-blur-md shadow-md z-10 flex justify-center"> className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70
<h1 className="text-xl font-bold font-poppins">{title}</h1> backdrop-blur-md shadow-md z-10 flex justify-center items-center
{/* ...any additional nav items... */} transition-colors duration-200"
</header> role="banner"
); >
} <h1 className="text-xl font-bold font-poppins">
{title}
</h1>
</header>
);
});
NavBar.displayName = 'NavBar';
export default NavBar;

View File

@@ -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 NavBar from "../components/NavBar";
import ChatWindow from "../components/ChatWindow"; import ChatWindow from "../components/ChatWindow";
import { apiService } from "../services/api";
const POLL_INTERVAL = 500; // 0.5 seconds 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() { export default function App() {
const containerRef = useRef(null); const containerRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const pollingRef = useRef(null);
const scrollTimeoutRef = useRef(null);
const [conversation, setConversation] = useState([]); 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 [userInput, setUserInput] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(INITIAL_ERROR_STATE);
const [done, setDone] = useState(true); const [done, setDone] = useState(true);
useEffect(() => { const debouncedUserInput = useDebounce(userInput, DEBOUNCE_DELAY);
// 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);
if (newConversation.length > 0) { // Error handling utility with auto-dismiss
const lastMsg = newConversation[newConversation.length - 1]; const handleError = useCallback((error, context) => {
setLoading(lastMsg.actor !== "agent"); console.error(`${context}:`, error);
setDone(lastMsg.response.next === "done"); const errorMessage = error.status === 400
? error.message
: `Error ${context.toLowerCase()}. Please try again.`;
// Only scroll if the last message changes setError({
if (!lastMessage || lastMsg.response.response !== lastMessage.response.response) { visible: true,
setLastMessage(lastMsg); // Update the last message message: errorMessage
} });
} else {
setLoading(false); const timer = setTimeout(() => setError(INITIAL_ERROR_STATE), 3000);
setDone(true); return () => clearTimeout(timer);
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) { return prevConversation;
console.error("Error fetching conversation history:", err); });
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); // Setup polling with cleanup
}, [lastMessage]); 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(() => { useEffect(() => {
if (containerRef.current && lastMessage) { if (lastMessage) {
containerRef.current.scrollTop = containerRef.current.scrollHeight; scrollToBottom();
} }
}, [lastMessage]); // Scroll only when the last message changes }, [lastMessage, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current && !loading && !done) {
inputRef.current.focus(); // Ensure the input box retains focus inputRef.current.focus();
} }
}, [userInput, loading, done]);
return () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [loading, done]);
const handleSendMessage = async () => { const handleSendMessage = async () => {
if (!userInput.trim()) return; const trimmedInput = userInput.trim();
if (!trimmedInput) return;
try { try {
setLoading(true); setLoading(true);
await fetch( setError(INITIAL_ERROR_STATE);
`http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent(userInput)}`, await apiService.sendMessage(trimmedInput);
{ method: "POST" }
);
setUserInput(""); setUserInput("");
} catch (err) { } catch (err) {
console.error("Error sending prompt:", err); handleError(err, "sending message");
setLoading(false); setLoading(false);
} }
}; };
@@ -76,29 +155,25 @@ export default function App() {
const handleConfirm = async () => { const handleConfirm = async () => {
try { try {
setLoading(true); setLoading(true);
await fetch("http://127.0.0.1:8000/confirm", { method: "POST" }); setError(INITIAL_ERROR_STATE);
await apiService.confirm();
} catch (err) { } catch (err) {
console.error("Confirm error:", err); handleError(err, "confirming action");
setLoading(false); setLoading(false);
} }
}; };
const handleStartNewChat = async () => { const handleStartNewChat = async () => {
try { try {
await fetch( setError(INITIAL_ERROR_STATE);
`http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent("I'd like to travel for an event.")}`, setLoading(true);
{ method: "POST" } await apiService.sendMessage("I'd like to travel for an event.");
); setConversation([]);
setConversation([]); // Clear local state setLastMessage(null);
setLastMessage(null); // Reset last message
} catch (err) { } catch (err) {
console.error("Error ending chat:", err); handleError(err, "starting new chat");
} } finally {
}; setLoading(false);
const handleKeyPress = (e) => {
if (e.key === "Enter") {
handleSendMessage();
} }
}; };
@@ -106,19 +181,28 @@ export default function App() {
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
<NavBar title="Temporal AI Agent 🤖" /> <NavBar title="Temporal AI Agent 🤖" />
{/* Centered content, but no manual bottom margin */} {error.visible && (
<div className="fixed top-16 left-1/2 transform -translate-x-1/2
bg-red-500 text-white px-4 py-2 rounded shadow-lg z-50
transition-opacity duration-300">
{error.message}
</div>
)}
<div className="flex-grow flex justify-center px-4 py-2 overflow-hidden"> <div className="flex-grow flex justify-center px-4 py-2 overflow-hidden">
<div className="w-full max-w-lg bg-white dark:bg-gray-900 p-8 px-3 rounded shadow-md <div className="w-full max-w-lg bg-white dark:bg-gray-900 p-8 px-3 rounded shadow-md
flex flex-col overflow-hidden"> flex flex-col overflow-hidden">
{/* Scrollable chat area */} <div ref={containerRef}
<div ref={containerRef} className="flex-grow overflow-y-auto pb-20 pt-10"> className="flex-grow overflow-y-auto pb-20 pt-10 scroll-smooth">
<ChatWindow <ChatWindow
conversation={conversation} conversation={conversation}
loading={loading} loading={loading}
onConfirm={handleConfirm} onConfirm={handleConfirm}
onContentChange={handleContentChange}
/> />
{done && ( {done && (
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4"> <div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4
animate-fade-in">
Chat ended Chat ended
</div> </div>
)} )}
@@ -126,41 +210,49 @@ export default function App() {
</div> </div>
</div> </div>
{/* Fixed bottom input */} <div className="fixed bottom-0 left-1/2 transform -translate-x-1/2
<div
className="fixed bottom-0 left-1/2 transform -translate-x-1/2
w-full max-w-lg bg-white dark:bg-gray-900 p-4 w-full max-w-lg bg-white dark:bg-gray-900 p-4
border-t border-gray-300 dark:border-gray-700" border-t border-gray-300 dark:border-gray-700 shadow-lg
style={{ zIndex: 10 }} transition-all duration-200"
> style={{ zIndex: 10 }}>
<div className="flex items-center"> <form onSubmit={(e) => {
e.preventDefault();
handleSendMessage();
}} className="flex items-center">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
className={`flex-grow rounded-l px-3 py-2 border border-gray-300 className={`flex-grow rounded-l px-3 py-2 border border-gray-300
dark:bg-gray-700 dark:border-gray-600 focus:outline-none dark:bg-gray-700 dark:border-gray-600 focus:outline-none
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`} transition-opacity duration-200
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`}
placeholder="Type your message..." placeholder="Type your message..."
value={userInput} value={userInput}
onChange={(e) => setUserInput(e.target.value)} onChange={(e) => setUserInput(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading || done} disabled={loading || done}
aria-label="Type your message"
/> />
<button <button
onClick={handleSendMessage} type="submit"
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-r className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-r
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`} transition-all duration-200
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={loading || done} disabled={loading || done}
aria-label="Send message"
> >
Send Send
</button> </button>
</div> </form>
<div className="text-right mt-3"> <div className="text-right mt-3">
<button <button
onClick={handleStartNewChat} onClick={handleStartNewChat}
className={`text-sm underline text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 className={`text-sm underline text-gray-600 dark:text-gray-400
${!done ? "opacity-0 cursor-not-allowed" : ""}`} hover:text-gray-800 dark:hover:text-gray-200
transition-all duration-200
${!done ? "opacity-0 cursor-not-allowed" : ""}`}
disabled={!done} disabled={!done}
aria-label="Start new chat"
> >
Start New Chat Start New Chat
</button> </button>

View File

@@ -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
);
}
}
};