1a390b2815
Scorecard supply-chain security / Scorecard analysis (push) Failing after 1s
/ test (push) Failing after 0s
secretlint / Run secretlint to diff files (push) Failing after 1s
UI-TARS E2E Test / E2E (macos-13) (push) Has been cancelled
UI-TARS E2E Test / E2E (macos-latest) (push) Has been cancelled
UI-TARS E2E Test / E2E (windows-latest) (push) Has been cancelled
CI Test, Typecheck / Test & Typecheck (push) Has been cancelled
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { motion } from 'framer-motion';
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useWindowSize } from 'usehooks-ts';
|
|
import Image from 'next/image';
|
|
import { useAtom } from 'jotai/react';
|
|
import { contextIdAtom } from '../atoms';
|
|
import posthog from 'posthog-js';
|
|
import XStream from '../utils/xstream';
|
|
|
|
interface ChatFeedProps {
|
|
initialMessage?: string;
|
|
onClose: () => void;
|
|
url?: string;
|
|
}
|
|
|
|
export interface BrowserStep {
|
|
text: string;
|
|
reasoning: string;
|
|
tool: 'GOTO' | 'ACT' | 'EXTRACT' | 'OBSERVE' | 'CLOSE' | 'WAIT' | 'NAVBACK';
|
|
instruction: string;
|
|
stepNumber?: number;
|
|
}
|
|
|
|
interface AgentState {
|
|
sessionId: string | null;
|
|
sessionUrl: string | null;
|
|
steps: BrowserStep[];
|
|
isLoading: boolean;
|
|
}
|
|
|
|
export default function ChatFeed({ initialMessage, onClose }: ChatFeedProps) {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const { width } = useWindowSize();
|
|
const isMobile = width ? width < 768 : false;
|
|
const initializationRef = useRef(false);
|
|
const chatContainerRef = useRef<HTMLDivElement>(null);
|
|
const [isAgentFinished, setIsAgentFinished] = useState(false);
|
|
const [contextId, setContextId] = useAtom(contextIdAtom);
|
|
const agentStateRef = useRef<AgentState>({
|
|
sessionId: null,
|
|
sessionUrl: null,
|
|
steps: [],
|
|
isLoading: false,
|
|
});
|
|
|
|
const [uiState, setUiState] = useState<{
|
|
sessionId: string | null;
|
|
sessionUrl: string | null;
|
|
steps: BrowserStep[];
|
|
}>({
|
|
sessionId: null,
|
|
sessionUrl: null,
|
|
steps: [],
|
|
});
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
if (chatContainerRef.current) {
|
|
chatContainerRef.current.scrollTop =
|
|
chatContainerRef.current.scrollHeight;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
uiState.steps.length > 0 &&
|
|
uiState.steps[uiState.steps.length - 1].tool === 'CLOSE'
|
|
) {
|
|
setIsAgentFinished(true);
|
|
fetch('/api/session', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
sessionId: uiState.sessionId,
|
|
}),
|
|
});
|
|
}
|
|
}, [uiState.sessionId, uiState.steps]);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [uiState.steps, scrollToBottom]);
|
|
|
|
useEffect(() => {
|
|
console.log('useEffect called');
|
|
const abortController = new AbortController();
|
|
const initializeSession = async () => {
|
|
if (initializationRef.current) return;
|
|
initializationRef.current = true;
|
|
|
|
if (initialMessage && !agentStateRef.current.sessionId) {
|
|
setIsLoading(true);
|
|
try {
|
|
const sessionResponse = await fetch('/api/session', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
contextId: contextId,
|
|
}),
|
|
});
|
|
const sessionData = await sessionResponse.json();
|
|
|
|
if (!sessionData.success) {
|
|
throw new Error(sessionData.error || 'Failed to create session');
|
|
}
|
|
|
|
setContextId(sessionData.contextId);
|
|
|
|
agentStateRef.current = {
|
|
...agentStateRef.current,
|
|
sessionId: sessionData.sessionId,
|
|
sessionUrl: sessionData.sessionUrl.replace(
|
|
'https://www.browserbase.com/devtools-fullscreen/inspector.html',
|
|
'https://www.browserbase.com/devtools-internal-compiled/index.html',
|
|
),
|
|
};
|
|
|
|
setUiState({
|
|
sessionId: sessionData.sessionId,
|
|
sessionUrl: sessionData.sessionUrl.replace(
|
|
'https://www.browserbase.com/devtools-fullscreen/inspector.html',
|
|
'https://www.browserbase.com/devtools-internal-compiled/index.html',
|
|
),
|
|
steps: [],
|
|
});
|
|
|
|
const response = await fetch('/api/agent', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
},
|
|
body: JSON.stringify({
|
|
goal: initialMessage,
|
|
sessionId: sessionData.sessionId,
|
|
action: 'START',
|
|
}),
|
|
signal: abortController.signal,
|
|
});
|
|
console.log('response.body', response.body);
|
|
|
|
for await (const chunk of XStream({
|
|
readableStream: response.body!,
|
|
})) {
|
|
console.log('Received chunk:', chunk);
|
|
const data = JSON.parse(chunk.data) || {};
|
|
console.log('datadatadatadatadata', data);
|
|
|
|
if (data.success && data.result) {
|
|
const nextStepData = {
|
|
text: data.result.text,
|
|
reasoning: data.result.reasoning,
|
|
tool: data.result.tool,
|
|
instruction: data.result.instruction,
|
|
stepNumber: agentStateRef.current.steps.length + 1,
|
|
done: data.done,
|
|
};
|
|
|
|
agentStateRef.current = {
|
|
...agentStateRef.current,
|
|
steps: [...agentStateRef.current.steps, nextStepData],
|
|
};
|
|
|
|
setUiState((prev) => ({
|
|
...prev,
|
|
steps: agentStateRef.current.steps,
|
|
}));
|
|
|
|
// Break after adding the CLOSE step to UI
|
|
if (nextStepData.done || nextStepData.tool === 'CLOSE') {
|
|
break;
|
|
}
|
|
}
|
|
if (data?.error) {
|
|
throw new Error(data.error?.stack || data?.error?.error);
|
|
}
|
|
}
|
|
|
|
posthog.capture('agent_start', {
|
|
goal: initialMessage,
|
|
sessionId: sessionData.sessionId,
|
|
contextId: sessionData.contextId,
|
|
});
|
|
} catch (error) {
|
|
console.error('Session initialization error:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
initializeSession();
|
|
return () => {
|
|
abortController.abort();
|
|
};
|
|
}, [initialMessage]);
|
|
|
|
// Spring configuration for smoother animations
|
|
const springConfig = {
|
|
type: 'spring',
|
|
stiffness: 350,
|
|
damping: 30,
|
|
};
|
|
|
|
const containerVariants = {
|
|
hidden: { opacity: 0, scale: 0.95 },
|
|
visible: {
|
|
opacity: 1,
|
|
scale: 1,
|
|
transition: {
|
|
...springConfig,
|
|
staggerChildren: 0.1,
|
|
},
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
scale: 0.95,
|
|
transition: { duration: 0.2 },
|
|
},
|
|
};
|
|
|
|
const messageVariants = {
|
|
hidden: { opacity: 0, y: 20 },
|
|
visible: { opacity: 1, y: 0 },
|
|
exit: { opacity: 0, y: -20 },
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
className="min-h-screen bg-gray-50 flex flex-col"
|
|
variants={containerVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
>
|
|
<motion.nav
|
|
className="flex justify-between items-center px-8 py-4 bg-white border-b border-gray-200 shadow-sm"
|
|
initial={{ y: -20, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Image
|
|
src="/favicon.svg"
|
|
alt="Open Operator"
|
|
className="w-8 h-8"
|
|
width={32}
|
|
height={32}
|
|
/>
|
|
<span className="font-ppneue text-gray-900">Open Operator</span>
|
|
</div>
|
|
<motion.button
|
|
onClick={onClose}
|
|
className="px-4 py-2 hover:bg-gray-100 text-gray-600 hover:text-gray-900 transition-colors rounded-md font-ppsupply flex items-center gap-2"
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
Close
|
|
{!isMobile && (
|
|
<kbd className="px-2 py-1 text-xs bg-gray-100 rounded-md">ESC</kbd>
|
|
)}
|
|
</motion.button>
|
|
</motion.nav>
|
|
<main className="flex-1 flex flex-col items-center p-6">
|
|
<motion.div
|
|
className="w-full max-w-[1280px] bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden"
|
|
initial={{ y: 20, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
transition={{ delay: 0.3 }}
|
|
>
|
|
<div className="w-full h-12 bg-white border-b border-gray-200 flex items-center px-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-red-500" />
|
|
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
|
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
</div>
|
|
</div>
|
|
|
|
{(() => {
|
|
console.log('Session URL:', uiState.sessionUrl);
|
|
return null;
|
|
})()}
|
|
|
|
<div className="flex flex-col md:flex-row">
|
|
{uiState.sessionUrl && !isAgentFinished && (
|
|
<div className="flex-1 p-6 border-b md:border-b-0 md:border-l border-gray-200 order-first md:order-last">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="w-full aspect-video"
|
|
>
|
|
<iframe
|
|
src={uiState.sessionUrl}
|
|
className="w-full h-full"
|
|
sandbox="allow-same-origin allow-scripts allow-forms"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
title="Browser Session"
|
|
/>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
{isAgentFinished && (
|
|
<div className="flex-1 p-6 border-b md:border-b-0 md:border-l border-gray-200 order-first md:order-last">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="w-full aspect-video"
|
|
>
|
|
<div className="w-full h-full border border-gray-200 rounded-lg flex items-center justify-center">
|
|
<p className="text-gray-500 text-center">
|
|
The agent has completed the task
|
|
<br />
|
|
"{initialMessage}"
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="md:w-[400px] p-6 min-w-0 md:h-[calc(56.25vw-3rem)] md:max-h-[calc(100vh-12rem)]">
|
|
<div
|
|
ref={chatContainerRef}
|
|
className="h-full overflow-y-auto space-y-4"
|
|
>
|
|
{initialMessage && (
|
|
<motion.div
|
|
variants={messageVariants}
|
|
className="p-4 bg-blue-50 rounded-lg font-ppsupply"
|
|
>
|
|
<p className="font-semibold">Goal:</p>
|
|
<p>{initialMessage}</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
{uiState.steps.map((step, index) => (
|
|
<motion.div
|
|
key={index}
|
|
variants={messageVariants}
|
|
className="p-4 bg-white border border-gray-200 rounded-lg font-ppsupply space-y-2"
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-gray-500">
|
|
Step {step.stepNumber}
|
|
</span>
|
|
<span className="px-2 py-1 bg-gray-100 rounded text-xs">
|
|
{step.tool}
|
|
</span>
|
|
</div>
|
|
<p className="font-medium">{step.text}</p>
|
|
<p className="text-sm text-gray-600">
|
|
<span className="font-semibold">Reasoning: </span>
|
|
{step.reasoning}
|
|
</p>
|
|
</motion.div>
|
|
))}
|
|
{isLoading && (
|
|
<motion.div
|
|
variants={messageVariants}
|
|
className="p-4 bg-gray-50 rounded-lg font-ppsupply animate-pulse"
|
|
>
|
|
Processing...
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</main>
|
|
</motion.div>
|
|
);
|
|
}
|