feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
409
src/lib/agent-browser.ts
Normal file
409
src/lib/agent-browser.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import type {
|
||||
AgentBrowserBounds,
|
||||
AgentBrowserCdpEvent,
|
||||
AgentBrowserCdpEventPage,
|
||||
AgentBrowserCdpResult,
|
||||
AgentBrowserPayloadChunk,
|
||||
AgentBrowserSnapshot,
|
||||
} from '../../shared/agent-browser';
|
||||
|
||||
type BrowserEnvelope = {
|
||||
success: boolean;
|
||||
browser: AgentBrowserSnapshot;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type EventPageEnvelope = {
|
||||
success: boolean;
|
||||
page: AgentBrowserCdpEventPage;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type CdpResultEnvelope = {
|
||||
success: boolean;
|
||||
result: AgentBrowserCdpResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PayloadEnvelope = {
|
||||
success: boolean;
|
||||
chunk: AgentBrowserPayloadChunk;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentBrowserNavigateAction = 'url' | 'back' | 'forward' | 'reload';
|
||||
|
||||
export interface AgentBrowserConsoleEntry {
|
||||
id: number;
|
||||
level: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
source?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AgentBrowserNetworkEntry {
|
||||
requestId: string;
|
||||
sequence: number;
|
||||
method: string;
|
||||
url: string;
|
||||
resourceType?: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
durationMs?: number;
|
||||
encodedDataLength?: number;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export interface AgentBrowserDiagnostics {
|
||||
console: AgentBrowserConsoleEntry[];
|
||||
network: AgentBrowserNetworkEntry[];
|
||||
}
|
||||
|
||||
function jsonBody(body: Record<string, unknown>): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function requireSuccess<T extends { success: boolean; error?: string }>(
|
||||
envelope: T,
|
||||
): T {
|
||||
if (!envelope.success) {
|
||||
throw new Error(envelope.error || '开发浏览器请求失败');
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export async function getAgentBrowserState(
|
||||
projectPath: string,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
const query = new URLSearchParams({ project_path: projectPath });
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
`/api/agent-browser/state?${query.toString()}`,
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function openAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
url: string;
|
||||
bounds?: AgentBrowserBounds;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/open',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
url: input.url,
|
||||
visible: true,
|
||||
...(input.bounds ? { bounds: input.bounds } : {}),
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function presentAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
visible: boolean;
|
||||
bounds?: AgentBrowserBounds;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/present',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
visible: input.visible,
|
||||
...(input.bounds ? { bounds: input.bounds } : {}),
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function navigateAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
action: AgentBrowserNavigateAction;
|
||||
url?: string;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/navigate',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
action: input.action,
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function sendAgentBrowserCdp(input: {
|
||||
projectPath: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionRef?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<AgentBrowserCdpResult> {
|
||||
const response = await hostApiFetch<CdpResultEnvelope>(
|
||||
'/api/agent-browser/cdp/send',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
method: input.method,
|
||||
...(input.params ? { params: input.params } : {}),
|
||||
...(input.sessionRef ? { session_ref: input.sessionRef } : {}),
|
||||
...(input.timeoutMs !== undefined ? { timeout_ms: input.timeoutMs } : {}),
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).result;
|
||||
}
|
||||
|
||||
export async function readAgentBrowserEvents(input: {
|
||||
projectPath: string;
|
||||
after?: number;
|
||||
methods?: string[];
|
||||
limit?: number;
|
||||
waitMs?: number;
|
||||
}): Promise<AgentBrowserCdpEventPage> {
|
||||
const response = await hostApiFetch<EventPageEnvelope>(
|
||||
'/api/agent-browser/cdp/events',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
...(input.after !== undefined ? { after: input.after } : {}),
|
||||
...(input.methods ? { methods: input.methods } : {}),
|
||||
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||
...(input.waitMs !== undefined ? { wait_ms: input.waitMs } : {}),
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).page;
|
||||
}
|
||||
|
||||
export async function readAgentBrowserPayload(input: {
|
||||
projectPath: string;
|
||||
handle: string;
|
||||
offset?: number;
|
||||
maxBytes?: number;
|
||||
}): Promise<AgentBrowserPayloadChunk> {
|
||||
const response = await hostApiFetch<PayloadEnvelope>(
|
||||
'/api/agent-browser/payload/read',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
handle: input.handle,
|
||||
...(input.offset !== undefined ? { offset: input.offset } : {}),
|
||||
...(input.maxBytes !== undefined ? { max_bytes: input.maxBytes } : {}),
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).chunk;
|
||||
}
|
||||
|
||||
export async function closeAgentBrowser(
|
||||
projectPath: string,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/close',
|
||||
jsonBody({ project_path: projectPath }),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function resetAgentBrowserProfile(
|
||||
projectPath: string,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/reset-profile',
|
||||
jsonBody({ project_path: projectPath }),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function stringifyConsoleArgument(value: unknown): string {
|
||||
const remoteObject = asRecord(value);
|
||||
if (!remoteObject) return String(value ?? '');
|
||||
if ('value' in remoteObject) {
|
||||
const primitive = remoteObject.value;
|
||||
if (typeof primitive === 'string') return primitive;
|
||||
try {
|
||||
return JSON.stringify(primitive);
|
||||
} catch {
|
||||
return String(primitive);
|
||||
}
|
||||
}
|
||||
return asString(remoteObject.description)
|
||||
?? asString(remoteObject.className)
|
||||
?? asString(remoteObject.type)
|
||||
?? '';
|
||||
}
|
||||
|
||||
function consoleEntryFromEvent(
|
||||
event: AgentBrowserCdpEvent,
|
||||
): AgentBrowserConsoleEntry | null {
|
||||
const params = asRecord(event.params);
|
||||
if (!params) {
|
||||
if (
|
||||
event.payload
|
||||
&& (
|
||||
event.method === 'Runtime.consoleAPICalled'
|
||||
|| event.method === 'Runtime.exceptionThrown'
|
||||
|| event.method === 'Log.entryAdded'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
id: event.sequence,
|
||||
level: 'info',
|
||||
text: `大型 Console 事件(${event.payload.byteLength} 字节),智能体可按需读取完整内容`,
|
||||
timestamp: event.timestamp,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.method === 'Runtime.consoleAPICalled') {
|
||||
const args = Array.isArray(params.args) ? params.args : [];
|
||||
return {
|
||||
id: event.sequence,
|
||||
level: asString(params.type) ?? 'log',
|
||||
text: args.map(stringifyConsoleArgument).join(' '),
|
||||
timestamp: event.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.method === 'Runtime.exceptionThrown') {
|
||||
const details = asRecord(params.exceptionDetails);
|
||||
const exception = asRecord(details?.exception);
|
||||
const exceptionText = exception ? stringifyConsoleArgument(exception) : '';
|
||||
return {
|
||||
id: event.sequence,
|
||||
level: 'error',
|
||||
text: asString(exception?.description)
|
||||
?? (exceptionText || undefined)
|
||||
?? asString(details?.text)
|
||||
?? '未捕获异常',
|
||||
timestamp: event.timestamp,
|
||||
url: asString(details?.url),
|
||||
};
|
||||
}
|
||||
|
||||
if (event.method === 'Log.entryAdded') {
|
||||
const entry = asRecord(params.entry);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
id: event.sequence,
|
||||
level: asString(entry.level) ?? 'log',
|
||||
text: asString(entry.text) ?? '',
|
||||
timestamp: event.timestamp,
|
||||
source: asString(entry.source),
|
||||
url: asString(entry.url),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deriveAgentBrowserDiagnostics(
|
||||
events: AgentBrowserCdpEvent[],
|
||||
): AgentBrowserDiagnostics {
|
||||
const consoleEntries: AgentBrowserConsoleEntry[] = [];
|
||||
const requests = new Map<string, AgentBrowserNetworkEntry & { startedAt: number }>();
|
||||
const requestOrder: string[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
const consoleEntry = consoleEntryFromEvent(event);
|
||||
if (consoleEntry) consoleEntries.push(consoleEntry);
|
||||
|
||||
const params = asRecord(event.params);
|
||||
const requestId = asString(params?.requestId);
|
||||
if (!params || !requestId) {
|
||||
if (event.payload && event.method.startsWith('Network.')) {
|
||||
const payloadKey = `${event.sessionRef ?? 'root'}:payload:${event.sequence}`;
|
||||
requestOrder.push(payloadKey);
|
||||
requests.set(payloadKey, {
|
||||
requestId: `payload:${event.sequence}`,
|
||||
sequence: event.sequence,
|
||||
method: event.method.slice('Network.'.length),
|
||||
url: `大型 Network 事件(${event.payload.byteLength} 字节),智能体可按需读取完整内容`,
|
||||
resourceType: 'payload',
|
||||
startedAt: event.timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const requestKey = `${event.sessionRef ?? 'root'}:${requestId}`;
|
||||
|
||||
if (event.method === 'Network.requestWillBeSent') {
|
||||
const request = asRecord(params.request);
|
||||
if (!requests.has(requestKey)) requestOrder.push(requestKey);
|
||||
requests.set(requestKey, {
|
||||
requestId,
|
||||
sequence: event.sequence,
|
||||
method: asString(request?.method) ?? 'GET',
|
||||
url: asString(request?.url) ?? '',
|
||||
resourceType: asString(params.type),
|
||||
startedAt: event.timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = requests.get(requestKey);
|
||||
if (!current) continue;
|
||||
|
||||
if (event.method === 'Network.responseReceived') {
|
||||
const response = asRecord(params.response);
|
||||
requests.set(requestKey, {
|
||||
...current,
|
||||
url: asString(response?.url) ?? current.url,
|
||||
resourceType: asString(params.type) ?? current.resourceType,
|
||||
status: asNumber(response?.status),
|
||||
statusText: asString(response?.statusText),
|
||||
mimeType: asString(response?.mimeType),
|
||||
});
|
||||
} else if (event.method === 'Network.loadingFinished') {
|
||||
requests.set(requestKey, {
|
||||
...current,
|
||||
durationMs: Math.max(0, event.timestamp - current.startedAt),
|
||||
encodedDataLength: asNumber(params.encodedDataLength),
|
||||
});
|
||||
} else if (event.method === 'Network.loadingFailed') {
|
||||
requests.set(requestKey, {
|
||||
...current,
|
||||
durationMs: Math.max(0, event.timestamp - current.startedAt),
|
||||
errorText: asString(params.errorText) ?? '请求失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
console: consoleEntries,
|
||||
network: requestOrder
|
||||
.map((requestId) => requests.get(requestId))
|
||||
.filter((entry): entry is AgentBrowserNetworkEntry & { startedAt: number } => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
requestId: entry.requestId,
|
||||
sequence: entry.sequence,
|
||||
method: entry.method,
|
||||
url: entry.url,
|
||||
...(entry.resourceType ? { resourceType: entry.resourceType } : {}),
|
||||
...(entry.status !== undefined ? { status: entry.status } : {}),
|
||||
...(entry.statusText ? { statusText: entry.statusText } : {}),
|
||||
...(entry.mimeType ? { mimeType: entry.mimeType } : {}),
|
||||
...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}),
|
||||
...(entry.encodedDataLength !== undefined
|
||||
? { encodedDataLength: entry.encodedDataLength }
|
||||
: {}),
|
||||
...(entry.errorText ? { errorText: entry.errorText } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
|
||||
'oauth:code': 'oauth:code',
|
||||
'oauth:success': 'oauth:success',
|
||||
'oauth:error': 'oauth:error',
|
||||
'agent-browser:show': 'agent-browser:show',
|
||||
'agent-browser:state': 'agent-browser:state',
|
||||
};
|
||||
|
||||
function getEventSource(): EventSource {
|
||||
|
||||
680
src/pages/Chat/AgentBrowserPanel.tsx
Normal file
680
src/pages/Chat/AgentBrowserPanel.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bug,
|
||||
Globe2,
|
||||
Loader2,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
closeAgentBrowser,
|
||||
deriveAgentBrowserDiagnostics,
|
||||
getAgentBrowserState,
|
||||
navigateAgentBrowser,
|
||||
openAgentBrowser,
|
||||
presentAgentBrowser,
|
||||
readAgentBrowserEvents,
|
||||
} from '@/lib/agent-browser';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
AgentBrowserBounds,
|
||||
AgentBrowserCdpEvent,
|
||||
AgentBrowserSnapshot,
|
||||
} from '../../../shared/agent-browser';
|
||||
|
||||
type DiagnosticsTab = 'console' | 'network';
|
||||
|
||||
export interface AgentBrowserPanelProps {
|
||||
projectId?: string | null;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_URL = 'http://localhost:5173';
|
||||
const EVENT_METHODS = [
|
||||
'Runtime.consoleAPICalled',
|
||||
'Runtime.exceptionThrown',
|
||||
'Log.entryAdded',
|
||||
'Network.requestWillBeSent',
|
||||
'Network.responseReceived',
|
||||
'Network.loadingFinished',
|
||||
'Network.loadingFailed',
|
||||
];
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return DEFAULT_URL;
|
||||
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 hasModalOcclusion(): boolean {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
|
||||
).some((element) => {
|
||||
if (element.getAttribute('data-state') === 'closed' || element.hidden) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
});
|
||||
}
|
||||
|
||||
function isBrowserActive(snapshot: AgentBrowserSnapshot | null): boolean {
|
||||
return Boolean(
|
||||
snapshot?.browserId
|
||||
&& snapshot.state !== 'closed'
|
||||
&& snapshot.state !== 'closing',
|
||||
);
|
||||
}
|
||||
|
||||
function statusPresentation(snapshot: AgentBrowserSnapshot | null): {
|
||||
label: string;
|
||||
className: string;
|
||||
} {
|
||||
if (snapshot?.state === 'attached') {
|
||||
return {
|
||||
label: 'AI 可调试',
|
||||
className: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
|
||||
};
|
||||
}
|
||||
if (snapshot?.state === 'suspended_devtools') {
|
||||
return {
|
||||
label: '原生 DevTools 占用',
|
||||
className: 'bg-amber-50 text-amber-700 ring-amber-600/20',
|
||||
};
|
||||
}
|
||||
if (snapshot?.state === 'opening' || snapshot?.state === 'attaching') {
|
||||
return {
|
||||
label: '正在连接',
|
||||
className: 'bg-brand-soft text-brand ring-brand/20',
|
||||
};
|
||||
}
|
||||
if (
|
||||
snapshot?.state === 'crashed'
|
||||
|| snapshot?.state === 'detached_fault'
|
||||
) {
|
||||
return {
|
||||
label: '需要恢复',
|
||||
className: 'bg-destructive/10 text-destructive ring-destructive/20',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: '尚未开启',
|
||||
className: 'bg-surface-subtle text-muted-foreground ring-border',
|
||||
};
|
||||
}
|
||||
|
||||
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, projectPath }: AgentBrowserPanelProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
|
||||
const [address, setAddress] = useState(DEFAULT_URL);
|
||||
const [activeTab, setActiveTab] = useState<DiagnosticsTab>('console');
|
||||
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [occluded, setOccluded] = useState(false);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const addressInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
|
||||
const browserActive = isBrowserActive(snapshot);
|
||||
const status = statusPresentation(snapshot);
|
||||
const diagnostics = useMemo(
|
||||
() => deriveAgentBrowserDiagnostics(events),
|
||||
[events],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOcclusion = () => setOccluded(hasModalOcclusion());
|
||||
updateOcclusion();
|
||||
const observer = new MutationObserver(updateOcclusion);
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-state', 'hidden', 'style'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
|
||||
setSnapshot(next);
|
||||
if (
|
||||
next.url
|
||||
&& document.activeElement !== addressInputRef.current
|
||||
) {
|
||||
setAddress(next.url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath) return undefined;
|
||||
const unsubscribeShow = subscribeHostEvent<AgentBrowserSnapshot>(
|
||||
'agent-browser:show',
|
||||
(next) => {
|
||||
if (projectId && next.projectId !== projectId) return;
|
||||
applySnapshot(next);
|
||||
setIsOpen(true);
|
||||
},
|
||||
);
|
||||
const unsubscribeState = subscribeHostEvent<AgentBrowserSnapshot>(
|
||||
'agent-browser:state',
|
||||
(next) => {
|
||||
if (projectId && next.projectId && next.projectId !== projectId) return;
|
||||
applySnapshot(next);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
unsubscribeShow();
|
||||
unsubscribeState();
|
||||
};
|
||||
}, [applySnapshot, projectId, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot(null);
|
||||
setEvents([]);
|
||||
eventCursorRef.current = 0;
|
||||
setError(null);
|
||||
setIsOpen(false);
|
||||
if (!projectPath) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
void getAgentBrowserState(projectPath)
|
||||
.then((next) => {
|
||||
if (cancelled || !isBrowserActive(next)) return;
|
||||
applySnapshot(next);
|
||||
setIsOpen(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// A missing browser is the normal initial state.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySnapshot, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !projectPath) return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const next = await getAgentBrowserState(projectPath);
|
||||
if (!cancelled) {
|
||||
applySnapshot(next);
|
||||
setError(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
setError(cause instanceof Error ? cause.message : '无法读取开发浏览器状态');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [applySnapshot, isOpen, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
eventCursorRef.current = 0;
|
||||
setEvents([]);
|
||||
}, [snapshot?.browserId, snapshot?.projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !projectPath || snapshot?.state !== 'attached') {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const page = await readAgentBrowserEvents({
|
||||
projectPath,
|
||||
after: eventCursorRef.current,
|
||||
methods: EVENT_METHODS,
|
||||
limit: 200,
|
||||
waitMs: 500,
|
||||
});
|
||||
if (cancelled) return;
|
||||
eventCursorRef.current = page.nextCursor;
|
||||
setEvents((current) => {
|
||||
const base = page.gap ? [] : current;
|
||||
return [...base, ...page.events].slice(-1000);
|
||||
});
|
||||
} catch {
|
||||
// State polling presents attachment failures without turning the panel noisy.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = window.setInterval(() => void poll(), 750);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [isOpen, projectPath, snapshot?.state, snapshot?.generation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || occluded || !projectPath || !browserActive) return undefined;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return undefined;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
let lastBounds = '';
|
||||
|
||||
const schedulePresentation = () => {
|
||||
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({
|
||||
projectPath,
|
||||
visible: true,
|
||||
bounds,
|
||||
}).then((next) => {
|
||||
if (!cancelled) applySnapshot(next);
|
||||
}).catch((cause) => {
|
||||
if (!cancelled) {
|
||||
setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const observer = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(schedulePresentation);
|
||||
observer?.observe(viewport);
|
||||
window.addEventListener('resize', schedulePresentation);
|
||||
schedulePresentation();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', schedulePresentation);
|
||||
};
|
||||
}, [applySnapshot, browserActive, isOpen, occluded, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath || !browserActive) return undefined;
|
||||
if (!isOpen || occluded) {
|
||||
void presentAgentBrowser({
|
||||
projectPath,
|
||||
visible: false,
|
||||
}).catch(() => undefined);
|
||||
return undefined;
|
||||
}
|
||||
return () => {
|
||||
void presentAgentBrowser({
|
||||
projectPath,
|
||||
visible: false,
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
}, [browserActive, isOpen, occluded, projectPath]);
|
||||
|
||||
const runBrowserAction = useCallback(async (
|
||||
action: () => Promise<AgentBrowserSnapshot>,
|
||||
) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
applySnapshot(await action());
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '开发浏览器操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [applySnapshot]);
|
||||
|
||||
const handleOpenAddress = useCallback((event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!projectPath) return;
|
||||
const url = normalizeAddress(address);
|
||||
setAddress(url);
|
||||
void runBrowserAction(() => browserActive
|
||||
? navigateAgentBrowser({ projectPath, action: 'url', url })
|
||||
: openAgentBrowser({
|
||||
projectPath,
|
||||
url,
|
||||
bounds: readBounds(viewportRef.current),
|
||||
}));
|
||||
}, [address, browserActive, projectPath, runBrowserAction]);
|
||||
|
||||
const handleNavigate = useCallback((
|
||||
action: 'back' | 'forward' | 'reload',
|
||||
) => {
|
||||
if (!projectPath || !browserActive) return;
|
||||
void runBrowserAction(
|
||||
() => navigateAgentBrowser({ projectPath, action }),
|
||||
);
|
||||
}, [browserActive, projectPath, runBrowserAction]);
|
||||
|
||||
const handlePower = useCallback(() => {
|
||||
if (!projectPath) return;
|
||||
if (browserActive) {
|
||||
void runBrowserAction(() => closeAgentBrowser(projectPath));
|
||||
return;
|
||||
}
|
||||
const url = normalizeAddress(address);
|
||||
setAddress(url);
|
||||
void runBrowserAction(() => openAgentBrowser({
|
||||
projectPath,
|
||||
url,
|
||||
bounds: readBounds(viewportRef.current),
|
||||
}));
|
||||
}, [address, browserActive, projectPath, runBrowserAction]);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-start border-l border-border/70 bg-surface-sidebar p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'relative h-10 w-10 rounded-full border border-border/70 p-0 shadow-soft transition-[background-color,color,box-shadow,transform] duration-150 ease-out active:scale-[0.96]',
|
||||
browserActive
|
||||
? 'bg-brand-soft text-brand hover:bg-brand-soft hover:shadow-float'
|
||||
: 'bg-background text-muted-foreground hover:bg-surface-subtle hover:text-foreground',
|
||||
)}
|
||||
aria-label="打开开发浏览器"
|
||||
title={browserActive ? '开发浏览器已收起,AI 调试已暂停' : '打开开发浏览器'}
|
||||
data-testid="agent-browser-panel-open"
|
||||
disabled={!projectPath}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
{browserActive ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-brand ring-2 ring-background"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex min-h-0 w-[min(440px,42vw)] shrink-0 flex-col overflow-hidden border-l border-border/80 bg-background text-foreground"
|
||||
data-testid="agent-browser-panel"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border/70 bg-background/90 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Globe2 className="h-4 w-4 shrink-0 text-brand" />
|
||||
<h2 className="truncate text-balance text-sm font-semibold">开发浏览器</h2>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-1 text-[10px] font-semibold ring-1 ring-inset',
|
||||
status.className,
|
||||
)}
|
||||
data-testid="agent-browser-debug-status"
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full text-muted-foreground active:scale-[0.96]',
|
||||
browserActive
|
||||
? 'bg-brand-soft text-brand hover:bg-brand-soft'
|
||||
: 'hover:bg-surface-subtle hover:text-foreground',
|
||||
)}
|
||||
aria-label={browserActive ? '关闭 AI 调试' : '开启 AI 调试'}
|
||||
aria-pressed={browserActive}
|
||||
disabled={!projectPath || busy}
|
||||
onClick={handlePower}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Power className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]"
|
||||
aria-label="收起开发浏览器"
|
||||
data-testid="agent-browser-panel-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<PanelRightClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex shrink-0 items-center gap-1 border-b border-border/70 bg-surface-input p-2"
|
||||
onSubmit={handleOpenAddress}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0 rounded-full text-muted-foreground active:scale-[0.96]"
|
||||
aria-label="后退"
|
||||
disabled={!browserActive || !snapshot?.canGoBack || busy}
|
||||
onClick={() => handleNavigate('back')}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0 rounded-full text-muted-foreground active:scale-[0.96]"
|
||||
aria-label="前进"
|
||||
disabled={!browserActive || !snapshot?.canGoForward || busy}
|
||||
onClick={() => handleNavigate('forward')}
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0 rounded-full text-muted-foreground active:scale-[0.96]"
|
||||
aria-label="刷新网页"
|
||||
disabled={!browserActive || busy}
|
||||
onClick={() => handleNavigate('reload')}
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} />
|
||||
</Button>
|
||||
<input
|
||||
ref={addressInputRef}
|
||||
value={address}
|
||||
onChange={(event) => setAddress(event.target.value)}
|
||||
aria-label="网页地址"
|
||||
className="h-10 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
|
||||
placeholder="http://localhost:5173"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 shrink-0 rounded-lg px-3 text-xs font-semibold active:scale-[0.96]"
|
||||
disabled={!projectPath || busy}
|
||||
>
|
||||
打开
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-destructive/15 bg-destructive/5 px-3 py-2 text-xs font-medium 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="开发浏览器页面"
|
||||
>
|
||||
{!browserActive ? (
|
||||
<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-2xl bg-background text-brand shadow-soft">
|
||||
<Bug className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-balance text-xs font-semibold text-foreground">输入开发服务地址,开启共享调试</p>
|
||||
<p className="text-pretty text-[11px] font-medium">智能体将看到同一页面的 Console 与 Network</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex h-52 shrink-0 flex-col border-t border-border/80 bg-background">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/70 bg-surface-input px-2">
|
||||
<div className="flex">
|
||||
{(['console', 'network'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={cn(
|
||||
'min-h-10 border-b-2 px-3 py-2 text-[11px] font-medium transition-[border-color,color,transform] duration-150 active:scale-[0.96]',
|
||||
activeTab === tab
|
||||
? 'border-brand text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab === 'console'
|
||||
? `Console ${diagnostics.console.length}`
|
||||
: `Network ${diagnostics.network.length}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full text-muted-foreground hover:text-foreground active:scale-[0.96]"
|
||||
aria-label="清空调试记录"
|
||||
onClick={() => setEvents([])}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'console' ? (
|
||||
<div
|
||||
role="log"
|
||||
aria-label="Console 日志"
|
||||
className="min-h-0 flex-1 overflow-auto font-mono text-[10px] tabular-nums"
|
||||
>
|
||||
{diagnostics.console.length === 0 ? (
|
||||
<p className="p-3 font-sans font-medium 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="min-h-0 flex-1 overflow-auto" aria-label="Network 请求">
|
||||
{diagnostics.network.length === 0 ? (
|
||||
<p className="p-3 text-[10px] font-medium 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>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ChatMessage } from './ChatMessage';
|
||||
import { ComposerAttachmentCard } from './ComposerAttachmentCard';
|
||||
import { ExecutionGraphCard } from './ExecutionGraphCard';
|
||||
import { GameAssetBrowser } from './GameAssetBrowser';
|
||||
import { AgentBrowserPanel } from './AgentBrowserPanel';
|
||||
import { OpencodeSessionDiffPreview } from './OpencodeSessionDiffPreview';
|
||||
import {
|
||||
buildMessageTextWithComposerAttachments,
|
||||
@@ -3174,6 +3175,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{!compactLayout ? (
|
||||
<AgentBrowserPanel
|
||||
projectId={activeProject?.id ?? null}
|
||||
projectPath={activeProject?.path ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ChatCommandRenameDialog
|
||||
open={commands.dialog?.kind === 'rename'}
|
||||
|
||||
Reference in New Issue
Block a user