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

@@ -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`);
}
}