mirror of
https://github.com/temporal-community/temporal-ai-agent.git
synced 2026-03-15 14:08:08 +01:00
refactor using Cursor
This commit is contained in:
@@ -1,69 +1,106 @@
|
||||
import React from "react";
|
||||
import React, { memo, useCallback } from "react";
|
||||
import LLMResponse from "./LLMResponse";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
function safeParse(str) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (err) {
|
||||
console.error("safeParse error:", err, "Original string:", str);
|
||||
return {};
|
||||
}
|
||||
class ChatErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
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)) {
|
||||
console.error("ChatWindow expected conversation to be an array, got:", conversation);
|
||||
return null;
|
||||
}
|
||||
|
||||
const filtered = conversation.filter((msg) => {
|
||||
const Message = memo(({ msg, idx, isLastMessage, onConfirm, onContentChange }) => {
|
||||
const { actor, response } = msg;
|
||||
|
||||
|
||||
if (actor === "user") {
|
||||
return true;
|
||||
return <MessageBubble message={{ response }} isUser />;
|
||||
}
|
||||
|
||||
if (actor === "agent") {
|
||||
const parsed = typeof response === "string" ? safeParse(response) : response;
|
||||
return true; // Adjust this logic based on your "next" field.
|
||||
}
|
||||
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}
|
||||
const data = safeParse(response);
|
||||
return (
|
||||
<LLMResponse
|
||||
data={data}
|
||||
onConfirm={onConfirm}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null; // Fallback for unsupported actors.
|
||||
})}
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="pt-2 flex justify-center">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
onHeightChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,59 +1,65 @@
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
|
||||
export default function ConfirmInline({ data, confirmed, onConfirm }) {
|
||||
const { args, tool } = data || {};
|
||||
const ConfirmInline = memo(({ data, confirmed, onConfirm }) => {
|
||||
const { args, tool } = data || {};
|
||||
|
||||
if (confirmed) {
|
||||
// Once confirmed, show "Running..." state in the same container
|
||||
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="text-sm text-gray-600 dark:text-gray-300">
|
||||
<div>
|
||||
<strong>Tool:</strong> {tool ?? "Unknown"}
|
||||
</div>
|
||||
{args && (
|
||||
const renderArgs = () => {
|
||||
if (!args) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<strong>Args:</strong>
|
||||
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
<strong>Args:</strong>
|
||||
<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)}
|
||||
</pre>
|
||||
</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
|
||||
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="text-gray-600 dark:text-gray-300">
|
||||
<div>
|
||||
Agent is ready to run the tool: <strong>{tool ?? "Unknown"}</strong>
|
||||
if (confirmed) {
|
||||
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-sm text-gray-600 dark:text-gray-300">
|
||||
<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>
|
||||
{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">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ConfirmInline.displayName = 'ConfirmInline';
|
||||
|
||||
export default ConfirmInline;
|
||||
|
||||
@@ -1,44 +1,60 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { memo, useEffect } from "react";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import ConfirmInline from "./ConfirmInline";
|
||||
|
||||
export default function LLMResponse({ data, onConfirm, isLastMessage }) {
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) => {
|
||||
const [isConfirmed, setIsConfirmed] = React.useState(false);
|
||||
const responseRef = React.useRef(null);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (onConfirm) await onConfirm();
|
||||
setIsConfirmed(true);
|
||||
};
|
||||
// Notify parent of height changes when confirm UI appears/changes
|
||||
useEffect(() => {
|
||||
if (isLastMessage && responseRef.current && onHeightChange) {
|
||||
onHeightChange();
|
||||
}
|
||||
}, [isLastMessage, isConfirmed, onHeightChange]);
|
||||
|
||||
// Only requires confirm if data.next === "confirm" AND it's the last message
|
||||
const requiresConfirm = data.next === "confirm" && isLastMessage;
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
if (onConfirm) await onConfirm();
|
||||
setIsConfirmed(true);
|
||||
} catch (error) {
|
||||
console.error('Error confirming action:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof data.response === "object") {
|
||||
data.response = data.response.response;
|
||||
}
|
||||
const response = typeof data?.response === 'object'
|
||||
? data.response.response
|
||||
: data?.response;
|
||||
|
||||
let displayText = (data.response || "").trim();
|
||||
if (!displayText && requiresConfirm) {
|
||||
displayText = `Agent is ready to run "${data.tool}". Please confirm.`;
|
||||
}
|
||||
const displayText = (response || '').trim();
|
||||
const requiresConfirm = data.next === "confirm" && isLastMessage;
|
||||
const defaultText = requiresConfirm
|
||||
? `Agent is ready to run "${data.tool}". Please confirm.`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<MessageBubble message={{ response: displayText }} />
|
||||
{requiresConfirm && (
|
||||
<ConfirmInline
|
||||
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>
|
||||
Agent chose tool: <strong>{data.tool ?? "Unknown"}</strong>
|
||||
</div>
|
||||
return (
|
||||
<div ref={responseRef} className="space-y-2">
|
||||
<MessageBubble
|
||||
message={{ response: displayText || defaultText }}
|
||||
/>
|
||||
{requiresConfirm && (
|
||||
<ConfirmInline
|
||||
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>
|
||||
Agent chose tool: <strong>{data.tool ?? "Unknown"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
LLMResponse.displayName = 'LLMResponse';
|
||||
|
||||
export default LLMResponse;
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
|
||||
export default function LoadingIndicator() {
|
||||
return (
|
||||
<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"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping delay-100"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping delay-200"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const LoadingIndicator = memo(() => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center space-x-2 pb-4"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
{[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;
|
||||
|
||||
@@ -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 (
|
||||
<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) => {
|
||||
if (urlRegex.test(part)) {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-block px-4 py-2 mb-1 rounded-lg
|
||||
${isUser
|
||||
? "ml-auto bg-blue-100 dark:bg-blue-900 dark:text-white"
|
||||
: "mr-auto bg-gray-200 dark:bg-gray-700 dark:text-white"
|
||||
}
|
||||
break-words max-w-[75%] transition-colors duration-200
|
||||
`}
|
||||
role="article"
|
||||
aria-label={`${isUser ? 'User' : 'Agent'} message`}
|
||||
>
|
||||
{renderTextWithLinks(displayText)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block px-4 py-2 mb-1 rounded-lg ${
|
||||
isUser ? "ml-auto bg-blue-100" : "mr-auto bg-gray-200"
|
||||
} break-words`}
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
maxWidth: "75%", // or '80%'
|
||||
}}
|
||||
>
|
||||
{renderTextWithLinks(displayText)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
MessageBubble.displayName = 'MessageBubble';
|
||||
|
||||
export default MessageBubble;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
|
||||
export default function NavBar({ title }) {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70
|
||||
backdrop-blur-md shadow-md z-10 flex justify-center">
|
||||
<h1 className="text-xl font-bold font-poppins">{title}</h1>
|
||||
{/* ...any additional nav items... */}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
const NavBar = memo(({ title }) => {
|
||||
return (
|
||||
<header
|
||||
className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70
|
||||
backdrop-blur-md shadow-md z-10 flex justify-center items-center
|
||||
transition-colors duration-200"
|
||||
role="banner"
|
||||
>
|
||||
<h1 className="text-xl font-bold font-poppins">
|
||||
{title}
|
||||
</h1>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
||||
NavBar.displayName = 'NavBar';
|
||||
|
||||
export default NavBar;
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex flex-col h-screen">
|
||||
<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="w-full max-w-lg bg-white dark:bg-gray-900 p-8 px-3 rounded shadow-md
|
||||
flex flex-col overflow-hidden">
|
||||
{/* Scrollable chat area */}
|
||||
<div ref={containerRef} className="flex-grow overflow-y-auto pb-20 pt-10">
|
||||
flex flex-col overflow-hidden">
|
||||
<div ref={containerRef}
|
||||
className="flex-grow overflow-y-auto pb-20 pt-10 scroll-smooth">
|
||||
<ChatWindow
|
||||
conversation={conversation}
|
||||
loading={loading}
|
||||
onConfirm={handleConfirm}
|
||||
onContentChange={handleContentChange}
|
||||
/>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
@@ -126,41 +210,49 @@ export default function App() {
|
||||
</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
|
||||
border-t border-gray-300 dark:border-gray-700"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
border-t border-gray-300 dark:border-gray-700 shadow-lg
|
||||
transition-all duration-200"
|
||||
style={{ zIndex: 10 }}>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}} className="flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={`flex-grow rounded-l px-3 py-2 border border-gray-300
|
||||
dark:bg-gray-700 dark:border-gray-600 focus:outline-none
|
||||
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
dark:bg-gray-700 dark:border-gray-600 focus:outline-none
|
||||
transition-opacity duration-200
|
||||
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
placeholder="Type your message..."
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={loading || done}
|
||||
aria-label="Type your message"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
type="submit"
|
||||
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}
|
||||
aria-label="Send message"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="text-right mt-3">
|
||||
<button
|
||||
onClick={handleStartNewChat}
|
||||
className={`text-sm underline text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200
|
||||
${!done ? "opacity-0 cursor-not-allowed" : ""}`}
|
||||
className={`text-sm underline text-gray-600 dark:text-gray-400
|
||||
hover:text-gray-800 dark:hover:text-gray-200
|
||||
transition-all duration-200
|
||||
${!done ? "opacity-0 cursor-not-allowed" : ""}`}
|
||||
disabled={!done}
|
||||
aria-label="Start new chat"
|
||||
>
|
||||
Start New Chat
|
||||
</button>
|
||||
|
||||
75
frontend/src/services/api.js
Normal file
75
frontend/src/services/api.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user