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