front/xhs-agent-desktop/app/main_window.py
2026-07-02 15:52:09 +08:00

398 lines
15 KiB
Python
Raw Permalink 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.

"""OMS 小红书 Agent 桌面主窗口。"""
from __future__ import annotations
import logging
import sys
import webbrowser
from datetime import datetime
from PyQt6.QtCore import QTimer, pyqtSignal
from PyQt6.QtGui import QCloseEvent, QFont
from PyQt6.QtWidgets import (
QApplication,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from app.agent_worker import AgentWorker
from app.config_store import AgentSettings
from app.engine_path import ensure_runtime_importable, extension_dir
from app.pair_server import DEFAULT_PAIR_PORT, PairPayload, PairServer
logger = logging.getLogger(__name__)
PAIR_TIMEOUT_SEC = 120
class MainWindow(QMainWindow):
pair_received = pyqtSignal(object)
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("OMS 小红书 Agent")
self.setMinimumSize(640, 520)
self.resize(720, 580)
self._settings = AgentSettings.load()
self._worker: AgentWorker | None = None
self._bridge = None
self._pair_server: PairServer | None = None
self._pair_timer: QTimer | None = None
root = QWidget()
self.setCentralWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
layout.addWidget(self._build_intro())
layout.addWidget(self._build_connection_group())
layout.addWidget(self._build_local_group())
layout.addWidget(self._build_status_group())
layout.addWidget(self._build_log_group(), 1)
self.pair_received.connect(self._on_pair_received)
self._apply_settings_to_form()
self._try_init_bridge()
def _build_intro(self) -> QLabel:
lbl = QLabel(
"打开本程序并点击「连接 OMS」即可。"
"请先在浏览器登录 OMS;若已打开「启动服务」页面,将自动完成授权,无需填写 API 或 Token。"
)
lbl.setWordWrap(True)
lbl.setStyleSheet("color:#64748b;font-size:12px;")
return lbl
def _build_connection_group(self) -> QGroupBox:
box = QGroupBox("OMS 连接")
form = QFormLayout(box)
form.setSpacing(8)
self.input_device_name = QLineEdit()
self.input_device_name.setPlaceholderText("如:办公室电脑")
form.addRow("设备名称", self.input_device_name)
self.input_bridge_port = QSpinBox()
self.input_bridge_port.setRange(1024, 65535)
self.input_bridge_port.setValue(9333)
form.addRow("Bridge 端口", self.input_bridge_port)
self.lbl_extension_path = QLabel(extension_dir().as_posix())
self.lbl_extension_path.setWordWrap(True)
self.lbl_extension_path.setStyleSheet("color:#475569;font-size:11px;")
form.addRow("Chrome 扩展", self.lbl_extension_path)
row = QHBoxLayout()
self.btn_save = QPushButton("保存配置")
self.btn_save.clicked.connect(self._save_settings)
self.btn_connect = QPushButton("连接 OMS")
self.btn_connect.setStyleSheet(
"background:#4F46E5;color:#fff;padding:6px 16px;border-radius:4px;"
)
self.btn_connect.clicked.connect(self._toggle_agent)
self.btn_reauth = QPushButton("重新授权")
self.btn_reauth.clicked.connect(self._reauthorize)
row.addWidget(self.btn_save)
row.addWidget(self.btn_reauth)
row.addStretch(1)
row.addWidget(self.btn_connect)
form.addRow("", row)
return box
def _build_local_group(self) -> QGroupBox:
box = QGroupBox("本机引擎(也可在网页远程控制)")
row = QHBoxLayout(box)
self.btn_engine_start = QPushButton("启动引擎")
self.btn_engine_stop = QPushButton("停止引擎")
self.btn_login = QPushButton("浏览器登录")
self.btn_check = QPushButton("刷新状态")
for btn in (self.btn_engine_start, self.btn_engine_stop, self.btn_login, self.btn_check):
row.addWidget(btn)
self.btn_engine_start.clicked.connect(self._local_start_engine)
self.btn_engine_stop.clicked.connect(self._local_stop_engine)
self.btn_login.clicked.connect(self._local_browser_login)
self.btn_check.clicked.connect(self._refresh_local_status)
return box
def _build_status_group(self) -> QGroupBox:
box = QGroupBox("状态")
grid = QFormLayout(box)
mono = QFont("Consolas", 10)
self.lbl_device_id = QLabel("-")
self.lbl_device_id.setFont(mono)
self.lbl_agent_state = QLabel("未连接")
self.lbl_engine = QLabel("-")
self.lbl_extension = QLabel("-")
self.lbl_login = QLabel("-")
grid.addRow("设备 ID", self.lbl_device_id)
grid.addRow("Agent", self.lbl_agent_state)
grid.addRow("引擎", self.lbl_engine)
grid.addRow("扩展", self.lbl_extension)
grid.addRow("小红书", self.lbl_login)
return box
def _build_log_group(self) -> QGroupBox:
box = QGroupBox("运行日志")
lay = QVBoxLayout(box)
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setMaximumBlockCount(500)
lay.addWidget(self.log_view)
return box
def _apply_settings_to_form(self) -> None:
self.input_device_name.setText(self._settings.device_name)
self.input_bridge_port.setValue(self._settings.bridge_port)
def _collect_settings(self) -> AgentSettings:
return AgentSettings(
oms_url=self._settings.oms_url,
token=self._settings.token,
device_name=self.input_device_name.text().strip(),
bridge_port=self.input_bridge_port.value(),
web_origin=self._settings.web_origin,
)
def _save_settings(self) -> None:
self._settings = self._collect_settings()
self._settings.save()
self._append_log("配置已保存")
def _try_init_bridge(self) -> None:
try:
ensure_runtime_importable()
from xhs_bridge.service import XhsBridgeService
self._bridge = XhsBridgeService()
self.lbl_device_id.setText(self._bridge.license.device_id)
self._refresh_local_status()
self._append_log("内置运行时已就绪")
except Exception as exc:
self._append_log(f"初始化失败: {exc}")
QMessageBox.warning(self, "运行时错误", str(exc))
def _toggle_agent(self) -> None:
if self._worker and self._worker.isRunning():
self._append_log("正在断开…")
self._worker.request_stop()
self.btn_connect.setEnabled(False)
return
self._settings = self._collect_settings()
if not self._settings.token:
self._start_pairing()
return
self._connect_with_settings()
def _connect_with_settings(self) -> None:
self._settings = self._collect_settings()
self._settings.save()
self._stop_pairing()
self._worker = AgentWorker(self._settings)
self._worker.log_line.connect(self._append_log)
self._worker.status_changed.connect(self._on_agent_status)
self._worker.started_ok.connect(self._on_agent_started)
self._worker.failed.connect(self._on_agent_failed)
self._worker.stopped.connect(self._on_agent_stopped)
self._worker.start()
self.btn_connect.setText("断开连接")
self.lbl_agent_state.setText("连接中…")
def _reauthorize(self) -> None:
if self._worker and self._worker.isRunning():
QMessageBox.warning(self, "提示", "请先断开连接再重新授权")
return
self._settings.token = ""
self._settings.save()
self._append_log("已清除授权,等待重新配对…")
self._start_pairing()
def _start_pairing(self) -> None:
if self._pair_server and self._pair_server.is_waiting:
self._append_log("已在等待浏览器授权…")
return
self._stop_pairing()
self._pair_server = PairServer(DEFAULT_PAIR_PORT)
def on_pair(payload: PairPayload) -> None:
self.pair_received.emit(payload)
self._pair_server.start(on_pair)
self.lbl_agent_state.setText("等待浏览器授权…")
self._append_log("等待 OMS 网页授权(请在浏览器登录 OMS)…")
self._open_pair_pages()
self._pair_timer = QTimer(self)
self._pair_timer.setSingleShot(True)
self._pair_timer.timeout.connect(self._on_pair_timeout)
self._pair_timer.start(PAIR_TIMEOUT_SEC * 1000)
def _open_pair_pages(self) -> None:
port = DEFAULT_PAIR_PORT
origins: list[str] = []
if self._settings.web_origin:
origins.append(self._settings.web_origin.rstrip("/"))
origins.extend(
[
"http://localhost:5173",
"http://127.0.0.1:5173",
]
)
seen: set[str] = set()
opened = False
for origin in origins:
if origin in seen:
continue
seen.add(origin)
url = f"{origin}/xhs/agentPair?port={port}"
try:
webbrowser.open(url)
opened = True
self._append_log(f"已打开授权页:{url}")
break
except Exception as exc:
logger.warning("打开浏览器失败 %s: %s", url, exc)
if not opened:
self._append_log(
"未能自动打开浏览器;请手动访问 OMS「小红书 → Agent 授权」或保持「启动服务」页打开"
)
def _on_pair_received(self, payload: object) -> None:
if not isinstance(payload, PairPayload):
return
self._settings.token = payload.token
self._settings.oms_url = payload.oms_url.rstrip("/")
if payload.web_origin:
self._settings.web_origin = payload.web_origin.rstrip("/")
self._settings.save()
self._append_log("已从浏览器收到授权,正在连接 OMS…")
self._connect_with_settings()
def _on_pair_timeout(self) -> None:
if self._pair_server and self._pair_server.is_waiting:
self._stop_pairing()
self.lbl_agent_state.setText("授权超时")
self._append_log("等待授权超时,请重试或保持「启动服务」页打开后再点连接")
QMessageBox.information(
self,
"授权超时",
"未收到浏览器授权。\n\n请确认:\n1. 浏览器已登录 OMS\n"
"2. 打开「小红书 → 启动服务」页面,或允许自动打开的授权页\n"
"3. 再次点击「连接 OMS」",
)
def _stop_pairing(self) -> None:
if self._pair_timer:
self._pair_timer.stop()
self._pair_timer = None
if self._pair_server:
self._pair_server.stop()
self._pair_server = None
def _on_agent_started(self, info: dict) -> None:
self.lbl_device_id.setText(str(info.get("device_id", "-")))
self.lbl_agent_state.setText("已连接 · 等待网页下发任务")
self._append_log("Agent 主循环已启动,请在网页「启动服务」查看在线状态")
def _on_agent_status(self, st: dict) -> None:
self.lbl_engine.setText("运行中" if st.get("engine_running") else "未启动")
self.lbl_extension.setText("已连接" if st.get("extension_connected") else "未连接")
self.lbl_login.setText("已登录" if st.get("xhs_logged_in") else "未登录")
def _on_agent_failed(self, msg: str) -> None:
self._append_log(f"错误: {msg}")
QMessageBox.critical(self, "Agent 异常", msg)
self._on_agent_stopped()
def _on_agent_stopped(self) -> None:
self.btn_connect.setEnabled(True)
self.btn_connect.setText("连接 OMS")
self.lbl_agent_state.setText("未连接")
self._worker = None
def _local_start_engine(self) -> None:
if not self._bridge:
return
try:
pid = self._bridge.start_engine()
self._append_log(f"引擎已启动 pid={pid}")
self._refresh_local_status()
except Exception as exc:
QMessageBox.warning(self, "启动失败", str(exc))
def _local_stop_engine(self) -> None:
if not self._bridge:
return
try:
self._bridge.stop_engine()
self._append_log("引擎已停止")
self._refresh_local_status()
except Exception as exc:
QMessageBox.warning(self, "停止失败", str(exc))
def _local_browser_login(self) -> None:
if not self._bridge:
return
try:
self._bridge.ensure_engine_running()
result = self._bridge.browser_login()
self._append_log(str(result.get("message") or result))
self._refresh_local_status()
except Exception as exc:
QMessageBox.warning(self, "登录", str(exc))
def _refresh_local_status(self) -> None:
if not self._bridge:
return
try:
st = self._bridge.status()
self.lbl_engine.setText("运行中" if st.get("bridge_running") else "未启动")
self.lbl_extension.setText("已连接" if st.get("extension_connected") else "未连接")
self.lbl_login.setText("已登录" if st.get("login_ok") else "未登录")
except Exception as exc:
self._append_log(f"状态刷新失败: {exc}")
def _append_log(self, text: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
self.log_view.appendPlainText(f"[{ts}] {text}")
def closeEvent(self, event: QCloseEvent) -> None:
self._stop_pairing()
if self._worker and self._worker.isRunning():
box = QMessageBox.question(
self,
"退出",
"Agent 正在运行,确定退出?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if box != QMessageBox.StandardButton.Yes:
event.ignore()
return
self._worker.request_stop()
self._worker.wait(3000)
event.accept()
def run_app() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
app = QApplication(sys.argv)
app.setApplicationName("OMS XHS Agent")
win = MainWindow()
win.show()
return app.exec()