Files
makelore/src/lib/agent-browser.ts

424 lines
12 KiB
TypeScript

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 setAgentBrowserDiagnostics(input: {
projectPath: string;
enabled: boolean;
}): Promise<AgentBrowserSnapshot> {
const response = await hostApiFetch<BrowserEnvelope>(
'/api/agent-browser/diagnostics',
jsonBody({
project_path: input.projectPath,
enabled: input.enabled,
}),
);
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 } : {}),
})),
};
}