feat: implement browser open functionality and related tests

This commit is contained in:
duanshuwen
2026-04-23 19:27:21 +08:00
parent c9617a3777
commit 979fb0a0f6
7 changed files with 567 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
"use strict";
require("electron");
require("./main-DpM79zJd.js");
require("./main-CFKIo_7y.js");
require("electron-squirrel-startup");
require("electron-log");
require("bytenode");

View File

@@ -0,0 +1,97 @@
import logManager from '@electron/service/logger';
import { extractBrowserOpenIntent, openUrlInBrowser } from '@electron/service/browser-open-service';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
import type { RawMessage } from '@runtime/shared/chat-model';
import { sessionStore } from './session-store';
import type { GatewayEvent } from './types';
function buildBrowserOpenResponseText(result: { pageUrl: string; title?: string }): string {
const suffix = result.title ? `${result.title}` : '';
return `已为你打开 ${result.pageUrl}${suffix}`;
}
function buildBrowserOpenErrorText(error: unknown): string {
return `打开失败:${error instanceof Error ? error.message : String(error)}`;
}
async function processBrowserOpen(
sessionKey: string,
runId: string,
url: string,
signal: AbortSignal,
broadcast: (event: GatewayEvent) => void,
) {
let assistantText = '';
try {
const result = await openUrlInBrowser(url, { signal });
if (signal.aborted) {
return;
}
assistantText = buildBrowserOpenResponseText(result);
} catch (error) {
if (signal.aborted) {
return;
}
assistantText = buildBrowserOpenErrorText(error);
}
sessionStore.clearActiveRun(sessionKey);
const finalMessage: RawMessage = {
role: 'assistant',
content: assistantText,
timestamp: Date.now(),
};
sessionStore.appendMessage(sessionKey, finalMessage);
appendTranscriptLine(sessionKey, {
type: 'message',
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: assistantText,
tool: 'browser.open_url',
},
});
broadcast({
type: 'chat:final',
sessionKey,
runId,
message: finalMessage,
});
}
export function maybeHandleBrowserOpenMessage(
sessionKey: string,
runId: string,
message: RawMessage,
broadcast: (event: GatewayEvent) => void,
): boolean {
const browserIntent = typeof message.content === 'string'
? extractBrowserOpenIntent(message.content)
: null;
if (!browserIntent) {
return false;
}
const abortController = new AbortController();
sessionStore.setActiveRun(sessionKey, runId, abortController);
processBrowserOpen(sessionKey, runId, browserIntent.url, abortController.signal, broadcast).catch(
(error) => {
logManager.error('Unexpected error in processBrowserOpen:', error);
sessionStore.clearActiveRun(sessionKey);
broadcast({
type: 'chat:error',
sessionKey,
runId,
error: error instanceof Error ? error.message : String(error),
});
},
);
return true;
}

View File

