- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
145 lines
4.9 KiB
Python
145 lines
4.9 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import websockets.sync.client as ws_client
|
||
|
||
from xhs_bridge.config import AppConfig
|
||
from xhs_bridge.exceptions import ProcessError
|
||
from xhs_bridge.subprocess_win import popen_kwargs_hide_console
|
||
|
||
|
||
class SkillsProcessManager:
|
||
"""管理 skills bridge_server 子进程与扩展连接状态。"""
|
||
|
||
def __init__(self, config: AppConfig) -> None:
|
||
self.config = config
|
||
|
||
@property
|
||
def _bridge_url(self) -> str:
|
||
return self.config.skills.bridge_ws_url
|
||
|
||
def _read_pid(self) -> int | None:
|
||
pid_file = self.config.bridge_pid_file()
|
||
if not pid_file.is_file():
|
||
return None
|
||
try:
|
||
pid = int(pid_file.read_text(encoding="utf-8").strip())
|
||
except ValueError:
|
||
return None
|
||
return pid if self._is_process_running(pid) else None
|
||
|
||
@staticmethod
|
||
def _is_process_running(pid: int) -> bool:
|
||
if pid <= 0:
|
||
return False
|
||
try:
|
||
import ctypes
|
||
|
||
handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
|
||
if handle:
|
||
ctypes.windll.kernel32.CloseHandle(handle)
|
||
return True
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
def _write_pid(self, pid: int) -> None:
|
||
pid_file = self.config.bridge_pid_file()
|
||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||
pid_file.write_text(str(pid), encoding="utf-8")
|
||
|
||
def _clear_pid(self) -> None:
|
||
pid_file = self.config.bridge_pid_file()
|
||
if pid_file.is_file():
|
||
pid_file.unlink(missing_ok=True)
|
||
|
||
def ping_bridge(self) -> dict[str, bool]:
|
||
"""查询 bridge server 与扩展连接状态。"""
|
||
try:
|
||
with ws_client.connect(self._bridge_url, max_size=32 * 1024 * 1024, open_timeout=3) as ws:
|
||
ws.send(json.dumps({"role": "cli", "method": "ping_server"}))
|
||
raw = ws.recv(timeout=5)
|
||
data = json.loads(raw)
|
||
result = data.get("result") or {}
|
||
return {
|
||
"server_running": True,
|
||
"extension_connected": bool(result.get("extension_connected")),
|
||
}
|
||
except Exception:
|
||
return {"server_running": False, "extension_connected": False}
|
||
|
||
def is_engine_healthy(self) -> bool:
|
||
ping = self.ping_bridge()
|
||
return ping["server_running"] and ping["extension_connected"]
|
||
|
||
def is_bridge_running(self) -> bool:
|
||
return self.ping_bridge()["server_running"]
|
||
|
||
def wait_engine_ready(self, timeout_sec: int | None = None) -> bool:
|
||
timeout = timeout_sec or self.config.skills.startup_timeout_sec
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
if self.is_engine_healthy():
|
||
return True
|
||
time.sleep(0.5)
|
||
return False
|
||
|
||
def start_engine(self, force: bool = False) -> int:
|
||
if not force and self.is_engine_healthy():
|
||
return self._read_pid() or 0
|
||
|
||
if force:
|
||
self.stop_engine()
|
||
|
||
port = self.config.skills.bridge_port
|
||
cmd = self.config.skills.bridge_launcher(port=port)
|
||
if len(cmd) >= 2 and cmd[0].endswith(".py") and not Path(cmd[0]).is_file():
|
||
raise ProcessError(f"Bridge 脚本不存在: {cmd[0]}")
|
||
if cmd[0].endswith(".exe") and not Path(cmd[0]).is_file():
|
||
raise ProcessError(f"Bridge 引擎不存在: {cmd[0]}")
|
||
|
||
proc = subprocess.Popen(
|
||
cmd,
|
||
cwd=str(self.config.skills.project_dir),
|
||
**popen_kwargs_hide_console(new_process_group=True),
|
||
)
|
||
self._write_pid(proc.pid)
|
||
|
||
if not self.wait_engine_ready():
|
||
raise ProcessError(
|
||
f"Skills 引擎启动超时({self.config.skills.startup_timeout_sec}s)。\n"
|
||
"请确认:\n"
|
||
"1. Chrome 已安装 XHS Bridge 扩展(extension/)\n"
|
||
"2. 扩展已启用并连接到 ws://localhost:{port}"
|
||
)
|
||
return proc.pid
|
||
|
||
def stop_engine(self) -> None:
|
||
pid = self._read_pid()
|
||
if pid:
|
||
subprocess.run(
|
||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||
check=False,
|
||
capture_output=True,
|
||
)
|
||
self._clear_pid()
|
||
|
||
def open_chrome(self) -> None:
|
||
"""尝试启动 Chrome(扩展未连接时)。"""
|
||
import os
|
||
|
||
candidates = [
|
||
Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
||
Path(r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"),
|
||
Path(os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe")),
|
||
]
|
||
for path in candidates:
|
||
if path.is_file():
|
||
subprocess.Popen([str(path)])
|
||
return
|