mirror of https://github.com/kortix-ai/suna.git
241 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|