@@ -1,4 +1,4 @@
import { randomUUID } from 'crypto';
import { randomUUID } from 'node:crypto';
import { createProvider } from '@electron/providers';
import type { BaseProvider } from '@electron/providers/BaseProvider';
import { providerApiService } from '@electron/service/provider-api-service';
@@ -8,6 +8,7 @@ import type { RawMessage } from '@runtime/shared/chat-model';
import { sessionStore } from '../session-store';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
import { maybeHandleBrowserOpenMessage } from '../browser-shortcut';
export interface GatewayChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
@@ -131,6 +132,10 @@ export function handleChatSend(
},
});
if (maybeHandleBrowserOpenMessage(sessionKey, runId, userMessage, broadcast)) {
return { runId };
}
// 2. Resolve provider account
const accountId = options?.providerAccountId || providerApiService.getDefault().accountId;
if (!accountId) {

View File

@@ -0,0 +1,159 @@
import { chromium } from 'playwright';
import logManager from '@electron/service/logger';
import { launchLocalChrome } from '@electron/utils/chrome/launchLocalChrome';
const CDP_ENDPOINT = 'http://127.0.0.1:9222';
const NAVIGATION_TIMEOUT_MS = 15_000;
const OPEN_URL_PATTERNS = [
/^\s*(?:||)\s*[:]?\s*(https?:\/\/\S+)\s*$/iu,
/^\s*(?:open|visit)\s*[:]?\s*(https?:\/\/\S+)\s*$/iu,
] as const;
export interface BrowserOpenIntent {
url: string;
}
export interface BrowserOpenResult {
url: string;
pageUrl: string;
title?: string;
}
function stripTrailingPunctuation(value: string): string {
return value.trim().replace(/[)\]}>.,!?;:'"]+$/u, '');
}
function normalizeHttpUrl(value: string): string {
const raw = stripTrailingPunctuation(value);
const parsed = new URL(raw);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
}
return parsed.toString();
}
function isBlankLikePage(url: string): boolean {
const normalized = String(url || '').trim().toLowerCase();
if (!normalized) return true;
return normalized === 'about:blank'
|| normalized.startsWith('about:blank#')
|| normalized.startsWith('about:blank?')
|| normalized === 'chrome://newtab/'
|| normalized.startsWith('chrome://newtab')
|| normalized.startsWith('chrome://new-tab-page');
}
function isSameTarget(currentUrl: string, targetUrl: string): boolean {
if (!currentUrl || !targetUrl) return false;
if (currentUrl.startsWith(targetUrl)) return true;
try {
const current = new URL(currentUrl);
const target = new URL(targetUrl);
return current.origin === target.origin && current.pathname.startsWith(target.pathname);
} catch {
return false;
}
}
function assertNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('Browser open aborted');
}
}
function toBrowserConnectError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
if (
message.includes('connect ECONNREFUSED')
|| message.includes('retrieving websocket url from http://127.0.0.1:9222')
) {
return new Error('Chrome 已启动但远程调试端口 9222 不可用,请稍后重试或重启本地 Chrome');
}
return error instanceof Error ? error : new Error(message);
}
export function extractBrowserOpenIntent(text: string): BrowserOpenIntent | null {
for (const pattern of OPEN_URL_PATTERNS) {
const match = pattern.exec(text);
if (!match?.[1]) {
continue;
}
try {
return {
url: normalizeHttpUrl(match[1]),
};
} catch {
return null;
}
}
return null;
}
export async function openUrlInBrowser(
url: string,
options?: { signal?: AbortSignal },
): Promise<BrowserOpenResult> {
const normalizedUrl = normalizeHttpUrl(url);
assertNotAborted(options?.signal);
await launchLocalChrome();
assertNotAborted(options?.signal);
const browser = await chromium.connectOverCDP(CDP_ENDPOINT).catch((error) => {
throw toBrowserConnectError(error);
});
try {
const context = browser.contexts()[0];
if (!context) {
throw new Error('No browser context available');
}
let page = context.pages().find((candidate) => isSameTarget(candidate.url(), normalizedUrl));
if (!page) {
page = context.pages().find((candidate) => isBlankLikePage(candidate.url()));
}
if (!page) {
page = await context.newPage();
}
await page.bringToFront();
assertNotAborted(options?.signal);
if (!isSameTarget(page.url(), normalizedUrl)) {
await page.goto(normalizedUrl, {
waitUntil: 'domcontentloaded',
timeout: NAVIGATION_TIMEOUT_MS,
});
}
const pageUrl = page.url() || normalizedUrl;
const title = (await page.title().catch(() => '')).trim() || undefined;
logManager.info('Browser open request completed', {
requestedUrl: normalizedUrl,
pageUrl,
title,
});
return {
url: normalizedUrl,
pageUrl,
title,
};
} finally {
try {
if (typeof browser.disconnect === 'function') {
await browser.disconnect();
} else {
await browser.close();
}
} catch (error) {
logManager.warn('Failed to disconnect browser after open request:', error);
}
}
}

View File

