assistant/renderer/components/BrowserPanel.tsx

141 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> {
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<HTMLDivElement>(null);
const taskId = task?.id ?? null;
const hasLiveSession = Boolean(taskId && task?.live_session);
const [replay, setReplay] = useState<Replay>({ 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<void> {
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 (
<section className="browser-panel" aria-label="来源浏览器" data-testid="browser-panel">
<div className="browser-toolbar">
<div className="browser-heading">
{hasLiveSession && state?.loading ? <LoaderCircle size={16} className="spin" /> : <Globe size={16} />}
<strong title={title}>{title}</strong>
{userMode && <span className="browser-manual" role="status">你在操作</span>}
<button type="button" className="icon-button" aria-label="收起浏览器" title="收起浏览器" onClick={onClose} data-testid="close-browser"><PanelRightClose size={16} /></button>
</div>
<p className="browser-address" title={hasLiveSession ? state?.url ?? '' : ''}>{hasLiveSession ? state?.url || (embedded ? '页面准备中' : '独立 Chrome 窗口停靠在应用旁边') : '已保存的页面截图,不是当前可操作网页'}</p>
<div className="browser-controls">
{hasLiveSession ? userMode ? (
<button type="button" className="primary-button small" onClick={onTakeoverDone} data-testid="browser-takeover-done"><Play size={16} />接管完成,继续</button>
) : (
<button type="button" className="secondary-button small" disabled={!canTakeover} onClick={onTakeover} data-testid="browser-takeover"><Hand size={16} />我来接管</button>
) : <button type="button" className="secondary-button small" disabled={snapshot.status === 'loading'} onClick={() => setReload(value => value + 1)}><RefreshCw size={14} />重新读取截图</button>}
</div>
</div>
{hasLiveSession ? <div className="browser-viewport-frame">
<div ref={slot} className="browser-viewport" data-testid="browser-viewport" aria-label="原生页面区域"><p className="muted">{embedded ? '页面将在此处显示' : '当前为独立窗口模式,请查看应用旁边的 Chrome 窗口'}</p></div>
</div> : <div className="browser-replay" data-testid="browser-replay">
<div className="browser-replay-notice"><strong>{replayHeading}</strong><p data-testid="browser-replay-reason">{endReason}</p>{snapshot.status === 'ready' && <p className="browser-replay-time" data-testid="browser-replay-time">{snapshot.lastModified ? <>截图文件保存时间:<time dateTime={snapshot.lastModified}>{new Date(snapshot.lastModified).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}</time>(北京时间)</> : '截图保存时间未提供。'}<br />截图可能早于最近一次采集,仅供回看。</p>}</div>
{snapshot.status === 'ready' && snapshot.src ? <img className="browser-replay-image" data-testid="browser-replay-image" src={snapshot.src} alt="任务最后保存的页面截图,只读回放" onError={() => setReplay({ taskId, status: 'failed', message: '保存的页面截图无法显示。' })} /> : <div className="browser-replay-empty" role="status">{snapshot.status === 'loading' ? <><LoaderCircle size={22} className="spin" /><p>正在读取最后页面截图…</p></> : <><Globe size={24} /><strong>{snapshot.status === 'empty' ? '暂无页面截图' : '截图暂不可用'}</strong><p>{snapshot.message || '没有可显示的最后页面截图。'}</p><p>结束原因和实际执行范围仍可在任务记录中查看。</p></>}</div>}
</div>}
</section>
);
}