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