From 5d55ec0f8cf2f3a4cec3d177aa63f6908d8063f8 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 14 Jul 2026 18:02:27 +0800 Subject: [PATCH] feat(browser): reuse system Chrome/Edge, bundle playwright for desktop --- agent/tools/browser/browser_env.py | 290 ++++++++++++++++++ agent/tools/browser/browser_service.py | 74 ++++- agent/tools/browser/browser_tool.py | 41 +++ channel/web/static/js/console.js | 27 ++ cli/commands/install.py | 163 ++++++++-- desktop/build/cowagent-backend.spec | 19 +- desktop/build/requirements-desktop.txt | 6 + .../src/renderer/src/components/ChatInput.tsx | 1 + .../src/renderer/src/components/Markdown.tsx | 28 ++ desktop/src/renderer/src/i18n.ts | 2 + docs/ja/README.md | 10 +- docs/ja/tools/browser.mdx | 15 + docs/tools/browser.mdx | 15 + docs/zh/README-Hant.md | 14 +- docs/zh/README.md | 14 +- docs/zh/tools/browser.mdx | 15 + plugins/cow_cli/cow_cli.py | 4 +- 17 files changed, 676 insertions(+), 62 deletions(-) create mode 100644 agent/tools/browser/browser_env.py diff --git a/agent/tools/browser/browser_env.py b/agent/tools/browser/browser_env.py new file mode 100644 index 00000000..ef1b4526 --- /dev/null +++ b/agent/tools/browser/browser_env.py @@ -0,0 +1,290 @@ +""" +Browser environment detection and capability resolution. + +Centralizes everything about *where* a usable browser engine comes from, so +both the runtime (browser_service) and the installer (cli/commands/install) +agree on the same decisions: + + - Whether the `playwright` Python package is importable. + - Whether a system Chrome / Edge is installed (Playwright can drive it via + the `channel="chrome"/"msedge"` launcher, no download needed). + - Where Playwright's own Chromium download lives (redirected to the writable + data dir so it survives frozen/desktop app updates). + +Resolution priority (see resolve_engine): + 1. system-chrome -> drive the user's installed Chrome / Edge (zero download) + 2. playwright-chromium -> Playwright's own Chromium, if already downloaded + 3. none -> nothing usable yet; caller should trigger onboarding +""" + +import os +import sys +import shutil +from typing import Optional, Dict, Any + +from common.log import logger + + +# Playwright browser channels we accept for the "system-chrome" mode, in +# preference order. "chrome" covers stable Google Chrome; "msedge" is the +# Chromium-based Edge shipped on every Windows 10/11. +_PREFERRED_CHANNELS = ("chrome", "msedge", "chrome-beta", "msedge-beta") + + +def get_data_root() -> str: + """Writable data root (~/.cow on desktop, else CWD-based). + + Mirrors the logic in common/log.py without importing config, to avoid a + circular import. The desktop build sets COW_DATA_DIR; source deployments + fall back to the current working directory. + """ + data_dir = os.environ.get("COW_DATA_DIR") + if data_dir: + return os.path.expanduser(data_dir) + return os.getcwd() + + +def browsers_download_dir() -> str: + """Directory Playwright downloads its Chromium into. + + We pin it under the writable data root (~/.cow/ms-playwright) rather than + Playwright's default (~/.cache/ms-playwright or %USERPROFILE%). This keeps + the frozen desktop build self-contained and makes the download survive app + updates. Set as PLAYWRIGHT_BROWSERS_PATH for both install and runtime. + """ + return os.path.join(get_data_root(), "ms-playwright") + + +def apply_browsers_path_env() -> None: + """Point Playwright at our pinned download dir via env var (idempotent). + + Only set it when not already provided by the user, so power users can + override the location. Must run before importing playwright's launcher. + """ + if not os.environ.get("PLAYWRIGHT_BROWSERS_PATH"): + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir() + + +def is_frozen() -> bool: + """True when running inside a PyInstaller-frozen bundle (desktop backend). + + In this mode sys.executable is the frozen exe (no pip), so the installer + must skip `pip install` and only download the browser binary. + """ + return bool(getattr(sys, "frozen", False)) + + +def is_desktop() -> bool: + """True when running as the Electron desktop client (dev or packaged). + + The desktop shell always sets COW_DESKTOP=1 (see python-manager.ts), both in + `npm run dev` (runs app.py with the user's Python) and in the packaged build + (frozen exe). Desktop users have no `cow` CLI, so onboarding must point them + at the in-chat `/install-browser` command rather than a terminal command. + """ + return os.environ.get("COW_DESKTOP") == "1" + + +def has_playwright_package() -> bool: + """True if the `playwright` Python package can be imported.""" + try: + import playwright # noqa: F401 + return True + except Exception: + return False + + +def _windows_program_dirs() -> list: + dirs = [] + for var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"): + val = os.environ.get(var) + if val: + dirs.append(val) + return dirs + + +def detect_system_chrome() -> Optional[Dict[str, str]]: + """Locate an installed Chromium-based browser Playwright can drive. + + Returns a dict {"channel": , "path": } for + the first match, or None. The `channel` is what we hand to Playwright's + launcher; `path` is only informational (Playwright resolves the channel on + its own, but we keep the path for logging / onboarding messages). + """ + candidates = [] + + if sys.platform == "darwin": + candidates = [ + ("chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ("msedge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + ("chrome-beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"), + ] + elif sys.platform == "win32": + prog_dirs = _windows_program_dirs() + for base in prog_dirs: + candidates.append(("chrome", os.path.join(base, "Google", "Chrome", "Application", "chrome.exe"))) + candidates.append(("msedge", os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe"))) + else: + # Linux: rely on PATH lookups for the common binaries. + path_lookups = [ + ("chrome", "google-chrome"), + ("chrome", "google-chrome-stable"), + ("chrome", "chromium"), + ("chrome", "chromium-browser"), + ("msedge", "microsoft-edge"), + ] + for channel, binary in path_lookups: + found = shutil.which(binary) + if found: + return {"channel": channel, "path": found} + + for channel, path in candidates: + if path and os.path.exists(path): + return {"channel": channel, "path": path} + + return None + + +def has_downloaded_chromium() -> bool: + """True if Playwright already has a Chromium download available. + + We check our pinned download dir for a chromium-* folder. This is a + lightweight heuristic (avoids importing/launching Playwright just to probe) + and matches how Playwright lays browsers out on disk. + """ + download_dir = browsers_download_dir() + if not os.path.isdir(download_dir): + return False + try: + for name in os.listdir(download_dir): + # Playwright names its browser dirs like "chromium-1140", + # "chromium_headless_shell-1140". + if name.startswith("chromium"): + return True + except OSError: + pass + return False + + +def resolve_engine(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Decide which browser engine to use, given config and environment. + + Returns a dict describing the launch strategy: + { + "mode": "system-chrome" | "playwright-chromium" | "none", + "channel": Optional[str], # for system-chrome + "path": Optional[str], # for system-chrome (informational) + "has_playwright": bool, + "reason": str, # human-readable, for logging / onboarding + } + + Config keys under tools.browser that influence this: + - engine: "auto" (default) | "system-chrome" | "chromium" + Force a specific engine. "auto" prefers system Chrome, then falls + back to a downloaded Chromium. + - prefer_system_browser: bool (default True). When False under "auto", + skip system Chrome and go straight to Playwright's Chromium. + """ + config = config or {} + apply_browsers_path_env() + + has_pw = has_playwright_package() + engine_pref = str(config.get("engine", "auto")).strip().lower() + prefer_system = config.get("prefer_system_browser", True) + + if not has_pw: + return { + "mode": "none", + "channel": None, + "path": None, + "has_playwright": False, + "reason": "playwright package not available", + } + + system = None + if engine_pref in ("auto", "system-chrome") and prefer_system: + system = detect_system_chrome() + + if engine_pref == "system-chrome": + # Explicitly requested: use system Chrome if found, else report none. + if system: + return { + "mode": "system-chrome", + "channel": system["channel"], + "path": system["path"], + "has_playwright": True, + "reason": f"using system browser ({system['channel']})", + } + return { + "mode": "none", + "channel": None, + "path": None, + "has_playwright": True, + "reason": "engine=system-chrome but no Chrome/Edge found", + } + + if engine_pref == "chromium": + # Explicitly requested Playwright's own Chromium. + if has_downloaded_chromium(): + return { + "mode": "playwright-chromium", + "channel": None, + "path": None, + "has_playwright": True, + "reason": "using downloaded Playwright Chromium", + } + return { + "mode": "none", + "channel": None, + "path": None, + "has_playwright": True, + "reason": "engine=chromium but Chromium not downloaded yet", + } + + # auto: system Chrome first, then downloaded Chromium. + if system: + return { + "mode": "system-chrome", + "channel": system["channel"], + "path": system["path"], + "has_playwright": True, + "reason": f"auto: using system browser ({system['channel']})", + } + if has_downloaded_chromium(): + return { + "mode": "playwright-chromium", + "channel": None, + "path": None, + "has_playwright": True, + "reason": "auto: using downloaded Playwright Chromium", + } + + return { + "mode": "none", + "channel": None, + "path": None, + "has_playwright": True, + "reason": "no system Chrome/Edge and no downloaded Chromium", + } + + +def capability_summary(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """High-level browser capability status, for onboarding / diagnostics. + + Combines resolve_engine with raw detection flags so the UI / tool layer can + craft a helpful message (e.g. "Chrome detected, click to enable" vs + "no browser, will download ~150MB"). + """ + engine = resolve_engine(config) + system = detect_system_chrome() + return { + "ready": engine["mode"] != "none", + "engine": engine, + "has_playwright": engine["has_playwright"], + "has_system_chrome": system is not None, + "system_chrome": system, + "has_downloaded_chromium": has_downloaded_chromium(), + "is_frozen": is_frozen(), + "is_desktop": is_desktop(), + "browsers_dir": browsers_download_dir(), + } diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py index f499fb29..0077e172 100644 --- a/agent/tools/browser/browser_service.py +++ b/agent/tools/browser/browser_service.py @@ -326,12 +326,19 @@ class BrowserService: # - persistent: launch with launch_persistent_context using a user_data_dir # so cookies / login state survive across runs (default). # - fresh: classic launch + new_context, clean state every run. + # + # Within persistent/fresh, the actual Chromium binary is resolved by + # browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero + # download) is preferred, falling back to Playwright's own downloaded + # Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge") + # when driving a system browser, else None (bundled Chromium). cdp_endpoint = self._config.get("cdp_endpoint") or "" persistent_flag = self._config.get("persistent", True) user_data_dir_cfg = self._config.get("user_data_dir") if user_data_dir_cfg is None: user_data_dir_cfg = _DEFAULT_USER_DATA_DIR + self._channel: Optional[str] = None self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else "" if self._cdp_endpoint: self._launch_mode = "cdp" @@ -343,6 +350,22 @@ class BrowserService: self._launch_mode = "fresh" self._user_data_dir = "" + # Resolve which browser engine to drive (system Chrome vs downloaded + # Chromium). Deferred detection failures are surfaced at launch time. + if self._launch_mode != "cdp": + try: + from agent.tools.browser.browser_env import resolve_engine + engine = resolve_engine(self._config) + if engine["mode"] == "system-chrome": + self._channel = engine["channel"] + logger.info(f"[Browser] Engine resolved: {engine['reason']}") + elif engine["mode"] == "playwright-chromium": + logger.info(f"[Browser] Engine resolved: {engine['reason']}") + else: + logger.info(f"[Browser] No ready engine yet: {engine['reason']}") + except Exception as e: + logger.debug(f"[Browser] Engine resolution skipped: {e}") + # Idle auto-release idle_cfg = self._config.get("idle_timeout") self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT @@ -428,6 +451,14 @@ class BrowserService: def _launch_browser(self): """Launch / connect Chromium on the background thread.""" + # Point Playwright at our pinned download dir before any launch so a + # bundled-Chromium fallback finds the browser downloaded to ~/.cow. + try: + from agent.tools.browser.browser_env import apply_browsers_path_env + apply_browsers_path_env() + except Exception as e: + logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}") + if self._headless is None: headless_cfg = self._config.get("headless") self._headless = headless_cfg if headless_cfg is not None else _should_use_headless() @@ -475,12 +506,20 @@ class BrowserService: logger.info("[Browser] Browser ready") def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): - """Classic launch: brand new Chromium with an empty context.""" - logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})") - self._browser = self._playwright.chromium.launch( - headless=self._headless, - args=launch_args, - ) + """Classic launch: brand new Chromium with an empty context. + + When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives + the user's installed system browser instead of its own Chromium. + """ + engine_label = f"system:{self._channel}" if self._channel else "chromium" + logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})") + launch_kwargs: Dict[str, Any] = { + "headless": self._headless, + "args": launch_args, + } + if self._channel: + launch_kwargs["channel"] = self._channel + self._browser = self._playwright.chromium.launch(**launch_kwargs) self._context = self._browser.new_context( viewport=viewport, user_agent=user_agent, @@ -491,18 +530,25 @@ class BrowserService: def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): """Launch Chromium with a persistent user_data_dir so login state survives.""" os.makedirs(self._user_data_dir, exist_ok=True) + engine_label = f"system:{self._channel}" if self._channel else "chromium" logger.info( - f"[Browser] Launching Chromium (persistent, headless={self._headless}, " + f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, " f"profile={self._user_data_dir})" ) + persistent_kwargs: Dict[str, Any] = { + "user_data_dir": self._user_data_dir, + "headless": self._headless, + "args": launch_args, + "viewport": viewport, + "user_agent": user_agent, + } + # When driving a system browser, let it use its real UA instead of the + # spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge). + if self._channel: + persistent_kwargs["channel"] = self._channel + persistent_kwargs.pop("user_agent", None) try: - self._context = self._playwright.chromium.launch_persistent_context( - user_data_dir=self._user_data_dir, - headless=self._headless, - args=launch_args, - viewport=viewport, - user_agent=user_agent, - ) + self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs) except Exception as e: # Profile is locked when another Chromium instance already holds it. msg = str(e).lower() diff --git a/agent/tools/browser/browser_tool.py b/agent/tools/browser/browser_tool.py index 9930a1ae..fb76eae7 100644 --- a/agent/tools/browser/browser_tool.py +++ b/agent/tools/browser/browser_tool.py @@ -185,6 +185,40 @@ class BrowserTool(BaseTool): f"({ip_str}), request blocked for security" ) + def _check_engine_ready(self) -> Optional[ToolResult]: + """Return an actionable onboarding message if no browser engine is ready. + + Returns None when a system Chrome/Edge or a downloaded Chromium is + available (so the tool can proceed). Otherwise returns a ToolResult with + clear guidance so the agent asks the user to enable the browser instead + of surfacing a raw Playwright launch error. CDP mode is exempt (the + endpoint is external and validated at connect time). + """ + if self.config.get("cdp_endpoint"): + return None + try: + from agent.tools.browser.browser_env import capability_summary + summary = capability_summary(self.config) + except Exception as e: + logger.debug(f"[Browser] capability probe failed: {e}") + return None + + if summary.get("ready"): + return None + + # Desktop clients (dev or packaged) have no `cow` CLI — onboard via the + # in-chat `/install-browser` command. Source / web / server installs use + # the `cow install-browser` terminal command. + install_hint = ( + "reply `/install-browser`" if summary.get("is_desktop") + else "run `cow install-browser` in a terminal" + ) + return ToolResult.fail( + f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; " + "skipped automatically if Google Chrome is already installed). " + "Do not retry until the user confirms." + ) + def execute(self, args: Dict[str, Any]) -> ToolResult: action = args.get("action", "").strip().lower() if not action: @@ -195,6 +229,13 @@ class BrowserTool(BaseTool): valid = ", ".join(sorted(self._ACTION_MAP.keys())) return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}") + # Preflight: on desktop the playwright package is bundled but the browser + # binary may be missing; return actionable onboarding instead of a cryptic + # launch failure. + not_ready = self._check_engine_ready() + if not_ready is not None: + return not_ready + try: return handler(self, args) except Exception as e: diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 0cf74766..94899f2a 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -1139,6 +1139,33 @@ function createMd() { return hljsLib.highlightAuto(str).value; } }); + // Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (*) + // and CJK full-width punctuation glued to a URL (common in LLM output like + // "**https://x**,中文"), turning the whole tail into one broken link. Cut + // the URL at the first such char and spill the remainder back as text. + var GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/; + md.core.ruler.after('linkify', 'fix_greedy_linkify', function(state) { + for (var b = 0; b < state.tokens.length; b++) { + var blk = state.tokens[b]; + if (blk.type !== 'inline' || !blk.children) continue; + var ch = blk.children; + for (var i = 0; i < ch.length; i++) { + var open = ch[i]; + if (open.type !== 'link_open' || open.markup !== 'linkify') continue; + var textTok = ch[i + 1], close = ch[i + 2]; + if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue; + var idx = textTok.content.search(GREEDY_LINK_CUT); + if (idx < 0) continue; + var keep = textTok.content.slice(0, idx); + var spill = textTok.content.slice(idx); + textTok.content = keep; + open.attrSet('href', keep); + var spillTok = new state.Token('text', '', 0); + spillTok.content = spill; + ch.splice(i + 3, 0, spillTok); + } + } + }); const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; diff --git a/cli/commands/install.py b/cli/commands/install.py index 442694d3..79695927 100644 --- a/cli/commands/install.py +++ b/cli/commands/install.py @@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int: return ret +def _is_frozen() -> bool: + """True when running inside a PyInstaller-frozen bundle (desktop backend). + + In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so + playwright is already bundled and we only need to download the browser + binary in-process rather than pip-installing anything. + """ + return bool(getattr(sys, "frozen", False)) + + +def _playwright_cli(args: list, env: Optional[dict] = None) -> int: + """Invoke the Playwright CLI, working in both source and frozen builds. + + Source builds shell out to ``python -m playwright ``. Frozen builds + can't use ``-m`` (the exe isn't a Python interpreter), so we call + Playwright's driver entrypoint in-process instead. ``env`` overrides are + applied to os.environ for the duration of the call (frozen path) or passed + through to the subprocess (source path). + """ + if not _is_frozen(): + cmd = [sys.executable, "-m", "playwright"] + args + return subprocess.call(cmd, env=env) + + # Frozen: run the bundled Playwright driver in-process. compute_driver_executable + # returns the Node driver shipped inside the bundle; we spawn it directly. + prev_env = {} + if env: + for k, v in env.items(): + prev_env[k] = os.environ.get(k) + os.environ[k] = v + try: + from playwright._impl._driver import compute_driver_executable, get_driver_env + driver = compute_driver_executable() + # compute_driver_executable may return a tuple (node, cli.js) on newer + # Playwright, or a single path on older ones. + if isinstance(driver, (list, tuple)): + cmd = list(driver) + args + else: + cmd = [str(driver)] + args + # get_driver_env() snapshots os.environ, which we've already patched with + # the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above, + # so mirror + pinned browsers dir are honored here too. + return subprocess.call(cmd, env=get_driver_env()) + except Exception as e: + # Last resort: try the module main via runpy (works if the frozen build + # kept playwright.__main__ importable). + try: + import runpy + sys.argv = ["playwright"] + args + runpy.run_module("playwright", run_name="__main__") + return 0 + except SystemExit as se: + return int(se.code or 0) + except Exception: + return 1 + finally: + for k, v in prev_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _default_stream(msg: str, fg: Optional[str] = None) -> None: """CLI: colored click output.""" if fg == "yellow": @@ -129,6 +192,7 @@ def run_install_browser( stream = stream or _default_stream python = sys.executable legacy_mode = False + frozen = _is_frozen() _phase(on_phase, _t( "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…", @@ -159,7 +223,7 @@ def run_install_browser( # Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it # from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for # py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4. - if sys.platform == "win32": + if sys.platform == "win32" and not frozen: stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow") ret = subprocess.call( [python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"] @@ -172,22 +236,52 @@ def run_install_browser( "yellow", ) - _phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) - stream("[1/3] Installing playwright Python package...", "yellow") - ret = _pip_install(f"playwright=={target_version}", stream) - if ret != 0: - stream("Failed to install playwright package.", "red") - _phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package.")) - return 1 + if frozen: + # Desktop bundle: playwright is already shipped inside the app; there is + # no pip and nothing to install. Skip straight to downloading Chromium. + installed = _get_installed_version() + stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green") + _phase(on_phase, _t( + "✅ [1/3] Playwright 已内置于客户端,跳过安装。", + "✅ [1/3] Playwright is bundled in the app; skipping install.", + )) + else: + _phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) + stream("[1/3] Installing playwright Python package...", "yellow") + ret = _pip_install(f"playwright=={target_version}", stream) + if ret != 0: + stream("Failed to install playwright package.", "red") + _phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package.")) + return 1 - installed = _get_installed_version() - if installed: - stream(f" playwright {installed} installed.", "green") - stream("") - _phase(on_phase, _t( - f"✅ [1/3] Playwright 包已安装({installed or target_version})。", - f"✅ [1/3] Playwright package installed ({installed or target_version}).", - )) + installed = _get_installed_version() + if installed: + stream(f" playwright {installed} installed.", "green") + stream("") + _phase(on_phase, _t( + f"✅ [1/3] Playwright 包已安装({installed or target_version})。", + f"✅ [1/3] Playwright package installed ({installed or target_version}).", + )) + + # With playwright available, prefer the user's system Chrome/Edge: the browser + # tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy + # ~150MB Chromium download entirely. Applies to every runtime (desktop, web, + # source) — only headless Linux servers, which usually lack a system browser, + # fall through to the download below. Honors prefer_system_browser via + # resolve_engine, so users who force downloaded Chromium still get it. + try: + from agent.tools.browser import browser_env + summary = browser_env.capability_summary() + if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome": + sc = summary.get("system_chrome") or {} + stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green") + _phase(on_phase, _t( + f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。", + f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.", + )) + return 0 + except Exception as e: + stream(f" (system browser probe skipped: {e})", None) if sys.platform == "linux": _phase(on_phase, _t( @@ -195,7 +289,7 @@ def run_install_browser( "🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…", )) stream("[2/3] Installing system dependencies (Linux)...", "yellow") - ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"]) + ret = _playwright_cli(["install-deps", "chromium"]) if ret != 0: stream( " Could not auto-install system deps (may need sudo).\n" @@ -238,12 +332,12 @@ def run_install_browser( "🌐 [3/3] Downloading and installing Chromium (large download, please wait)…", )) stream("[3/3] Installing Chromium browser...", "yellow") - cmd = [python, "-m", "playwright", "install", "chromium"] + pw_args = ["install", "chromium"] if _is_headless_linux() and not legacy_mode: ver = _version_tuple(installed or "") if ver >= (1, 57, 0): - cmd.append("--only-shell") + pw_args.append("--only-shell") stream(" (headless shell for Linux server)", None) else: stream(" (full Chromium)", None) @@ -251,6 +345,15 @@ def run_install_browser( stream(" (full browser for Linux desktop)", None) env = os.environ.copy() + # Pin the download location so it survives desktop app updates and matches + # what the runtime looks up (see browser_env.browsers_download_dir()). + try: + from agent.tools.browser.browser_env import browsers_download_dir + env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir() + stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None) + except Exception: + pass + use_mirror = _is_china_network() if use_mirror: env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR @@ -260,7 +363,7 @@ def run_install_browser( "📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.", )) - ret = subprocess.call(cmd, env=env) + ret = _playwright_cli(pw_args, env=env) if ret != 0 and use_mirror: stream(" Mirror download failed, retrying with official CDN...", "yellow") @@ -268,9 +371,9 @@ def run_install_browser( "⚠️ 镜像下载失败,正在改用官方源重试…", "⚠️ Mirror download failed; retrying with the official CDN…", )) - env_no_mirror = os.environ.copy() + env_no_mirror = dict(env) env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None) - ret = subprocess.call(cmd, env=env_no_mirror) + ret = _playwright_cli(pw_args, env=env_no_mirror) if ret != 0: stream("Failed to install Chromium.", "red") @@ -282,10 +385,18 @@ def run_install_browser( stream("Verifying browser installation...", None) _phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…")) - ret = subprocess.call( - [python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"], - stderr=subprocess.DEVNULL, - ) + if frozen: + # Frozen: no child interpreter to spawn; import in-process instead. + try: + from playwright.sync_api import sync_playwright # noqa: F401 + ret = 0 + except Exception: + ret = 1 + else: + ret = subprocess.call( + [python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"], + stderr=subprocess.DEVNULL, + ) if ret != 0: stream( " Warning: playwright import failed. Browser tool may not work on this system.\n" diff --git a/desktop/build/cowagent-backend.spec b/desktop/build/cowagent-backend.spec index 94506d59..6ba93cb8 100644 --- a/desktop/build/cowagent-backend.spec +++ b/desktop/build/cowagent-backend.spec @@ -105,6 +105,14 @@ hiddenimports += collect_submodules('docx') hiddenimports += collect_submodules('pptx') hiddenimports += collect_submodules('openpyxl') +# Playwright powers the browser tool. Only the pure-Python package + its bundled +# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled +# and is either satisfied by the user's system Chrome/Edge (preferred, zero +# download) or downloaded on demand into ~/.cow/ms-playwright at first use. +# Playwright imports its transport/driver lazily, so list submodules explicitly. +hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl'] +hiddenimports += collect_submodules('playwright') + # --- Data files ----------------------------------------------------------- # Runtime-read files/dirs that must travel with the executable. Paths are # (source, dest_dir_in_bundle). @@ -134,6 +142,12 @@ datas += collect_data_files('tiktoken_ext', include_py_files=False) datas += collect_data_files('docx') datas += collect_data_files('pptx') +# Playwright ships its Node.js driver + package.json under playwright/driver/. +# These are NOT Python modules, so hiddenimports won't pull them in — collect +# them as data or `playwright install` / launching fails in the frozen build. +# include_py_files=True is required: the driver dir contains .py entrypoints. +datas += collect_data_files('playwright', include_py_files=True) + # --- Excludes ------------------------------------------------------------- # Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop # mode), tests/docs, and dev-only packages. @@ -143,7 +157,10 @@ excludes = [ 'pip', 'wheel', 'pytest', - 'playwright', # browser tool is opt-in, not bundled + # NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB) + # so the browser tool works out of the box on desktop. The heavy Chromium + # binary is still NOT bundled: it comes from the user's system Chrome/Edge or + # is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py. ] block_cipher = None diff --git a/desktop/build/requirements-desktop.txt b/desktop/build/requirements-desktop.txt index dc6c0776..58637e72 100644 --- a/desktop/build/requirements-desktop.txt +++ b/desktop/build/requirements-desktop.txt @@ -42,6 +42,12 @@ python-docx openpyxl python-pptx +# ---- browser tool ---- +# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB). +# The Chromium binary is NOT bundled: the browser tool drives the user's system +# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use. +playwright==1.52.0 + # ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ---- wechatpy pycryptodome diff --git a/desktop/src/renderer/src/components/ChatInput.tsx b/desktop/src/renderer/src/components/ChatInput.tsx index 49339c5e..34abb0b7 100644 --- a/desktop/src/renderer/src/components/ChatInput.tsx +++ b/desktop/src/renderer/src/components/ChatInput.tsx @@ -53,6 +53,7 @@ const ChatInput = forwardRef(function ChatInput { cmd: '/memory dream ', desc: t('slash_memory_dream') }, { cmd: '/knowledge', desc: t('slash_knowledge') }, { cmd: '/knowledge list', desc: t('slash_knowledge_list') }, + { cmd: '/install-browser', desc: t('slash_install_browser') }, { cmd: '/config', desc: t('slash_config') }, { cmd: '/cancel', desc: t('slash_cancel') }, { cmd: '/logs', desc: t('slash_logs') }, diff --git a/desktop/src/renderer/src/components/Markdown.tsx b/desktop/src/renderer/src/components/Markdown.tsx index a27e4afc..eb44008b 100644 --- a/desktop/src/renderer/src/components/Markdown.tsx +++ b/desktop/src/renderer/src/components/Markdown.tsx @@ -30,6 +30,34 @@ const md: MarkdownIt = new MarkdownIt({ }, }) +// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`) +// and CJK full-width punctuation glued to a URL (common in LLM output like +// `**https://x**,中文`), turning the whole tail into one broken link. Cut the +// URL at the first such char and spill the remainder back as plain text. +const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/ +md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => { + for (const blk of state.tokens) { + if (blk.type !== 'inline' || !blk.children) continue + const ch = blk.children + for (let i = 0; i < ch.length; i++) { + const open = ch[i] + if (open.type !== 'link_open' || open.markup !== 'linkify') continue + const textTok = ch[i + 1] + const close = ch[i + 2] + if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue + const idx = textTok.content.search(_GREEDY_LINK_CUT) + if (idx < 0) continue + const keep = textTok.content.slice(0, idx) + const spill = textTok.content.slice(idx) + textTok.content = keep + open.attrSet('href', keep) + const spillTok = new state.Token('text', '', 0) + spillTok.content = spill + ch.splice(i + 3, 0, spillTok) + } + } +}) + // Open links in a new tab safely. const defaultLinkOpen = md.renderer.rules.link_open || diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index b07e8e7a..5d6669e2 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -376,6 +376,7 @@ const translations: Record> = { slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)', slash_knowledge: '查看知识库统计', slash_knowledge_list: '查看知识库文件树', + slash_install_browser: '安装浏览器工具', slash_config: '查看当前配置', slash_cancel: '中止当前正在运行的 Agent 任务', slash_logs: '查看最近日志', @@ -758,6 +759,7 @@ const translations: Record> = { slash_memory_dream: 'Trigger memory distillation (optional days, default 3)', slash_knowledge: 'Show knowledge base stats', slash_knowledge_list: 'Show knowledge base file tree', + slash_install_browser: 'Install browser tool', slash_config: 'Show current config', slash_cancel: 'Abort the running agent task', slash_logs: 'Show recent logs', diff --git a/docs/ja/README.md b/docs/ja/README.md index c5a3e985..e7f246e8 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -1,10 +1,10 @@

