mucho UI fixes

This commit is contained in:
Steve Androulakis
2025-01-03 23:19:48 -08:00
parent f12c6ac471
commit 1fa11495e9
10 changed files with 185 additions and 101 deletions

View File

@@ -48,10 +48,8 @@ TODO: Document /frontend react app running instructions.
- Note the mapping in `tools/__init__.py` to each tool - Note the mapping in `tools/__init__.py` to each tool
## TODO ## TODO
- The LLM prompts move through 3 mock tools (FindEvents, SearchFlights, CreateInvoice) but I should make them contact real APIs. - Code GenerateInvoice against the Stripe API
- I should prove this out with other tool definitions (take advantage of my nice DSL). - I should prove this out with other tool definitions outside of the event/flight search case (take advantage of my nice DSL).
- Might need to abstract the json example in the prompt generator to be part of a ToolDefinition (prevent overfitting to the example).
- Currently hardcoded to the Temporal dev server at localhost:7233. Need to support options incl Temporal Cloud. - Currently hardcoded to the Temporal dev server at localhost:7233. Need to support options incl Temporal Cloud.
- UI: Make prettier - UI: Make prettier
- UI: Tool Confirmed state could be better represented - UI: Tool Confirmed state could be better represented
- UI: 'Start new chat' button needs to handle better

View File

