suna/frontend/src/components/thread/tool-views/BrowserToolView.tsx

241 lines
8.5 KiB
TypeScript

import React, { useMemo } from 'react';
import {
Globe,
MonitorPlay,
ExternalLink,
CheckCircle,
AlertTriangle,
CircleDashed,
} from 'lucide-react';
import { ToolViewProps } from './types';
import {
extractBrowserUrl,
extractBrowserOperation,
formatTimestamp,
getToolTitle,
} from './utils';
import { ApiMessageType } from '@/components/thread/types';
import { safeJsonParse } from '@/components/thread/utils';
import { cn } from '@/lib/utils';
export function BrowserToolView({
name = 'browser-operation',
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
project,
agentStatus = 'idle',
messages = [],
currentIndex = 0,
totalCalls = 1,
}: ToolViewProps) {
const url = extractBrowserUrl(assistantContent);
const operation = extractBrowserOperation(name);
const toolTitle = getToolTitle(name);
// --- message_id Extraction Logic ---
let browserStateMessageId: string | undefined;
try {
// 1. Parse the top-level JSON
const topLevelParsed = safeJsonParse<{ content?: string }>(toolContent, {});
const innerContentString = topLevelParsed?.content;
if (innerContentString && typeof innerContentString === 'string') {
// 2. Extract the output='...' string using regex
const outputMatch = innerContentString.match(/\boutput='(.*?)'(?=\s*\))/);
const outputString = outputMatch ? outputMatch[1] : null;
if (outputString) {
// 3. Unescape the JSON string (basic unescaping for \n and \")
const unescapedOutput = outputString
.replace(/\\n/g, '\n')
.replace(/\\"/g, '"');
// 4. Parse the unescaped JSON to get message_id
const finalParsedOutput = safeJsonParse<{ message_id?: string }>(
unescapedOutput,
{},
);
browserStateMessageId = finalParsedOutput?.message_id;
}
}
} catch (error) {
console.error(
'[BrowserToolView] Error parsing tool content for message_id:',
error,
);
}
// Find the browser_state message and extract the screenshot
let screenshotBase64: string | null = null;
let latestBrowserState: any = null;
let latestTimestamp = 0;
if (messages.length > 0) {
// Find the latest browser_state message by comparing timestamps
messages.forEach((msg) => {
if ((msg.type as string) === 'browser_state') {
try {
const content = safeJsonParse<{ timestamp?: number }>(msg.content, {});
const timestamp = content?.timestamp || 0;
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestBrowserState = content;
}
} catch (error) {
console.error('[BrowserToolView] Error parsing browser state:', error);
}
}
});
// Use the latest browser state
if (latestBrowserState) {
screenshotBase64 = latestBrowserState.screenshot_base64 || null;
console.log('Latest browser state:', latestBrowserState);
}
}
// Check if we have a VNC preview URL from the project
const vncPreviewUrl = project?.sandbox?.vnc_preview
? `${project.sandbox.vnc_preview}/vnc_lite.html?password=${project?.sandbox?.pass}&autoconnect=true&scale=local&width=1024&height=768`
: undefined;
const isRunning = isStreaming || agentStatus === 'running';
const isLastToolCall = currentIndex === totalCalls - 1;
// Memoize the VNC iframe to prevent reconnections on re-renders
const vncIframe = useMemo(() => {
if (!vncPreviewUrl) return null;
console.log(
'[BrowserToolView] Creating memoized VNC iframe with URL:',
vncPreviewUrl,
);
return (
<iframe
src={vncPreviewUrl}
title="Browser preview"
className="w-full h-full border-0 flex-1"
/>
);
}, [vncPreviewUrl]); // Only recreate if the URL changes
return (
<div className="flex flex-col h-full">
<div className="flex-1 p-4 overflow-auto">
<div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col">
<div className="bg-zinc-100 dark:bg-zinc-900 p-2 flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800">
<div className="flex items-center">
<MonitorPlay className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" />
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
Browser Window
</span>
</div>
{url && (
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-400 truncate max-w-[340px]">
{url}
</div>
)}
</div>
{/* Preview Logic */}
<div className="flex-1 flex items-stretch bg-black">
{isLastToolCall ? (
// Only show live sandbox or fallback to sandbox for the last tool call
isRunning && vncIframe ? (
// Use the memoized iframe for live preview
vncIframe
) : screenshotBase64 ? (
<div className="flex items-center justify-center w-full h-full max-h-[650px] overflow-auto">
<img
src={`data:image/jpeg;base64,${screenshotBase64}`}
alt="Browser Screenshot"
className="max-w-full max-h-full object-contain"
/>
</div>
) : vncIframe ? (
// Use the memoized iframe
vncIframe
) : (
<div className="p-8 flex flex-col items-center justify-center w-full bg-zinc-50 dark:bg-zinc-900 text-zinc-700 dark:text-zinc-400">
<MonitorPlay className="h-12 w-12 mb-3 opacity-40" />
<p className="text-sm font-medium">
Browser preview not available
</p>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mt-3 flex items-center text-blue-600 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 hover:underline"
>
Visit URL <ExternalLink className="h-3 w-3 ml-1" />
</a>
)}
</div>
)
) : // For non-last tool calls, only show screenshot if available, otherwise show "No Browser State image found"
screenshotBase64 ? (
<div className="flex items-center justify-center w-full h-full max-h-[650px] overflow-auto">
<img
src={`data:image/jpeg;base64,${screenshotBase64}`}
alt="Browser Screenshot"
className="max-w-full max-h-full object-contain"
/>
</div>
) : (
<div className="p-8 flex flex-col items-center justify-center w-full bg-zinc-50 dark:bg-zinc-900 text-zinc-700 dark:text-zinc-400">
<MonitorPlay className="h-12 w-12 mb-3 opacity-40" />
<p className="text-sm font-medium">
No Browser State image found
</p>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
{!isRunning && (
<div className="flex items-center gap-2">
{isSuccess ? (
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
) : (
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
)}
<span>
{isSuccess
? `${operation} completed successfully`
: `${operation} failed`}
</span>
</div>
)}
{isRunning && (
<div className="flex items-center gap-2">
<CircleDashed className="h-3.5 w-3.5 text-blue-500 animate-spin" />
<span>Executing browser action...</span>
</div>
)}
<div className="text-xs">
{toolTimestamp && !isRunning
? formatTimestamp(toolTimestamp)
: assistantTimestamp
? formatTimestamp(assistantTimestamp)
: ''}
</div>
</div>
</div>
</div>
);
}