@@ -5,30 +5,56 @@ import { isChromeRunning } from './isChromeRunning';
import { spawn } from 'child_process';
import log from 'electron-log';
const CHROME_CDP_PORT = 9222;
const CHROME_READY_TIMEOUT_MS = 15_000;
const CHROME_READY_POLL_MS = 250;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForChromeReady(timeoutMs = CHROME_READY_TIMEOUT_MS): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isChromeRunning()) {
return true;
}
await sleep(CHROME_READY_POLL_MS);
}
return await isChromeRunning();
}
// 启动本地浏览器
export async function launchLocalChrome() {
const chromePath = getChromePath();
if (!chromePath) {
throw new Error(`Unsupported platform for Chrome launch: ${process.platform}`);
}
// 多账号隔离
const profileDir = getProfileDir('default');
log.info(`Launching Chrome with user data dir: ${profileDir}`);
// 检查端口是否被占用
const portInUse = await isPortInUse(9222);
const portInUse = await isPortInUse(CHROME_CDP_PORT);
const chromeReady = await isChromeRunning();
if (chromeReady) {
log.info('Chrome DevTools endpoint already available, skip launching.');
return;
}
if (portInUse) {
log.info('Chrome already running on port 9222, skip launching.');
return;
throw new Error(`Port ${CHROME_CDP_PORT} is already in use, but Chrome DevTools is unavailable`);
}
if (await isChromeRunning()) {
log.info('Chrome already running, skip launching.');
return;
}
return new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
const chromeProcess = spawn(chromePath as string, [
'--remote-debugging-port=9222',
`--remote-debugging-port=${CHROME_CDP_PORT}`,
'--window-size=1920,1080',
'--window-position=0,0',
'--no-first-run',
@@ -38,14 +64,19 @@ export async function launchLocalChrome() {
// '--window-maximized',
], {
detached: true,
stdio: 'ignore'
stdio: 'ignore',
});
chromeProcess.on('error', reject);
chromeProcess.on('error', (error) => {
reject(new Error(`Failed to launch Chrome at ${chromePath}: ${error instanceof Error ? error.message : String(error)}`));
});
// 延迟几秒等浏览器起来
setTimeout(() => {
resolve(0);
}, 1000); // 延迟1秒
chromeProcess.unref();
resolve();
});
const ready = await waitForChromeReady();
if (!ready) {
throw new Error(`Chrome launched but DevTools endpoint http://127.0.0.1:${CHROME_CDP_PORT} was not ready within ${CHROME_READY_TIMEOUT_MS}ms`);
}
}

View File

@@ -0,0 +1,113 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const state = {
currentUrl: 'about:blank',
};
const page = {
url: vi.fn(() => state.currentUrl),
goto: vi.fn(async (url: string) => {
state.currentUrl = url;
}),
bringToFront: vi.fn(async () => {}),
title: vi.fn(async () => '百度一下,你就知道'),
};
const context = {
pages: vi.fn(() => [page]),
newPage: vi.fn(async () => page),
};
const browser = {
contexts: vi.fn(() => [context]),
disconnect: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
return {
state,
page,
context,
browser,
launchLocalChrome: vi.fn(async () => {}),
isChromeRunning: vi.fn(async () => true),
connectOverCDP: vi.fn(async () => browser),
logger: {
info: vi.fn(),
warn: vi.fn(),
},
};
});
vi.mock('@electron/utils/chrome/launchLocalChrome', () => ({
launchLocalChrome: mocks.launchLocalChrome,
}));
vi.mock('@electron/utils/chrome/isChromeRunning', () => ({
isChromeRunning: mocks.isChromeRunning,
}));
vi.mock('@electron/service/logger', () => ({
default: mocks.logger,
}));
vi.mock('playwright', () => ({
chromium: {
connectOverCDP: mocks.connectOverCDP,
},
}));
describe('browser open service', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.state.currentUrl = 'about:blank';
mocks.page.url.mockImplementation(() => mocks.state.currentUrl);
mocks.page.goto.mockImplementation(async (url: string) => {
mocks.state.currentUrl = url;
});
});
it('extracts an explicit open-url intent and normalizes the URL', async () => {
const { extractBrowserOpenIntent } = await import('../electron/service/browser-open-service');
expect(extractBrowserOpenIntent('打开 http://www.baidu.com。')).toEqual({
url: 'http://www.baidu.com/',
});
expect(extractBrowserOpenIntent('请帮我分析一下 http://www.baidu.com')).toBeNull();
expect(extractBrowserOpenIntent('打开 javascript:alert(1)')).toBeNull();
});
it('launches chrome via cdp and opens the requested page', async () => {
const { openUrlInBrowser } = await import('../electron/service/browser-open-service');
const result = await openUrlInBrowser('http://www.baidu.com');
expect(mocks.launchLocalChrome).toHaveBeenCalledTimes(1);
expect(mocks.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:9222');
expect(mocks.page.bringToFront).toHaveBeenCalledTimes(1);
expect(mocks.page.goto).toHaveBeenCalledWith('http://www.baidu.com/', expect.objectContaining({
waitUntil: 'domcontentloaded',
timeout: 15000,
}));
expect(mocks.browser.disconnect).toHaveBeenCalledTimes(1);
expect(result).toEqual({
url: 'http://www.baidu.com/',
pageUrl: 'http://www.baidu.com/',
title: '百度一下,你就知道',
});
});
it('wraps cdp connection errors with a clearer message', async () => {
mocks.connectOverCDP.mockRejectedValueOnce(new Error('connect ECONNREFUSED 127.0.0.1:9222'));
const { openUrlInBrowser } = await import('../electron/service/browser-open-service');
await expect(openUrlInBrowser('http://www.baidu.com')).rejects.toThrow(
'Chrome 已启动但远程调试端口 9222 不可用',
);
});
});