@@ -19,46 +19,50 @@ export default function ChatWindow({ conversation, loading, onConfirm }) {
} }
const filtered = conversation.filter((msg) => { const filtered = conversation.filter((msg) => {
const { actor, response } = msg; const { actor, response } = msg;
if (actor === "user") { if (actor === "user") {
return true; return true;
} }
if (actor === "agent") { if (actor === "agent") {
const parsed = typeof response === "string" ? safeParse(response) : response; const parsed = typeof response === "string" ? safeParse(response) : response;
// Keep if next is "question", "confirm", or "user_confirmed_tool_run". return true; // Adjust this logic based on your "next" field.
// Only skip if next is "done" (or something else).
// return !["done"].includes(parsed.next);
return true;
} }
return false; return false;
}); });
return ( return (
<div className="flex-grow overflow-y-auto space-y-4"> <div className="flex-grow flex flex-col">
{filtered.map((msg, idx) => { {/* Main message container */}
<div className="flex-grow flex flex-col justify-end overflow-y-auto space-y-3">
const { actor, response } = msg; {filtered.map((msg, idx) => {
const { actor, response } = msg;
if (actor === "user") {
return ( if (actor === "user") {
<MessageBubble key={idx} message={{ response }} isUser /> return <MessageBubble key={idx} message={{ response }} isUser />;
); } else if (actor === "agent") {
} else if (actor === "agent") { const data =
const data = typeof response === "string" ? safeParse(response) : response;
typeof response === "string" ? safeParse(response) : response; const isLastMessage = idx === filtered.length - 1;
return <LLMResponse key={idx} data={data} onConfirm={onConfirm} />; return (
} <LLMResponse
return null; key={idx}
})} data={data}
onConfirm={onConfirm}
{/* If loading = true, show the spinner at the bottom */} isLastMessage={isLastMessage}
{loading && ( />
<div className="flex justify-center"> );
<LoadingIndicator /> }
</div> return null; // Fallback for unsupported actors.
)} })}
{/* Loading indicator */}
{loading && (
<div className="pt-2 flex justify-center">
<LoadingIndicator />
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -15,7 +15,7 @@ export default function ConfirmInline({ data, confirmed, onConfirm }) {
{args && ( {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-xs whitespace-pre-wrap"> <pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap">
{JSON.stringify(args, null, 2)} {JSON.stringify(args, null, 2)}
</pre> </pre>
</div> </div>
@@ -31,14 +31,14 @@ export default function ConfirmInline({ data, confirmed, onConfirm }) {
// Not confirmed yet → show confirmation UI // Not confirmed yet → show confirmation UI
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 bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-600 dark:text-gray-300"> <div className="text-gray-600 dark:text-gray-300">
<div> <div>
<strong>Tool:</strong> {tool ?? "Unknown"} <strong>Tool:</strong> {tool ?? "Unknown"}
</div> </div>
{args && ( {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-xs whitespace-pre-wrap"> <pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap">
{JSON.stringify(args, null, 2)} {JSON.stringify(args, null, 2)}
</pre> </pre>
</div> </div>

View File

@@ -2,17 +2,16 @@ import React, { useState } from "react";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import ConfirmInline from "./ConfirmInline"; import ConfirmInline from "./ConfirmInline";
export default function LLMResponse({ data, onConfirm }) { export default function LLMResponse({ data, onConfirm, isLastMessage }) {
const [isConfirmed, setIsConfirmed] = useState(false); const [isConfirmed, setIsConfirmed] = useState(false);
const handleConfirm = async () => { const handleConfirm = async () => {
if (onConfirm) { if (onConfirm) await onConfirm();
await onConfirm(); setIsConfirmed(true);
}
setIsConfirmed(true); // Update state after confirmation
}; };
const requiresConfirm = data.next === "confirm"; // Only requires confirm if data.next === "confirm" AND it's the last message
const requiresConfirm = data.next === "confirm" && isLastMessage;
if (typeof data.response === "object") { if (typeof data.response === "object") {
data.response = data.response.response; data.response = data.response.response;

View File

@@ -16,11 +16,14 @@ export default function MessageBubble({ message, fallback = "", isUser = false }
return ( return (
<div <div
className={`max-w-xs md:max-w-sm px-4 py-2 mb-1 rounded-lg ${ className={`inline-block px-4 py-2 mb-1 rounded-lg ${
isUser ? "ml-auto" : "mr-auto" isUser ? "ml-auto bg-blue-100" : "mr-auto bg-gray-200"
} ${bubbleStyle}`} } break-words`}
style={{ whiteSpace: "pre-wrap" }} style={{
> whiteSpace: "pre-wrap",
maxWidth: "75%", // or '80%'
}}
>
{displayText} {displayText}
</div> </div>
); );

View File

@@ -2,8 +2,10 @@ import React from "react";
export default function NavBar({ title }) { export default function NavBar({ title }) {
return ( return (
<div className="bg-gray-200 dark:bg-gray-700 p-4 shadow-sm"> <header className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70
<h1 className="text-xl font-bold">{title}</h1> backdrop-blur-md shadow-md z-10 flex justify-center">
</div> <h1 className="text-xl font-bold font-poppins">{title}</h1>
{/* ...any additional nav items... */}
</header>
); );
} }

View File

@@ -1,3 +1,53 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
*,
html,
body {
box-sizing: border-box;
}
html {
height: 100vh;
width: 100vw;
}
body {
font-family: Inter, sans-serif;
position: relative;
height: 100%;
width: 100%;
}
h1,
h2,
h3,
h4,
h5,
h6,
titles,
labels {
font-family: Poppins, sans-serif;
}
/* example if you want it in index.css or a separate .css */
.corner {
width: 3em;
height: 3em;
}
.corner a {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.corner img {
width: 2em;
height: 2em;
object-fit: contain;
}

View File

@@ -8,23 +8,26 @@ export default function App() {
const [conversation, setConversation] = useState([]); const [conversation, setConversation] = useState([]);
const [userInput, setUserInput] = useState(""); const [userInput, setUserInput] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [done, setDone] = useState(true); // New `done` state
useEffect(() => { useEffect(() => {
// Poll /get-conversation-history once per second // Poll /get-conversation-history every 0.5 seconds
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
const res = await fetch("http://127.0.0.1:8000/get-conversation-history"); const res = await fetch("http://127.0.0.1:8000/get-conversation-history");
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// data is now an object like { messages: [ ... ] } // Update conversation
if (data.messages && data.messages.length > 0 && (data.messages[data.messages.length - 1].actor === "agent")) {
setLoading(false);
}
else {
setLoading(true);
}
setConversation(data.messages || []); setConversation(data.messages || []);
if (data.messages && data.messages.length > 0) {
const lastMessage = data.messages[data.messages.length - 1];
setLoading(lastMessage.actor !== "agent");
setDone(lastMessage.response.next === "done");
} else {
setLoading(false);
setDone(true); // Default to `done` if no messages
}
} }
} catch (err) { } catch (err) {
console.error("Error fetching conversation history:", err); console.error("Error fetching conversation history:", err);
@@ -37,7 +40,7 @@ export default function App() {
const handleSendMessage = async () => { const handleSendMessage = async () => {
if (!userInput.trim()) return; if (!userInput.trim()) return;
try { try {
setLoading(true); // <--- Mark as loading setLoading(true); // Mark as loading
await fetch( await fetch(
`http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent(userInput)}`, `http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent(userInput)}`,
{ method: "POST" } { method: "POST" }
@@ -65,7 +68,7 @@ export default function App() {
`http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent("I'd like to travel for an event.")}`, `http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent("I'd like to travel for an event.")}`,
{ method: "POST" } { method: "POST" }
); );
setConversation([]); // clear local state setConversation([]); // Clear local state
} catch (err) { } catch (err) {
console.error("Error ending chat:", err); console.error("Error ending chat:", err);
} }
@@ -78,44 +81,69 @@ export default function App() {
}; };
return ( return (
<div className="flex flex-col min-h-screen"> <div className="flex flex-col h-screen">
<NavBar title="Temporal AI Agent" /> <NavBar title="Temporal AI Agent" />
<div className="flex-grow flex justify-center px-4 py-6">
<div className="w-full max-w-lg bg-white dark:bg-gray-900 p-4 rounded shadow-md flex flex-col"> {/* Centered content, but no manual bottom margin */}
{/* Pass down the array of messages to ChatWindow */} <div className="flex-grow flex justify-center px-4 py-2 overflow-hidden">
<ChatWindow <div className="w-full max-w-lg bg-white dark:bg-gray-900 p-8 px-3 rounded shadow-md
conversation={conversation} flex flex-col overflow-hidden">
loading={loading} {/* Scrollable chat area */}
onConfirm={handleConfirm} <div className="flex-grow overflow-y-auto pb-20 pt-10">
/> <ChatWindow
conversation={conversation}
<div className="flex items-center mt-4"> loading={loading}
<input onConfirm={handleConfirm}
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"
placeholder="Type your message..."
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyPress={handleKeyPress}
/> />
<button {done && (
onClick={handleSendMessage} <div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-r" Chat ended
> </div>
Send )}
</button> </div>
</div> </div>
<div className="text-right mt-3"> </div>
<button
onClick={handleStartNewChat} {/* Floating Input Section */}
className="text-sm underline text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200" {/* Fixed bottom input */}
> <div
Start New Chat className="fixed bottom-0 left-1/2 transform -translate-x-1/2
</button> w-full max-w-lg bg-white dark:bg-gray-900 p-4
</div> border-t border-gray-300 dark:border-gray-700"
style={{ zIndex: 10 }}
>
<div className="flex items-center">
<input
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" : ""}`}
placeholder="Type your message..."
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading || done}
/>
<button
onClick={handleSendMessage}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-r
${loading || done ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={loading || done}
>
Send
</button>
</div>
<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" : ""}`}
disabled={!done}
>
Start New Chat
</button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [
"./public/index.html", "./index.html",
"./src/**/*.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}",
], ],
darkMode: "class", // enable dark mode by toggling a .dark class darkMode: "class", // enable dark mode by toggling a .dark class

View File

@@ -81,7 +81,7 @@ def generate_genai_prompt(
"1) If any required argument is missing, set next='question' and ask the user.\n" "1) If any required argument is missing, set next='question' and ask the user.\n"
"2) If all required arguments are known, set next='confirm' and specify the tool.\n" "2) If all required arguments are known, set next='confirm' and specify the tool.\n"
" The user will confirm before the tool is run.\n" " The user will confirm before the tool is run.\n"
"3) If no more tools are needed, set next='done' and tool=null.\n" "3) If no more tools are needed (user_confirmed_tool_run has been run for all), set next='done' and tool=null.\n"
"4) response should be short and user-friendly." "4) response should be short and user-friendly."
) )