basic react API

This commit is contained in:
Steve Androulakis
2025-01-02 18:05:28 -08:00
parent 9b139ee479
commit 93ec96a406
21 changed files with 3498 additions and 24 deletions

53
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Logs
logs
*.log
logs/*.log
# OS-specific files
.DS_Store
# Build output
dist/
build/
# Dependency directories
jspm_packages/
# Testing
coverage/
# Next.js
.next/
# Vite
.vite/
# Parcel
.cache/
# Environment files
.env
.env.*.local
# Editor files
.idea/
.vscode/
*.swp
# Temporary files
*.tmp
*.temp
*.bak
*.orig
# Lock files
*.lock
# Others
public/**/*.cache

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Temporal AI Agent</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" src="/src/main.jsx"></script>
</head>
<body class="bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100">
<div id="root"></div>
</body>
</html>

2926
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "temporal-ai-agent-frontend",
"version": "1.0.0",
"description": "React and Tailwind",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.0.7"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,66 @@
import React 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 {};
}
}
export default function ChatWindow({ conversation, loading, onConfirm }) {
if (!Array.isArray(conversation)) {
console.error("ChatWindow expected conversation to be an array, got:", conversation);
return null;
}
const filtered = conversation.filter((msg) => {
const { actor, response } = msg;
if (actor === "user") {
return true;
}
if (actor === "response") {
const parsed = typeof response === "string" ? safeParse(response) : response;
// Keep if next is "question", "confirm", or "confirmed".
// Only skip if next is "done" (or something else).
return !["done"].includes(parsed.next);
}
return false;
});
return (
<div className="flex-grow overflow-y-auto space-y-4">
{filtered.map((msg, idx) => {
const { actor, response } = msg;
if (actor === "user") {
return (
<MessageBubble key={idx} message={{ response }} isUser />
);
} else if (actor === "response") {
const data =
typeof response === "string" ? safeParse(response) : response;
return <LLMResponse key={idx} data={data} onConfirm={onConfirm} />;
}
return null;
})}
{/* If loading = true, show the spinner at the bottom */}
{loading && (
<div className="flex justify-center">
<LoadingIndicator />
</div>
)}
{conversation.length > 0 && conversation[conversation.length - 1].actor === "user" && (
<div className="flex justify-center">
<LoadingIndicator />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import LoadingIndicator from "./LoadingIndicator";
export default function ConfirmInline({ data, confirmed, onConfirm }) {
const { args, tool } = data || {};
console.log("ConfirmInline rendered with confirmed:", confirmed);
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 && (
<div className="mt-1">
<strong>Args:</strong>
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-xs whitespace-pre-wrap">
{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-sm text-gray-600 dark:text-gray-300">
<div>
<strong>Tool:</strong> {tool ?? "Unknown"}
</div>
{args && (
<div className="mt-1">
<strong>Args:</strong>
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-xs whitespace-pre-wrap">
{JSON.stringify(args, null, 2)}
</pre>
</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

@@ -0,0 +1,34 @@
import React, { useState } from "react";
import MessageBubble from "./MessageBubble";
import ConfirmInline from "./ConfirmInline";
export default function LLMResponse({ data, onConfirm }) {
const [isConfirmed, setIsConfirmed] = useState(false);
const handleConfirm = async () => {
if (onConfirm) {
await onConfirm();
}
setIsConfirmed(true); // Update state after confirmation
};
const requiresConfirm = data.next === "confirm";
let displayText = (data.response || "").trim();
if (!displayText && requiresConfirm) {
displayText = `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}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
import React from "react";
export default function LoadingIndicator() {
return (
<div className="flex items-center justify-center space-x-2">
<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>
);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
export default function MessageBubble({ message, fallback = "", isUser = false }) {
// Use isUser directly instead of message.user
const bubbleStyle = isUser
? "bg-blue-600 text-white self-end"
: "bg-gray-300 text-gray-900 dark:bg-gray-600 dark:text-gray-100";
// If message.response is empty or whitespace, use fallback text
const displayText = message.response?.trim() ? message.response : fallback;
// Skip display entirely if text starts with ###
if (displayText.startsWith("###")) {
return null;
}
return (
<div
className={`max-w-xs md:max-w-sm px-4 py-2 mb-1 rounded-lg ${
isUser ? "ml-auto" : "mr-auto"
} ${bubbleStyle}`}
style={{ whiteSpace: "pre-wrap" }}
>
{displayText}
</div>
);
}

View File

@@ -0,0 +1,9 @@
import React from "react";
export default function NavBar({ title }) {
return (
<div className="bg-gray-200 dark:bg-gray-700 p-4 shadow-sm">
<h1 className="text-xl font-bold">{title}</h1>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
export default function useLocalChatHistory(key, initialValue) {
const [state, setState] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch (err) {
console.error("Error parsing localStorage:", err);
return initialValue;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}

3
frontend/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

9
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./pages/App";
import "./index.css"; // Tailwind imports
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);

114
frontend/src/pages/App.jsx Normal file
View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from "react";
import NavBar from "../components/NavBar";
import ChatWindow from "../components/ChatWindow";
const POLL_INTERVAL = 500; // 0.5 seconds
export default function App() {
const [conversation, setConversation] = useState([]);
const [userInput, setUserInput] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
// Poll /get-conversation-history once per second
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();
// data is now an object like { messages: [ ... ] }
if (data.messages && data.messages.some(msg => msg.actor === "response" || msg.actor === "tool_result")) {
setLoading(false);
}
setConversation(data.messages || []);
}
} catch (err) {
console.error("Error fetching conversation history:", err);
}
}, POLL_INTERVAL);
return () => clearInterval(intervalId);
}, []);
const handleSendMessage = async () => {
if (!userInput.trim()) return;
try {
setLoading(true); // <--- Mark as loading
await fetch(
`http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent(userInput)}`,
{ method: "POST" }
);
setUserInput("");
} catch (err) {
console.error("Error sending prompt:", err);
setLoading(false);
}
};
const handleConfirm = async () => {
try {
setLoading(true);
await fetch("http://127.0.0.1:8000/confirm", { method: "POST" });
} catch (err) {
console.error("Confirm error:", err);
setLoading(false);
}
};
const handleStartNewChat = async () => {
try {
await fetch("http://127.0.0.1:8000/end-chat", { method: "POST" });
// sleep for a bit to allow the server to process the end-chat request
await new Promise((resolve) => setTimeout(resolve, 4000)); // todo make less dodgy
await fetch(
`http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent("I'd like to travel to an event.")}`,
{ method: "POST" }
);
setConversation([]); // clear local state
} catch (err) {
console.error("Error ending chat:", err);
}
};
return (
<div className="flex flex-col min-h-screen">
<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">
{/* Pass down the array of messages to ChatWindow */}
<ChatWindow
conversation={conversation}
loading={loading}
onConfirm={handleConfirm}
/>
<div className="flex items-center mt-4">
<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"
placeholder="Type your message..."
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
/>
<button
onClick={handleSendMessage}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-r"
>
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"
>
Start New Chat
</button>
</div>
</div>
</div>
</div>
);
}

View File

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

9
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
open: true,
},
});