import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { api } from '../api'; import { Globe, Hand, LoaderCircle, PanelRightClose, Play, RefreshCw } from './Icons'; import type { BrowserViewState, Task } from '../types'; import { stopReasonText } from '../taskView'; type Replay = { taskId: string | null; status: 'loading' | 'ready' | 'empty' | 'failed'; src?: string; message?: string; lastModified?: string }; function imageDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('截图无法读取')); reader.onerror = () => reject(new Error('截图无法读取')); reader.readAsDataURL(blob); }); } export function BrowserPanel({ task, state, embedded, onClose, onTakeover, onTakeoverDone }: { task: Task | null; state: BrowserViewState | null; embedded: boolean; onClose: () => void; onTakeover: () => void; onTakeoverDone: () => void; }) { const slot = useRef(null); const taskId = task?.id ?? null; const hasLiveSession = Boolean(taskId && task?.live_session); const [replay, setReplay] = useState({ taskId: null, status: 'empty' }); const [reload, setReload] = useState(0); // Only a live session owns a native view. Ending or losing it releases the // viewport immediately; a saved screenshot is an ordinary read-only image. useLayoutEffect(() => { const element = slot.current; if (!embedded || !element || !taskId || !hasLiveSession) { void api.browser.viewport({ taskId: taskId ?? undefined, visible: false }); return; } let frame = 0; let previous = ''; const measure = (): void => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const rect = element.getBoundingClientRect(); const x = Math.max(0, Math.round(rect.left)); const y = Math.max(0, Math.round(rect.top)); const width = Math.max(0, Math.round(Math.min(window.innerWidth, rect.right)) - x); const height = Math.max(0, Math.round(Math.min(window.innerHeight, rect.bottom)) - y); const next = width > 0 && height > 0 ? { taskId, visible: true, bounds: { x, y, width, height } } : { taskId, visible: false }; const key = JSON.stringify(next); if (key !== previous) { previous = key; void api.browser.viewport(next); } }); }; const observer = new ResizeObserver(measure); observer.observe(element); window.addEventListener('resize', measure); measure(); return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener('resize', measure); void api.browser.viewport({ taskId, visible: false }); }; }, [embedded, taskId, hasLiveSession]); useEffect(() => { if (hasLiveSession || !taskId) return; const controller = new AbortController(); let current = true; setReplay({ taskId, status: 'loading' }); async function load(): Promise { try { const meta = await api.appMeta(); if (!Number.isInteger(meta.apiPort) || meta.apiPort < 1 || meta.apiPort > 65535) throw new Error('本地截图服务端口不可用'); const url = `http://127.0.0.1:${meta.apiPort}/api/tasks/${encodeURIComponent(taskId!)}/screenshot`; const response = await fetch(url, { signal: controller.signal, cache: 'no-store' }); if (response.status === 404) { if (current) setReplay({ taskId, status: 'empty', message: '这个任务尚未保存页面截图。' }); return; } if (!response.ok) throw new Error(`截图读取失败(HTTP ${response.status})`); if (!response.headers.get('content-type')?.toLowerCase().startsWith('image/png')) throw new Error('截图服务未返回有效图片'); const blob = await response.blob(); if (!blob.size) throw new Error('保存的截图为空'); // The renderer CSP allows data images. Fetch from the local API rather // than loading a filesystem URL or exposing a native view without a session. const src = await imageDataUrl(blob); const modifiedHeader = response.headers.get('last-modified'); const modified = modifiedHeader ? new Date(modifiedHeader) : null; const lastModified = modified && Number.isFinite(modified.valueOf()) ? modified.toISOString() : undefined; if (current) setReplay({ taskId, status: 'ready', src, lastModified }); } catch (error) { if (current && !controller.signal.aborted) setReplay({ taskId, status: 'failed', message: error instanceof Error ? error.message : '截图暂时无法读取' }); } } void load(); return () => { current = false; controller.abort(); }; }, [hasLiveSession, taskId, task?.latest_screenshot, task?.updated_at, reload]); const title = hasLiveSession ? state?.title || '来源浏览器' : '最近保存截图 · 只读回放'; const userMode = hasLiveSession && (state?.mode === 'user' || Boolean(task?.takeover)); const canTakeover = Boolean(hasLiveSession && task && ['running', 'paused'].includes(task.status) && !userMode); const finished = Boolean(task && ['completed', 'failed', 'cancelled', 'rejected'].includes(task.status)); const replayHeading = finished ? '任务已结束,显示最近保存截图' : task?.status === 'running' ? '当前没有活动浏览器,显示最近保存截图' : '采集已暂停,显示最近保存截图'; const endReason = (task ? stopReasonText(task) : '') || task?.pause_reason || task?.artifacts.summary || (finished ? '本轮已结束。下方保存的是最后一帧页面,仅供回看。' : '当前浏览器会话已关闭;可以回看已保存页面和任务执行记录。'); const snapshot = replay.taskId === taskId ? replay : { taskId, status: 'loading' as const }; return (
{hasLiveSession && state?.loading ? : } {title} {userMode && 你在操作}

{hasLiveSession ? state?.url || (embedded ? '页面准备中' : '独立 Chrome 窗口停靠在应用旁边') : '已保存的页面截图,不是当前可操作网页'}

{hasLiveSession ? userMode ? ( ) : ( ) : }
{hasLiveSession ?

{embedded ? '页面将在此处显示' : '当前为独立窗口模式,请查看应用旁边的 Chrome 窗口'}

:
{replayHeading}

{endReason}

{snapshot.status === 'ready' &&

{snapshot.lastModified ? <>截图文件保存时间:(北京时间) : '截图保存时间未提供。'}
截图可能早于最近一次采集,仅供回看。

}
{snapshot.status === 'ready' && snapshot.src ? 任务最后保存的页面截图,只读回放 setReplay({ taskId, status: 'failed', message: '保存的页面截图无法显示。' })} /> :
{snapshot.status === 'loading' ? <>

正在读取最后页面截图…

: <>{snapshot.status === 'empty' ? '暂无页面截图' : '截图暂不可用'}

{snapshot.message || '没有可显示的最后页面截图。'}

结束原因和实际执行范围仍可在任务记录中查看。

}
}
}
); }