需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
129 lines
3.6 KiB
TypeScript
129 lines
3.6 KiB
TypeScript
import type {
|
|
AgentBrowserCdpEvent,
|
|
AgentBrowserCdpEventPage,
|
|
} from '../../shared/agent-browser';
|
|
|
|
type GapReason = NonNullable<AgentBrowserCdpEventPage['gap']>['reason'];
|
|
|
|
interface BufferedEvent {
|
|
event: AgentBrowserCdpEvent;
|
|
byteLength: number;
|
|
}
|
|
|
|
interface GapMarker {
|
|
sequence: number;
|
|
reason: GapReason;
|
|
}
|
|
|
|
export interface AgentBrowserEventBufferOptions {
|
|
maxEvents?: number;
|
|
maxBytes?: number;
|
|
}
|
|
|
|
const DEFAULT_MAX_EVENTS = 10_000;
|
|
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
|
|
export class AgentBrowserEventBuffer {
|
|
private readonly events: BufferedEvent[] = [];
|
|
private readonly gaps: GapMarker[] = [];
|
|
private readonly maxEvents: number;
|
|
private readonly maxBytes: number;
|
|
private bytes = 0;
|
|
private sequence = 0;
|
|
private evictedThrough = 0;
|
|
|
|
constructor(options: AgentBrowserEventBufferOptions = {}) {
|
|
this.maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
|
|
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
}
|
|
|
|
get cursor(): number {
|
|
return this.sequence;
|
|
}
|
|
|
|
push(event: Omit<AgentBrowserCdpEvent, 'sequence'>): AgentBrowserCdpEvent {
|
|
const complete = { ...event, sequence: ++this.sequence };
|
|
const byteLength = Buffer.byteLength(JSON.stringify(complete));
|
|
this.events.push({ event: complete, byteLength });
|
|
this.bytes += byteLength;
|
|
this.evict();
|
|
return complete;
|
|
}
|
|
|
|
markGap(reason: GapReason): number {
|
|
const sequence = ++this.sequence;
|
|
this.gaps.push({ sequence, reason });
|
|
this.pruneGaps();
|
|
return sequence;
|
|
}
|
|
|
|
read(after = 0, methods?: string[], limit = 100): AgentBrowserCdpEventPage {
|
|
const safeAfter = Math.max(0, Math.trunc(after));
|
|
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200);
|
|
const methodSet = methods?.length ? new Set(methods) : null;
|
|
const start = Math.max(safeAfter, this.evictedThrough);
|
|
const selected: AgentBrowserCdpEvent[] = [];
|
|
let scannedThrough = start;
|
|
|
|
for (const buffered of this.events) {
|
|
if (buffered.event.sequence <= start) continue;
|
|
scannedThrough = buffered.event.sequence;
|
|
if (!methodSet || methodSet.has(buffered.event.method)) {
|
|
selected.push(buffered.event);
|
|
if (selected.length >= safeLimit) break;
|
|
}
|
|
}
|
|
|
|
if (selected.length < safeLimit) {
|
|
scannedThrough = Math.max(scannedThrough, this.sequence);
|
|
}
|
|
|
|
const nextCursor = Math.max(safeAfter, scannedThrough);
|
|
const hasMore = this.events.some(({ event }) =>
|
|
event.sequence > nextCursor && (!methodSet || methodSet.has(event.method)));
|
|
const oldestAvailable = this.events[0]?.event.sequence ?? this.sequence + 1;
|
|
const gap = safeAfter < this.evictedThrough
|
|
? { reason: 'evicted' as const, oldestAvailable }
|
|
: this.gaps.find((marker) =>
|
|
marker.sequence > safeAfter && marker.sequence <= nextCursor);
|
|
|
|
return {
|
|
events: selected,
|
|
nextCursor,
|
|
hasMore,
|
|
...(gap
|
|
? {
|
|
gap: {
|
|
reason: gap.reason,
|
|
oldestAvailable,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
clear(): void {
|
|
this.events.length = 0;
|
|
this.gaps.length = 0;
|
|
this.bytes = 0;
|
|
this.sequence = 0;
|
|
this.evictedThrough = 0;
|
|
}
|
|
|
|
private evict(): void {
|
|
while (this.events.length > this.maxEvents || this.bytes > this.maxBytes) {
|
|
const removed = this.events.shift();
|
|
if (!removed) break;
|
|
this.bytes -= removed.byteLength;
|
|
this.evictedThrough = Math.max(this.evictedThrough, removed.event.sequence);
|
|
}
|
|
this.pruneGaps();
|
|
}
|
|
|
|
private pruneGaps(): void {
|
|
while (this.gaps[0] && this.gaps[0].sequence <= this.evictedThrough) {
|
|
this.gaps.shift();
|
|
}
|
|
}
|
|
}
|