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

662 lines
26 KiB
TypeScript
Raw 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 {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from 'react';
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;
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,
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;
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>}
<form className="flex h-10 shrink-0 items-center gap-1 border-b border-border/70 px-2" onSubmit={handleOpenAddress}>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 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 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 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>
{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>
);
}