CowAgent

- Latest release - License: MIT - Stars - ドキュメント + Latest release + License: MIT + Stars + ドキュメント

@@ -230,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください: - +
diff --git a/docs/ja/tools/browser.mdx b/docs/ja/tools/browser.mdx index ed332f9e..dc81839d 100644 --- a/docs/ja/tools/browser.mdx +++ b/docs/ja/tools/browser.mdx @@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素 2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。 + + **デスクトップクライアント利用者**:playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時: + - **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨); + - 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。 + + ## ワークフロー Agentがブラウザを使う典型的な流れ: @@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ: } ``` +## ブラウザエンジン + +ブラウザエンジンは自動選択され、設定は不要です: + +1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します; +2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。 + +どちらも以下のログイン状態の永続化を使用し、挙動は同一です。 + ## ログイン状態の永続化 **対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります: diff --git a/docs/tools/browser.mdx b/docs/tools/browser.mdx index 4c6fda82..0494e636 100644 --- a/docs/tools/browser.mdx +++ b/docs/tools/browser.mdx @@ -49,6 +49,12 @@ Control a Chromium browser for web navigation, element interaction and content e 2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool. + + **Desktop client users**: playwright is bundled in the installer, no separate install needed. On first use of the browser tool: + - If **Google Chrome / Edge** is installed, it drives the system browser directly with **no download** (recommended); + - Otherwise, send `/install-browser` in chat to download a lightweight browser engine into `~/.cow`. + + ## Workflow A typical browser workflow for the Agent: @@ -105,6 +111,15 @@ You can override it in `config.json`: } ``` +## Browser Engine + +The browser engine is selected automatically, no configuration needed: + +1. If **Google Chrome / Edge** is detected on the machine, it drives the system browser directly, with **no Chromium download**, using real browser fingerprints; +2. Otherwise it falls back to the Chromium engine downloaded into `~/.cow` via `install-browser`. + +Both use the persistent login below and behave identically. + ## Persistent Login **Log in to a target site once and the Agent can keep using it.** Two ways are supported: diff --git a/docs/zh/README-Hant.md b/docs/zh/README-Hant.md index 3394d393..7af39da2 100644 --- a/docs/zh/README-Hant.md +++ b/docs/zh/README-Hant.md @@ -1,10 +1,10 @@

