feat(browser): reuse system Chrome/Edge, bundle playwright for desktop

This commit is contained in:
zhayujie
2026-07-14 18:02:27 +08:00
parent eeb4b7981e
commit 5d55ec0f8c
17 changed files with 676 additions and 62 deletions

View File

@@ -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": <playwright channel>, "path": <exe 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(),
}

View File

@@ -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()

View File

@@ -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:

View File

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

View File

@@ -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 <args>``. 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"

View File

@@ -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

View File

@@ -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

View File

@@ -53,6 +53,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(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') },

View File

@@ -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 ||

View File

@@ -376,6 +376,7 @@ const translations: Record<string, Record<string, string>> = {
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<string, Record<string, string>> = {
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',

View File

@@ -1,10 +1,10 @@
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント" /></a>
</p>
<p align="center">
@@ -230,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
<br/>

View File

@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
2. ブラウザToolは依存関係が大きい約300MBため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
</Note>
<Note>
**デスクトップクライアント利用者**playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
</Note>
## ワークフロー
Agentがブラウザを使う典型的な流れ
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ
}
```
## ブラウザエンジン
ブラウザエンジンは自動選択され、設定は不要です:
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
## ログイン状態の永続化
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります

View File

@@ -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.
</Note>
<Note>
**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`.
</Note>
## 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:

View File

@@ -1,10 +1,10 @@
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件" /></a>
</p>
<p align="center">
@@ -233,7 +233,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
掃碼加入微信開源交流群:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
也可透過以下方式獲取支援:
@@ -252,7 +252,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
## 🏢 企業服務
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
>
@@ -262,7 +262,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
**產品諮詢和企業服務** 可聯絡產品客服:
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
<br/>

View File

@@ -1,10 +1,10 @@
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档"></a>
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档" /></a>
</p>
<p align="center">
@@ -233,7 +233,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
扫码加入微信开源交流群:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
也可通过以下方式获取支持:
@@ -252,7 +252,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
## 🏢 企业服务
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
>
@@ -262,7 +262,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
**产品咨询和企业服务** 可联系产品客服:
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
<br/>

View File

@@ -49,6 +49,12 @@ description: 控制浏览器访问和操作网页
2. 浏览器工具依赖较重约300MB为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
</Note>
<Note>
**桌面客户端用户**playwright 已内置于安装包,无需单独安装。首次使用浏览器工具时:
- 若系统已安装 **Google Chrome / Edge**,会直接驱动系统浏览器,**无需任何下载**(推荐);
- 若未安装,可在对话中发送 `/install-browser`,自动下载一个精简浏览器内核到 `~/.cow`。
</Note>
## 工作流程
Agent 使用浏览器的典型流程:
@@ -105,6 +111,15 @@ Agent 使用浏览器的典型流程:
}
```
## 浏览器内核
浏览器内核会自动选择,无需配置:
1. 若检测到本机已安装 **Google Chrome / Edge**,直接驱动系统浏览器,**无需下载 Chromium**,并使用真实浏览器指纹;
2. 否则使用 `install-browser` 下载到 `~/.cow` 的 Chromium 内核作为兜底。
两种方式都使用下面的登录态持久化,行为一致。
## 登录态持久化
**只需登录一次目标网站Agent 后续可直接使用**。提供两种方式:

View File

@@ -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.",
)
# ------------------------------------------------------------------