From 979fb0a0f62e83c873ef2cb019e7b831c588927a Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 23 Apr 2026 19:27:21 +0800 Subject: [PATCH] feat: implement browser open functionality and related tests --- dist-electron/main/main.js | 2 +- electron/gateway/browser-shortcut.ts | 97 +++++++++++++ electron/gateway/handlers/chat.ts | 7 +- electron/service/browser-open-service.ts | 159 +++++++++++++++++++++ electron/utils/chrome/launchLocalChrome.ts | 63 +++++--- tests/browser-open-service.test.ts | 113 +++++++++++++++ tests/browser-shortcut.test.ts | 144 +++++++++++++++++++ 7 files changed, 567 insertions(+), 18 deletions(-) create mode 100644 electron/gateway/browser-shortcut.ts create mode 100644 electron/service/browser-open-service.ts create mode 100644 tests/browser-open-service.test.ts create mode 100644 tests/browser-shortcut.test.ts diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 7a17612..414c2f5 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -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"); diff --git a/electron/gateway/browser-shortcut.ts b/electron/gateway/browser-shortcut.ts new file mode 100644 index 0000000..40a787e --- /dev/null +++ b/electron/gateway/browser-shortcut.ts @@ -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; +} diff --git a/electron/gateway/handlers/chat.ts b/electron/gateway/handlers/chat.ts index dcf5e71..b0c76ef 100644 --- a/electron/gateway/handlers/chat.ts +++ b/electron/gateway/handlers/chat.ts @@ -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) { diff --git a/electron/service/browser-open-service.ts b/electron/service/browser-open-service.ts new file mode 100644 index 0000000..b2f8a35 --- /dev/null +++ b/electron/service/browser-open-service.ts @@ -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 { + 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); + } + } +} diff --git a/electron/utils/chrome/launchLocalChrome.ts b/electron/utils/chrome/launchLocalChrome.ts index 75bd378..b396b45 100644 --- a/electron/utils/chrome/launchLocalChrome.ts +++ b/electron/utils/chrome/launchLocalChrome.ts @@ -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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function waitForChromeReady(timeoutMs = CHROME_READY_TIMEOUT_MS): Promise { + 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((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`); + } } diff --git a/tests/browser-open-service.test.ts b/tests/browser-open-service.test.ts new file mode 100644 index 0000000..6bf0c10 --- /dev/null +++ b/tests/browser-open-service.test.ts @@ -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 不可用', + ); + }); +}); diff --git a/tests/browser-shortcut.test.ts b/tests/browser-shortcut.test.ts new file mode 100644 index 0000000..0204769 --- /dev/null +++ b/tests/browser-shortcut.test.ts @@ -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 { + 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(); + }); +});