Files
makelore/src/pages/Chat/AgentBrowserPanel.tsx
T

677 lines
26 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from 'react';
import { createPortal } from 'react-dom';
import {
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronUp,
Globe2,
RefreshCw,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
closeAgentBrowser,
deriveAgentBrowserDiagnostics,
getAgentBrowserState,
navigateAgentBrowser,
openAgentBrowser,
presentAgentBrowser,
readAgentBrowserEvents,
setAgentBrowserDiagnostics,
} from '@/lib/agent-browser';
import { subscribeHostEvent } from '@/lib/host-events';
import { cn } from '@/lib/utils';
import type { CodingConversationMetadata } from '@/types/coding-project';
import { useWorkPreview } from './use-work-preview';
import { usePreviewClearance } from './use-preview-clearance';
import { codingRecovery, type CodingRecoveryAction } from '../../../shared/coding-recovery';
import type { WorkPreviewResult } from '../../../shared/coding-work-preview';
import type {
AgentBrowserBounds,
AgentBrowserCdpEvent,
AgentBrowserSnapshot,
} from '../../../shared/agent-browser';
type DiagnosticsTab = 'console' | 'network';
export interface AgentBrowserPanelProps {
projectId: string | null;
open: boolean;
onOpenChange(open: boolean): void;
embedded?: boolean;
toolbarContainer?: HTMLElement | null;
teacherBubble?: HTMLElement | null;
teacherIntroduction?: HTMLElement | null;
conversationId?: string | null;
onWorkConversation?(conversation: CodingConversationMetadata): void;
workRevision?: number;
onWorkResult?(result: WorkPreviewResult): void;
onRecovery?(action: CodingRecoveryAction, work: boolean): Promise<void> | void;
}
const EVENT_METHODS = [
'Runtime.consoleAPICalled',
'Runtime.exceptionThrown',
'Log.entryAdded',
'Network.requestWillBeSent',
'Network.responseReceived',
'Network.loadingFinished',
'Network.loadingFailed',
];
const MAX_RENDERED_EVENTS = 500;
const EVENT_WAIT_MS = 5_000;
const EVENT_DRAIN_DELAY_MS = 100;
const EVENT_EMPTY_DELAY_MS = 250;
function normalizeAddress(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed)
? trimmed
: `http://${trimmed}`;
}
function readBounds(element: HTMLElement | null): AgentBrowserBounds | undefined {
if (!element) return undefined;
const rect = element.getBoundingClientRect();
const bounds = {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
return bounds.width > 0 && bounds.height > 0 ? bounds : undefined;
}
function browserIsActive(snapshot: AgentBrowserSnapshot | null): boolean {
return Boolean(
snapshot?.browserId
&& snapshot.state !== 'closed'
&& snapshot.state !== 'closing',
);
}
function modalOccludesBrowser(): boolean {
return Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"], #coding-consultation-dock'),
).some((element) => {
if (element.hidden || element.getAttribute('data-state') === 'closed') return false;
const style = window.getComputedStyle(element);
if (element.id === 'coding-consultation-dock' && style.position !== 'absolute' && style.position !== 'fixed') return false;
return style.display !== 'none' && style.visibility !== 'hidden';
});
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
return Number.isNaN(date.getTime())
? '--:--:--'
: date.toLocaleTimeString('zh-CN', { hour12: false });
}
function consoleTone(level: string): string {
if (level === 'error' || level === 'assert') return 'text-destructive';
if (level === 'warning' || level === 'warn') return 'text-amber-700';
return 'text-foreground';
}
function statusTone(status?: number): string {
if (status === undefined) return 'text-muted-foreground';
if (status >= 500) return 'text-destructive';
if (status >= 400) return 'text-amber-700';
return 'text-emerald-700';
}
export function AgentBrowserPanel({
projectId,
open,
onOpenChange,
embedded = false,
toolbarContainer,
teacherBubble,
teacherIntroduction,
conversationId,
onWorkConversation,
workRevision,
onWorkResult,
onRecovery,
}: AgentBrowserPanelProps) {
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
const [address, setAddress] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [occluded, setOccluded] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<DiagnosticsTab>('console');
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
const viewportRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open, teacherIntroduction);
const addressInputRef = useRef<HTMLInputElement | null>(null);
const eventCursorRef = useRef(0);
const snapshotGenerationRef = useRef(0);
const presentationRetries = useRef(0);
const [presentationRevision, setPresentationRevision] = useState(0);
const [presentationFailed, setPresentationFailed] = useState(false);
const wasOpenRef = useRef(open);
const browserActive = browserIsActive(snapshot);
const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]);
const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
// A slow foreground state read must not replace a newly restored view.
if (next.generation < snapshotGenerationRef.current) return;
snapshotGenerationRef.current = next.generation;
setSnapshot(next);
if (browserIsActive(next)) setError(null);
if (next.url && document.activeElement !== addressInputRef.current) {
setAddress(next.url);
}
}, []);
const work = useWorkPreview({
projectId, conversationId, enabled: embedded && open, revision: workRevision,
onResult: (result) => {
if (result.status === 'empty') {
setError(null);
setPresentationFailed(false);
presentationRetries.current = 0;
}
if (result.browser) applySnapshot(result.browser);
if (result.conversation) onWorkConversation?.(result.conversation);
onWorkResult?.(result);
},
});
const workEmpty = embedded && work.result?.status === 'empty';
const workPending = embedded && (work.result?.status !== 'ready' || presentationFailed);
const recovery = codingRecovery(work.result?.errorCode, true);
const transportFailure = ['NETWORK', 'TIMEOUT'].includes(work.result?.errorCode ?? '');
const showBrowser = work.showBrowser;
const restoreWork = work.restore;
const retryPresentation = useCallback(() => {
presentationRetries.current = 0;
setPresentationFailed(false);
setError(null);
setPresentationRevision((value) => value + 1);
restoreWork();
}, [restoreWork]);
useEffect(() => {
if (embedded && open && snapshot && !browserActive && !presentationFailed && work.result?.status === 'ready') {
restoreWork();
}
}, [browserActive, embedded, open, presentationFailed, restoreWork, snapshot, work.result?.status]);
useEffect(() => {
snapshotGenerationRef.current = 0;
setSnapshot(null);
setAddress('');
setError(null);
presentationRetries.current = 0;
setPresentationFailed(false);
setDiagnosticsOpen(false);
setEvents([]);
eventCursorRef.current = 0;
}, [projectId]);
useEffect(() => {
if (!projectId) return undefined;
const unsubscribeShow = subscribeHostEvent<AgentBrowserSnapshot>(
'agent-browser:show',
(next) => {
if (next.projectId !== projectId) return;
applySnapshot(next);
if (workEmpty) restoreWork();
onOpenChange(true);
},
);
const unsubscribeState = subscribeHostEvent<AgentBrowserSnapshot>(
'agent-browser:state',
(next) => {
if (next.projectId && next.projectId !== projectId) return;
applySnapshot(next);
},
);
return () => {
unsubscribeShow();
unsubscribeState();
};
}, [applySnapshot, onOpenChange, projectId, restoreWork, workEmpty]);
useEffect(() => {
if (!open || !projectId) return undefined;
let cancelled = false;
void getAgentBrowserState(projectId)
.then((next) => {
if (!cancelled) applySnapshot(next);
})
.catch(() => {
if (!cancelled) setSnapshot(null);
});
return () => {
cancelled = true;
};
}, [applySnapshot, open, projectId]);
useEffect(() => {
if (!open || !projectId) return undefined;
let cancelled = false;
const syncAfterResume = () => {
if (document.visibilityState === 'hidden') return;
void getAgentBrowserState(projectId)
.then((next) => {
if (!cancelled) applySnapshot(next);
})
.catch(() => {
if (!cancelled) setSnapshot(null);
});
};
window.addEventListener('focus', syncAfterResume);
document.addEventListener('visibilitychange', syncAfterResume);
return () => {
cancelled = true;
window.removeEventListener('focus', syncAfterResume);
document.removeEventListener('visibilitychange', syncAfterResume);
};
}, [applySnapshot, open, projectId]);
useEffect(() => {
const wasOpen = wasOpenRef.current;
wasOpenRef.current = open;
if (!wasOpen || open || !projectId) return;
// Switching the central workspace tab must preserve the running work page.
if (embedded) return;
setSnapshot(null);
setDiagnosticsOpen(false);
setEvents([]);
eventCursorRef.current = 0;
void closeAgentBrowser(projectId).catch(() => undefined);
}, [embedded, open, projectId]);
useEffect(() => () => {
if (projectId) void closeAgentBrowser(projectId).catch(() => undefined);
}, [projectId]);
useEffect(() => {
eventCursorRef.current = 0;
setEvents([]);
}, [snapshot?.browserId, snapshot?.generation]);
useEffect(() => {
if (!open) {
setOccluded(false);
return undefined;
}
const update = () => setOccluded(modalOccludesBrowser());
update();
window.addEventListener('resize', update);
const observer = new MutationObserver(update);
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'data-state', 'hidden', 'style'],
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
window.removeEventListener('resize', update);
};
}, [open]);
useEffect(() => {
if (!projectId || !browserActive) return undefined;
if (!open || occluded || workPending) {
void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
return undefined;
}
const viewport = viewportRef.current;
if (!viewport) return undefined;
let cancelled = false;
let timer: number | null = null;
let lastBounds = '';
const schedule = () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
const bounds = readBounds(viewport);
if (!bounds) return;
const key = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`;
if (key === lastBounds) return;
lastBounds = key;
void presentAgentBrowser({ projectId, visible: true, bounds })
.then((next) => {
if (!cancelled) {
applySnapshot(next);
if (browserIsActive(next) && next.visible) presentationRetries.current = 0;
}
})
.catch((cause) => {
if (!cancelled) {
if (embedded) {
if (presentationRetries.current++ < 2) {
setSnapshot(null);
setPresentationRevision((value) => value + 1);
restoreWork();
} else {
setPresentationFailed(true);
}
} else setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
}
});
}, 50);
};
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(schedule);
observer?.observe(viewport);
window.addEventListener('resize', schedule);
schedule();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
observer?.disconnect();
window.removeEventListener('resize', schedule);
void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
};
}, [applySnapshot, browserActive, embedded, occluded, open, presentationRevision, projectId, restoreWork, snapshot?.generation, workPending]);
useEffect(() => {
if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
return undefined;
}
let cancelled = false;
void setAgentBrowserDiagnostics({ projectId, enabled: true })
.then((next) => {
if (!cancelled) applySnapshot(next);
})
.catch(() => undefined);
return () => {
cancelled = true;
void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined);
};
}, [applySnapshot, diagnosticsOpen, embedded, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]);
useEffect(() => {
if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
return undefined;
}
let cancelled = false;
let inFlight = false;
let timer: number | null = null;
const poll = async () => {
if (inFlight) return;
inFlight = true;
let retryDelay = EVENT_EMPTY_DELAY_MS;
try {
const page = await readAgentBrowserEvents({
projectId,
after: eventCursorRef.current,
methods: EVENT_METHODS,
limit: 200,
waitMs: EVENT_WAIT_MS,
});
if (cancelled) return;
eventCursorRef.current = page.nextCursor;
setEvents((current) => {
const base = page.gap ? [] : current;
return [...base, ...page.events].slice(-MAX_RENDERED_EVENTS);
});
retryDelay = page.events.length > 0 || page.hasMore
? EVENT_DRAIN_DELAY_MS
: EVENT_EMPTY_DELAY_MS;
} catch {
// The state event or the next explicit open reports actionable failures.
} finally {
inFlight = false;
if (!cancelled) {
timer = window.setTimeout(() => {
timer = null;
void poll();
}, retryDelay);
}
}
};
void poll();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [diagnosticsOpen, embedded, open, projectId, snapshot?.generation, snapshot?.state]);
const runBrowserAction = useCallback(async (
action: () => Promise<AgentBrowserSnapshot>,
) => {
setBusy(true);
setError(null);
try {
applySnapshot(await action());
presentationRetries.current = 0;
setPresentationFailed(false);
showBrowser();
} catch (cause) {
if (embedded) {
setSnapshot(null);
setPresentationRevision((value) => value + 1);
restoreWork();
} else setError(cause instanceof Error ? cause.message : '开发浏览器操作失败');
} finally {
setBusy(false);
}
}, [applySnapshot, embedded, restoreWork, showBrowser]);
const handleOpenAddress = useCallback((event?: FormEvent) => {
event?.preventDefault();
if (!projectId) return;
const url = normalizeAddress(address);
if (!url) return;
setAddress(url);
void runBrowserAction(() => browserActive
? navigateAgentBrowser({ projectId, action: 'url', url })
: openAgentBrowser({ projectId, url, bounds: readBounds(viewportRef.current) }));
}, [address, browserActive, projectId, runBrowserAction]);
const handleNavigate = useCallback((action: 'back' | 'forward' | 'reload') => {
if (!projectId || !browserActive) return;
void runBrowserAction(() => navigateAgentBrowser({ projectId, action }));
}, [browserActive, projectId, runBrowserAction]);
if (!open) return null;
const toolbar = (
<form
data-testid="agent-browser-toolbar"
aria-label="网页导航"
className={cn(
'flex shrink-0 items-center gap-1',
embedded && toolbarContainer ? 'h-8 w-full min-w-0' : 'h-10 border-b border-border/70 px-2',
)}
onSubmit={handleOpenAddress}
>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-md" aria-label="后退" disabled={!snapshot?.canGoBack || busy} onClick={() => handleNavigate('back')}>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-md" aria-label="前进" disabled={!snapshot?.canGoForward || busy} onClick={() => handleNavigate('forward')}>
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-md" aria-label="刷新网页" disabled={!browserActive || busy} onClick={() => handleNavigate('reload')}>
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} aria-hidden="true" />
</Button>
<input
ref={addressInputRef}
value={address}
onChange={(event) => setAddress(event.target.value)}
aria-label="网页地址"
className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium outline-none focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
placeholder={embedded ? '作品打开后会显示地址' : '输入 URL'}
spellCheck={false}
/>
</form>
);
return (
<aside
ref={panelRef}
className={cn(embedded ? 'relative flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'relative flex w-[400px] min-w-[300px] max-w-[48vw] shrink-0 flex-col overflow-hidden border-l border-border/80 bg-background shadow-[-16px_0_32px_hsl(220_20%_16%_/_0.06)] max-[900px]:fixed max-[900px]:inset-y-10 max-[900px]:right-0 max-[900px]:z-40 max-[900px]:max-w-[min(92vw,420px)]')}
data-testid="agent-browser-panel"
aria-label={embedded ? '作品预览' : '开发浏览器'}
>
{!embedded && <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/70 px-2.5">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-brand-soft text-brand">
<Globe2 className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<h2 className="min-w-0 flex-1 truncate text-xs font-semibold">开发浏览器</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-muted-foreground"
aria-label="关闭开发浏览器"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>}
{embedded && toolbarContainer ? createPortal(toolbar, toolbarContainer) : toolbar}
{error && !workEmpty ? (
<p role="alert" className="shrink-0 border-b border-destructive/15 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{error}
</p>
) : null}
<div
ref={viewportRef}
className="relative min-h-[180px] flex-1 bg-surface-tertiary"
data-testid="agent-browser-viewport"
aria-label={embedded ? '作品画面' : '开发浏览器页面'}
>
{!browserActive || workPending ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-6 text-center text-muted-foreground">
<span className="flex h-12 w-12 items-center justify-center rounded-xl border border-border/70 bg-background shadow-soft">
<Globe2 className="h-5 w-5" aria-hidden="true" />
</span>
<p role="status" className="text-[13px] font-semibold text-foreground">{embedded
? workEmpty ? '还没有作品,快和老师聊聊吧' : presentationFailed ? '作品画面暂时没能显示' : work.result?.status === 'failed' ? '作品还没有打开' : work.result?.status === 'waiting' ? '等当前步骤完成,就帮你打开作品' : '正在打开你的作品…'
: '开始浏览'}</p>
{!workEmpty && <p className="text-xs leading-6">{embedded
? presentationFailed ? '点“重新打开作品”,接着看你的作品。' : work.result?.message ?? '准备好后,页面会自动出现在这里。'
: '输入 URL,或等待智能体打开调试页面'}</p>}
{embedded && <div className="flex gap-2">
{!workEmpty && (presentationFailed ? <Button variant="outline" size="sm" onClick={retryPresentation}>重新打开作品</Button> : work.result?.status === 'failed' && <Button variant="outline" size="sm" onClick={async () => {
if (transportFailure || recovery.action === 'check-work' || recovery.action === 'refresh') work.retry();
else try { await onRecovery?.(recovery.action, true); }
catch { setError('暂时没能继续,请稍后再点一次。'); }
}}>{transportFailure ? '再试一次' : recovery.label}</Button>)}
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>回到操作对话</Button>
</div>}
</div>
) : null}
</div>
{!embedded && <section
className="flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
style={{ height: diagnosticsOpen ? '240px' : '42px' }}
data-state={diagnosticsOpen ? 'open' : 'closed'}
data-testid="agent-browser-diagnostics"
>
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border/70 bg-surface-input px-1.5">
<div className="flex min-w-0 items-center">
{(['console', 'network'] as const).map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
className={cn(
'h-10 border-b-2 px-2.5 text-[11px] font-medium',
activeTab === tab
? 'border-brand text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
onClick={() => {
setActiveTab(tab);
setDiagnosticsOpen(true);
}}
>
{tab === 'console'
? `Console ${diagnostics.console.length}`
: `Network ${diagnostics.network.length}`}
</button>
))}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-muted-foreground"
aria-label={diagnosticsOpen ? '收起调试面板' : '展开调试面板'}
onClick={() => setDiagnosticsOpen((current) => !current)}
>
{diagnosticsOpen
? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
: <ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />}
</Button>
</div>
{diagnosticsOpen ? (
<div className="min-h-0 flex-1 overflow-hidden">
{activeTab === 'console' ? (
<div role="log" aria-label="Console 日志" className="h-full overflow-auto font-mono text-[10px] tabular-nums">
{diagnostics.console.length === 0 ? (
<p className="p-3 font-sans text-muted-foreground">暂无 Console 记录</p>
) : diagnostics.console.map((entry) => (
<div key={entry.id} className={cn('flex gap-2 border-b border-border/60 px-2 py-1.5 leading-4', consoleTone(entry.level))}>
<span className="shrink-0 text-muted-foreground">{formatTime(entry.timestamp)}</span>
<span className="shrink-0 font-semibold uppercase">{entry.level}</span>
<span className="min-w-0 whitespace-pre-wrap break-all">{entry.text || '(empty)'}</span>
</div>
))}
</div>
) : (
<div className="h-full overflow-auto" aria-label="Network 请求">
{diagnostics.network.length === 0 ? (
<p className="p-3 text-[10px] text-muted-foreground">暂无 Network 请求</p>
) : (
<table className="w-full table-fixed text-left font-mono text-[10px] tabular-nums">
<thead className="sticky top-0 bg-surface-input">
<tr className="border-b border-border/70">
<th className="w-12 px-2 py-1.5 font-semibold">方法</th>
<th className="w-12 px-1 py-1.5 font-semibold">状态</th>
<th className="px-1 py-1.5 font-semibold">地址</th>
<th className="w-14 px-2 py-1.5 text-right font-semibold">耗时</th>
</tr>
</thead>
<tbody>
{diagnostics.network.map((entry) => (
<tr key={`${entry.requestId}:${entry.sequence}`} className="border-b border-border/60">
<td className="truncate px-2 py-1.5 font-semibold">{entry.method}</td>
<td className={cn('truncate px-1 py-1.5 font-semibold', statusTone(entry.status))}>
{entry.errorText ? '失败' : entry.status ?? '…'}
</td>
<td className="truncate px-1 py-1.5" title={entry.url}>{entry.url}</td>
<td className="px-2 py-1.5 text-right">
{entry.durationMs === undefined ? '…' : `${Math.round(entry.durationMs)}ms`}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
) : null}
</section>}
</aside>
);
}