mirror of
https://github.com/temporal-community/temporal-ai-agent.git
synced 2026-03-15 14:08:08 +01:00
basic react API
This commit is contained in:
53
frontend/.gitignore
vendored
Normal file
53
frontend/.gitignore
vendored
Normal 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
12
frontend/index.html
Normal 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
2926
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
66
frontend/src/components/ChatWindow.jsx
Normal file
66
frontend/src/components/ChatWindow.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/ConfirmInline.jsx
Normal file
59
frontend/src/components/ConfirmInline.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/LLMResponse.jsx
Normal file
34
frontend/src/components/LLMResponse.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/LoadingIndicator.jsx
Normal file
11
frontend/src/components/LoadingIndicator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/MessageBubble.jsx
Normal file
27
frontend/src/components/MessageBubble.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/components/NavBar.jsx
Normal file
9
frontend/src/components/NavBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/hooks/useLocalChatHistory.js
Normal file
19
frontend/src/hooks/useLocalChatHistory.js
Normal 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
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
9
frontend/src/main.jsx
Normal file
9
frontend/src/main.jsx
Normal 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
114
frontend/src/pages/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
frontend/tailwind.config.js
Normal file
12
frontend/tailwind.config.js
Normal 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
9
frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user