View File

@@ -0,0 +1,144 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
appendMessage: vi.fn(),
setActiveRun: vi.fn(),
clearActiveRun: vi.fn(),
extractBrowserOpenIntent: vi.fn(),
openUrlInBrowser: vi.fn(),
appendTranscriptLine: vi.fn(),
logger: {
error: vi.fn(),
},
}));
vi.mock('../electron/gateway/session-store', () => ({
sessionStore: {
appendMessage: mocks.appendMessage,
setActiveRun: mocks.setActiveRun,
clearActiveRun: mocks.clearActiveRun,
},
}));
vi.mock('@electron/service/browser-open-service', () => ({
extractBrowserOpenIntent: mocks.extractBrowserOpenIntent,
openUrlInBrowser: mocks.openUrlInBrowser,
}));
vi.mock('@electron/utils/token-usage-writer', () => ({
appendTranscriptLine: mocks.appendTranscriptLine,
}));
vi.mock('@electron/service/logger', () => ({
default: mocks.logger,
}));
function flushAsyncTasks(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
describe('gateway browser shortcut', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.extractBrowserOpenIntent.mockImplementation((text: string) => (
text === '打开 http://www.baidu.com'
? { url: 'http://www.baidu.com/' }
: null
));
});
it('starts a browser-open run and emits a final assistant message on success', async () => {
mocks.openUrlInBrowser.mockResolvedValue({
url: 'http://www.baidu.com/',
pageUrl: 'http://www.baidu.com/',
title: '百度一下,你就知道',
});
const { maybeHandleBrowserOpenMessage } = await import('../electron/gateway/browser-shortcut');
const broadcast = vi.fn();
const handled = maybeHandleBrowserOpenMessage(
'agent:test:main',
'run-1',
{ role: 'user', content: '打开 http://www.baidu.com' },
broadcast,
);
expect(handled).toBe(true);
expect(mocks.setActiveRun).toHaveBeenCalledWith(
'agent:test:main',
'run-1',
expect.any(AbortController),
);
await flushAsyncTasks();
expect(mocks.openUrlInBrowser).toHaveBeenCalledWith(
'http://www.baidu.com/',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(mocks.appendMessage).toHaveBeenCalledWith(
'agent:test:main',
expect.objectContaining({
role: 'assistant',
content: '已为你打开 http://www.baidu.com/(百度一下,你就知道)',
}),
);
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
type: 'chat:final',
sessionKey: 'agent:test:main',
runId: 'run-1',
}));
});
it('emits a failure assistant message when browser open fails', async () => {
mocks.openUrlInBrowser.mockRejectedValue(new Error('No browser context available'));
const { maybeHandleBrowserOpenMessage } = await import('../electron/gateway/browser-shortcut');
const broadcast = vi.fn();
const handled = maybeHandleBrowserOpenMessage(
'agent:test:main',
'run-2',
{ role: 'user', content: '打开 http://www.baidu.com' },
broadcast,
);
expect(handled).toBe(true);
await flushAsyncTasks();
expect(mocks.appendMessage).toHaveBeenCalledWith(
'agent:test:main',
expect.objectContaining({
role: 'assistant',
content: '打开失败No browser context available',
}),
);
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
type: 'chat:final',
runId: 'run-2',
}));
});
it('returns false when the user message is not an explicit browser-open command', async () => {
mocks.extractBrowserOpenIntent.mockReturnValue(null);
const { maybeHandleBrowserOpenMessage } = await import('../electron/gateway/browser-shortcut');
expect(
maybeHandleBrowserOpenMessage(
'agent:test:main',
'run-3',
{ role: 'user', content: '帮我总结一下百度首页' },
vi.fn(),
),
).toBe(false);
expect(mocks.openUrlInBrowser).not.toHaveBeenCalled();
});
});