CowAgent

- Latest release - License: MIT - Stars - 文件 + Latest release + License: MIT + Stars + 文件

@@ -233,7 +233,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像 掃碼加入微信開源交流群: - + 也可透過以下方式獲取支援: @@ -252,7 +252,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像 ## 🏢 企業服務 - + > [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援: > @@ -262,7 +262,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像 **產品諮詢和企業服務** 可聯絡產品客服: - +
diff --git a/docs/zh/README.md b/docs/zh/README.md index 2fcedbd8..9ca15885 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -1,10 +1,10 @@

CowAgent

- Latest release - License: MIT - Stars - 文档 + Latest release + License: MIT + Stars + 文档

@@ -233,7 +233,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 扫码加入微信开源交流群: - + 也可通过以下方式获取支持: @@ -252,7 +252,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 ## 🏢 企业服务 - + > [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持: > @@ -262,7 +262,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 **产品咨询和企业服务** 可联系产品客服: - +
diff --git a/docs/zh/tools/browser.mdx b/docs/zh/tools/browser.mdx index 0d3c9197..d77b06d4 100644 --- a/docs/zh/tools/browser.mdx +++ b/docs/zh/tools/browser.mdx @@ -49,6 +49,12 @@ description: 控制浏览器访问和操作网页 2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。 + + **桌面客户端用户**:playwright 已内置于安装包,无需单独安装。首次使用浏览器工具时: + - 若系统已安装 **Google Chrome / Edge**,会直接驱动系统浏览器,**无需任何下载**(推荐); + - 若未安装,可在对话中发送 `/install-browser`,自动下载一个精简浏览器内核到 `~/.cow`。 + + ## 工作流程 Agent 使用浏览器的典型流程: @@ -105,6 +111,15 @@ Agent 使用浏览器的典型流程: } ``` +## 浏览器内核 + +浏览器内核会自动选择,无需配置: + +1. 若检测到本机已安装 **Google Chrome / Edge**,直接驱动系统浏览器,**无需下载 Chromium**,并使用真实浏览器指纹; +2. 否则使用 `install-browser` 下载到 `~/.cow` 的 Chromium 内核作为兜底。 + +两种方式都使用下面的登录态持久化,行为一致。 + ## 登录态持久化 **只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式: diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index 3642fe09..ac26e831 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -813,8 +813,8 @@ class CowCliPlugin(Plugin): "you can also run `cow install-browser` in a terminal.", ) return _t( - "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。", - "✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).", + "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具。", + "✅ Installation finished. Restart CowAgent to use the browser tool.", ) # ------------------------------------------------------------------