Spaces:
Running
Running
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; | |
if (browserStateMessageId && messages.length > 0) { | |
const browserStateMessage = messages.find(msg => | |
(msg.type as string) === 'browser_state' && | |
msg.message_id === browserStateMessageId | |
); | |
if (browserStateMessage) { | |
const browserStateContent = safeJsonParse<{ screenshot_base64?: string }>(browserStateMessage.content, {}); | |
screenshotBase64 = browserStateContent?.screenshot_base64 || null; | |
} | |
} | |
// 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> | |
); | |
} |