Files
OpenMesh/coworker/connectors/browser_worker.py
zhaolei 3e356b117a
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
```
feat(agent): 添加技能自动路由功能
- 引入 SkillRouter 实现根据用户消息自动推荐技能
- 在构建引擎时集成技能路由器
- 从对话历史中提取最后一条用户消息作为路由输入
- 为技能添加触发关键词和中文标题字段支持

refactor(browser): 重构浏览器自动化为子进程架构

- 将 Playwright 浏览器控制移至独立的子进程 worker
- 解决 PyInstaller 打包环境下 C 扩展兼容性问题
- 通过 JSON RPC 协议与浏览器 worker 通信
- 添加工具目录和脚本路径查找机制

feat(skills): 增强技能元数据和UI展示

- 为技能添加 triggers 和 title 字段
- 在技能商店中包含中文标题信息
- 添加 office-viz 技能优先级排序
- 在服务器管理器中返回技能标题

feat(gui): 实现技能选择器UI组件

- 添加带下拉菜单的技能选择器按钮
- 支持中文标题和拼音首字母显示
- 集成会话技能加载和状态管理
- 提供通用技能选项和已启用技能列表
```
2026-09-14 17:36:02 +08:00

300 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Playwright 浏览器子进程 Worker。
后端通过 stdin/stdout 与本进程通信,协议为每行一条 JSON
请求: {"id": 1, "action": "open_url", "params": {"url": "..."}}
响应: {"id": 1, "ok": true, ...} 或 {"id": 1, "error": "..."}
为什么做成子进程:
后端是 PyInstaller 打包的 Pythonplaywright 的 C 扩展在里面会崩;
tools/python/ 是原生 Pythonplaywright/.venv 的包都能正常运行。
"""
import json
import os
import re
import sys
import base64
import threading
from pathlib import Path
def _find_tools_dir() -> Path:
"""查找 tools 目录,优先级:
1. 环境变量 OPENMESH_TOOLS_DIR
2. 从 Python 解释器路径向上找tools/python/python.exe → tools/
3. 从脚本路径向上找
4. 当前目录的 tools/
"""
# 1. 环境变量
env_path = os.environ.get("OPENMESH_TOOLS_DIR", "")
if env_path and Path(env_path).exists():
return Path(env_path)
# 2. 从 Python 解释器向上找tools/python/python.exe → tools/
try:
exe_dir = Path(sys.executable).resolve().parent
if exe_dir.name == "python" and (exe_dir.parent / "tools").exists():
# exe 在 dist/openmesh/tools/python/
return exe_dir.parent
if exe_dir.name == "Scripts" and (exe_dir.parent.parent / "tools").exists():
# exe 在 .venv/Scripts/
return exe_dir.parent.parent / "tools"
except Exception:
pass
# 3. 从脚本路径向上找
here = Path(__file__).resolve().parent
for p in [here, here.parent, here.parent.parent, here.parent.parent.parent]:
candidate = p / "tools"
if candidate.exists():
return candidate
# 4. 兜底
return Path("tools")
# 找到 tools 目录,设置 playwright 浏览器路径
_tools_dir = _find_tools_dir()
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_tools_dir / "playwright"))
# 把 .venv 的 site-packages 加到路径
_venv_site = _tools_dir.parent / ".venv" / "Lib" / "site-packages"
if _venv_site.exists():
sys.path.insert(0, str(_venv_site))
from playwright.sync_api import sync_playwright # noqa: E402
# ===== 页面快照 JS跟原来 browser_automation.py 里的一样)=====
_SNAPSHOT_JS = """
() => {
const visible = (el) => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style && style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
};
const labelFor = (el) => {
if (el.labels && el.labels.length) return Array.from(el.labels).map(l => l.innerText.trim()).filter(Boolean).join(' ');
const id = el.getAttribute('id');
if (id) {
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
if (label) return label.innerText.trim();
}
return '';
};
const describe = (el, i) => ({
index: i,
tag: el.tagName.toLowerCase(),
type: el.getAttribute('type') || '',
id: el.getAttribute('id') || '',
name: el.getAttribute('name') || '',
role: el.getAttribute('role') || '',
aria: el.getAttribute('aria-label') || '',
label: labelFor(el),
placeholder: el.getAttribute('placeholder') || '',
text: (el.innerText || el.value || '').trim().slice(0, 200),
href: el.getAttribute('href') || '',
selectorHint: el.getAttribute('id') ? `#${CSS.escape(el.getAttribute('id'))}` : (el.getAttribute('name') ? `[name="${el.getAttribute('name')}"]` : '')
});
const controls = Array.from(document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]'))
.filter(visible)
.slice(0, 120)
.map(describe);
return {
title: document.title,
url: location.href,
text: document.body ? document.body.innerText : '',
controls
};
}
"""
class BrowserWorker:
def __init__(self):
self._pw = None
self._browser = None
self._context = None
self._page = None
self._lock = threading.Lock()
def _ensure_page(self):
if self._page is not None:
return self._page
if self._pw is None:
self._pw = sync_playwright().start()
if self._browser is None:
self._browser = self._pw.chromium.launch(
headless=True,
executable_path=str(_tools_dir / "playwright" / "chromium-1234" / "chrome-win64" / "chrome.exe"),
)
if self._context is None:
self._context = self._browser.new_context(
viewport={"width": 1280, "height": 900}
)
if self._page is None:
self._page = self._context.new_page()
return self._page
def _snapshot(self, page, max_chars=20000):
data = page.evaluate(_SNAPSHOT_JS)
text = re.sub(r"\n{3,}", "\n\n", str(data.get("text") or ""))
cap = max(1, min(int(max_chars or 20000), 100000))
return {
"title": data.get("title"),
"url": data.get("url"),
"text": text[:cap],
"truncated": len(text) > cap,
"controls": (data.get("controls") or [])[:30],
}
def _target_locator(self, page, target):
target = str(target).strip()
if target.startswith("text="):
return page.get_by_text(target[5:], exact=False).first
if target.startswith("role="):
role_name = target[5:]
role, _, name = role_name.partition(":")
return page.get_by_role(role.strip(), name=name.strip() or None).first
try:
return page.locator(target).first
except Exception:
return page.get_by_text(target, exact=False).first
# ===== 动作处理 =====
def handle(self, action, params):
with self._lock:
try:
fn = getattr(self, f"_action_{action}", None)
if fn is None:
return {"error": f"unknown action: {action}"}
return fn(params or {})
except Exception as e:
return {"error": f"{type(e).__name__}: {e}"}
def _action_state(self, params):
if self._page is None:
return {"open": False, "status": "closed", "url": "", "title": "", "controls": []}
snap = self._snapshot(self._page, params.get("max_chars", 2000))
return {
"open": True,
"status": "open",
"url": self._page.url,
"title": self._page.title(),
"controls": snap.get("controls", []),
}
def _action_open_url(self, params):
url = params.get("url", "")
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
page = self._ensure_page()
page.goto(url, wait_until=params.get("wait_until", "domcontentloaded"), timeout=30000)
return {"ok": True, "url": page.url}
def _action_read_page(self, params):
page = self._ensure_page()
return self._snapshot(page, params.get("max_chars", 20000))
def _action_click(self, params):
page = self._ensure_page()
self._target_locator(page, params.get("target", "")).click(timeout=10000)
return {"ok": True, "url": page.url}
def _action_type(self, params):
page = self._ensure_page()
loc = self._target_locator(page, params.get("target", ""))
if params.get("clear", True):
loc.fill(params.get("text", ""), timeout=10000)
else:
loc.type(params.get("text", ""), timeout=10000)
return {"ok": True, "url": page.url}
def _action_select(self, params):
page = self._ensure_page()
self._target_locator(page, params.get("target", "")).select_option(
params.get("value", ""), timeout=10000
)
return {"ok": True, "url": page.url}
def _action_upload_file(self, params):
page = self._ensure_page()
path = params.get("path", "")
if not path or not os.path.exists(path):
return {"error": f"file not found: {path}"}
self._target_locator(page, params.get("target", "")).set_input_files(path, timeout=10000)
return {"ok": True, "path": path}
def _action_wait(self, params):
page = self._ensure_page()
target = params.get("target", "")
if target:
self._target_locator(page, target).wait_for(
timeout=max(1, int(params.get("milliseconds") or 1000))
)
else:
page.wait_for_timeout(max(1, min(int(params.get("milliseconds") or 1000), 30000)))
return {"ok": True, "url": page.url}
def _action_screenshot(self, params):
page = self._ensure_page()
path = params.get("path", "")
if path:
out_path = Path(path).resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(out_path), full_page=True)
return {"ok": True, "path": str(out_path), "url": page.url}
else:
# 返回 base64
png = page.screenshot(full_page=False)
data_url = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
return {"ok": True, "screenshot_data_url": data_url, "url": page.url}
def _action_close(self, params):
try:
if self._context:
self._context.close()
if self._browser:
self._browser.close()
if self._pw:
self._pw.stop()
finally:
self._page = None
self._context = None
self._browser = None
self._pw = None
return {"ok": True}
def main():
worker = BrowserWorker()
# 发送 ready 信号
print(json.dumps({"id": 0, "ok": True, "status": "ready"}), flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
print(json.dumps({"id": 0, "error": "invalid JSON"}), flush=True)
continue
req_id = req.get("id", 0)
action = req.get("action", "")
params = req.get("params", {})
result = worker.handle(action, params)
result["id"] = req_id
print(json.dumps(result, ensure_ascii=False), flush=True)
# 退出命令
if action == "close":
break
if __name__ == "__main__":
main()