# -*- coding: utf-8 -*- """PySide6 desktop shell for the WeCom customer-service assistant.""" from __future__ import annotations import json import os import queue import signal import sys import threading import time from pathlib import Path from PySide6.QtCore import ( QEasingCurve, QEvent, QObject, QPoint, QPointF, QPropertyAnimation, QRect, QSize, QTimer, Qt, QUrl, Signal, ) from PySide6.QtGui import ( QColor, QDesktopServices, QFont, QFontMetrics, QIcon, QPainter, QPen, QPixmap, ) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QDialog, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect, QGraphicsOpacityEffect, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox, QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, ) from PySide6.QtWebEngineWidgets import QWebEngineView from app_version import APP_VERSION, release_status from runtime_paths import application_data_dir, resource_path # 直接用 GUI 无关的共享运行时;过去 import wechat_gui 会连带把整个 # tkinter/tcl 拖进 Qt 进程,冷启动白付一笔加载费。 from gui_runtime import ( BotThread, LogQueue, delete_pending_reply_file, normalize_message_batch_window_seconds, ) from gui_runtime import ( MESSAGE_BATCH_WINDOW_SECONDS, MESSAGE_BATCH_WINDOW_MAX_SECONDS as _BATCH_MAX, MESSAGE_BATCH_WINDOW_MIN_SECONDS as _BATCH_MIN, ) MESSAGE_BATCH_WINDOW_MIN_SECONDS = int(_BATCH_MIN) MESSAGE_BATCH_WINDOW_MAX_SECONDS = int(_BATCH_MAX) SCRIPT_DIR = application_data_dir() APP_SETTINGS_FILE = SCRIPT_DIR / "app_settings.json" WIN_TITLE = f"甄养堂 · 企微客服助手 v{APP_VERSION}" APP_ICON_PATH = resource_path("assets", "brand", "zhenyangtang-icon.png") WECOM_WAITING_MESSAGE = ( "企业微信主界面未显示、未在前台或已最小化,正在尝试切到前台;" "本轮失败时下一轮将自动重试。" ) COLORS = { "canvas": "#F4F7F5", "surface": "#FFFFFF", "surface_alt": "#F8FAF9", "sidebar": "#10271F", "sidebar_alt": "#17372C", "ink": "#17251F", "muted": "#617269", "faint": "#8A9991", "line": "#DDE7E1", "line_soft": "#EAF0ED", "accent": "#10A57A", "accent_dark": "#087A59", "accent_soft": "#DDF5EC", "success": "#118764", "warning": "#B67820", "danger": "#D85260", "danger_soft": "#FBEAEC", } APP_QSS = """ * { font-family: "Microsoft YaHei UI", "Segoe UI"; font-size: 14px; color: #17251F; } QMainWindow, QWidget#AppRoot, QWidget#PageRoot, QScrollArea#PageScroll, QScrollArea#PageScroll > QWidget > QWidget { background: #F4F7F5; } QDialog#SessionDetailDialog { background: #F4F7F5; } QFrame#Sidebar { background: #FCFDFC; border: none; border-right: 1px solid #DDE7E1; } QFrame#Sidebar QLabel { color: #42574D; } QFrame#Sidebar QLabel#Brand { color: #087A59; font-family: "Cascadia Mono", "Microsoft YaHei UI"; font-size: 22px; font-weight: 700; letter-spacing: 1px; } QFrame#Sidebar QLabel#BrandSub { color: #6C7D74; font-size: 13px; } QFrame#Sidebar QLabel#SectionLabel { color: #8A9991; font-family: "Cascadia Mono"; font-size: 11px; font-weight: 600; letter-spacing: 1px; } QFrame#SideStatus { background: #EEF8F3; border: 1px solid #CFE9DE; border-radius: 12px; } QFrame#Sidebar QLabel#SideStatusTitle { color: #17372C; font-size: 15px; font-weight: 700; } QFrame#Sidebar QLabel#SideStatusHint { color: #6C7D74; font-size: 12px; } QPushButton#NavButton { background: transparent; color: #5D7067; border: none; border-left: 3px solid transparent; border-radius: 0px; text-align: left; padding: 13px 16px; font-size: 15px; } QPushButton#NavButton:hover { background: #F0F6F3; color: #17372C; } QPushButton#NavButton:checked { background: #DDF5EC; color: #087A59; border-left-color: #10A57A; font-weight: 700; } QFrame#DeskRail { background: #FCFDFC; border: none; border-right: 1px solid #DDE7E1; } QListWidget#DeskSessionList { background: transparent; border: none; outline: none; } QListWidget#DeskSessionList::item { background: transparent; border: none; padding: 0px; margin: 3px 8px; } QLabel#DeskSessionTitle { color: #17372C; font-size: 14px; font-weight: 700; } QLabel#DeskSessionPreview { color: #8A9991; font-size: 12px; } QFrame#DeskSessionCard { background: transparent; border-radius: 12px; padding: 2px; } QListWidget#DeskSessionList::item:selected QFrame#DeskSessionCard { background: #DDF5EC; } QPlainTextEdit#DeskComposer { min-height: 84px; } QPushButton#PrimaryButton, QPushButton#SidePrimary { background: #10A57A; color: #FFFFFF; border: none; border-radius: 10px; padding: 11px 20px; font-weight: 700; } QPushButton#PrimaryButton:hover, QPushButton#SidePrimary:hover { background: #087A59; } QPushButton#PrimaryButton:pressed, QPushButton#SidePrimary:pressed { background: #06684B; } QPushButton#PrimaryButton:disabled, QPushButton#SidePrimary:disabled { background: #C8D7D0; color: #F7F9F8; } QPushButton#SecondaryButton { background: #FFFFFF; color: #42574D; border: 1px solid #D7E2DC; border-radius: 10px; padding: 10px 18px; } QPushButton#SecondaryButton:hover { background: #F0F6F3; border-color: #BFD3C9; } QPushButton#DangerButton { background: #FBEAEC; color: #C74452; border: 1px solid #F2CDD2; border-radius: 10px; padding: 10px 18px; font-weight: 600; } QPushButton#DangerButton:hover { background: #D85260; color: #FFFFFF; } QFrame#Card, QFrame#HeroCard, QFrame#MetricCard { background: #FFFFFF; border: 1px solid #DDE7E1; border-radius: 16px; } QFrame#CustomerBubble { background: #FFFFFF; border: 1px solid #DDE7E1; border-radius: 13px; } QFrame#AssistantBubble { background: #DDF5EC; border: 1px solid #BFE8D8; border-radius: 13px; } QFrame#SystemBubble { background: #EEF2F0; border: 1px solid #DDE7E1; border-radius: 11px; } QLabel#MessageRole { color: #087A59; font-size: 12px; font-weight: 700; } QLabel#MessageTime { color: #91A097; font-size: 11px; } QLabel#MessageContent { color: #17251F; font-size: 14px; } QFrame#HeroCard { background: #FBFDFC; } QLabel#Eyebrow { color: #0B8B67; font-family: "Cascadia Mono"; font-size: 12px; font-weight: 700; letter-spacing: 1px; } QLabel#PageTitle { color: #17251F; font-size: 28px; font-weight: 700; } QLabel#PageSubtitle { color: #687A71; font-size: 14px; } QLabel#CardTitle { color: #17251F; font-size: 17px; font-weight: 700; } QLabel#CardSubtitle { color: #6E7F76; font-size: 13px; } QLabel#MetricValue { color: #17251F; font-family: "Cascadia Mono"; font-size: 30px; font-weight: 700; } QLabel#MetricLabel { color: #6C7D74; font-size: 13px; } QLabel#MetricMeta { color: #96A39C; font-size: 12px; } QLabel#HeroStatus { color: #17251F; font-size: 30px; font-weight: 700; } QLabel#HeroHint { color: #687A71; font-size: 14px; } QLabel#SuccessText { color: #118764; font-weight: 600; } QLabel#WarningText { color: #B67820; font-weight: 600; } QLabel#DangerText { color: #D85260; font-weight: 600; } QLineEdit, QPlainTextEdit, QTextEdit, QSpinBox, QDoubleSpinBox { background: #FBFCFB; color: #17251F; border: 1px solid #D7E2DC; border-radius: 9px; padding: 9px 11px; selection-background-color: #BDEBDC; } QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus { background: #FFFFFF; border: 2px solid #10A57A; padding: 8px 10px; } QLineEdit[readOnly="true"] { background: #F2F5F3; color: #798880; } QCheckBox { spacing: 10px; font-size: 14px; } QCheckBox::indicator { width: 38px; height: 21px; border-radius: 10px; background: #C9D6D0; } QCheckBox::indicator:checked { background: #10A57A; } QTableWidget { background: #FFFFFF; alternate-background-color: #F8FAF9; border: 1px solid #DDE7E1; border-radius: 12px; gridline-color: #EAF0ED; selection-background-color: #DDF5EC; selection-color: #17251F; } QHeaderView::section { background: #F2F6F4; color: #607168; border: none; border-bottom: 1px solid #DDE7E1; padding: 10px; font-weight: 600; } QScrollBar:vertical { width: 10px; background: transparent; margin: 3px; } QScrollBar::handle:vertical { background: #C7D5CE; border-radius: 4px; min-height: 30px; } QScrollBar::handle:vertical:hover { background: #9EB5AA; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } QProgressBar { background: transparent; border: none; max-height: 3px; } QProgressBar::chunk { background: #10A57A; } QToolTip { background: #17372C; color: #FFFFFF; border: none; padding: 7px; } """ def _button(text: str, kind: str = "secondary") -> QPushButton: button = QPushButton(text) names = { "primary": "PrimaryButton", "danger": "DangerButton", "side": "SidePrimary", } button.setObjectName(names.get(kind, "SecondaryButton")) button.setCursor(Qt.PointingHandCursor) return button def _shadow(widget: QWidget, blur: int = 28, y: int = 8, alpha: int = 24) -> None: effect = QGraphicsDropShadowEffect(widget) effect.setBlurRadius(blur) effect.setOffset(0, y) effect.setColor(QColor(45, 52, 49, alpha)) widget.setGraphicsEffect(effect) def _label(text: str, name: str = "") -> QLabel: item = QLabel(text) if name: item.setObjectName(name) item.setWordWrap(True) return item def _page_header(index: str, title: str, subtitle: str) -> QVBoxLayout: layout = QVBoxLayout() layout.setSpacing(5) layout.addWidget(_label(f"{index} CONTROL CENTER", "Eyebrow")) layout.addWidget(_label(title, "PageTitle")) layout.addWidget(_label(subtitle, "PageSubtitle")) return layout def _card(title: str = "", subtitle: str = "") -> tuple[QFrame, QVBoxLayout]: frame = QFrame() frame.setObjectName("Card") layout = QVBoxLayout(frame) layout.setContentsMargins(22, 20, 22, 20) layout.setSpacing(11) if title: layout.addWidget(_label(title, "CardTitle")) if subtitle: layout.addWidget(_label(subtitle, "CardSubtitle")) return frame, layout class FadingStack(QStackedWidget): def setCurrentIndexAnimated(self, index: int) -> None: if index == self.currentIndex(): return self.setCurrentIndex(index) page = self.currentWidget() effect = QGraphicsOpacityEffect(page) page.setGraphicsEffect(effect) animation = QPropertyAnimation(effect, b"opacity", page) animation.setDuration(220) animation.setStartValue(0.25) animation.setEndValue(1.0) animation.setEasingCurve(QEasingCurve.OutCubic) animation.finished.connect(lambda: page.setGraphicsEffect(None)) page._fade_animation = animation animation.start() class _DeskBridge(QObject): finished = Signal(str, object) class AgentDeskPage(QWidget): """本地 DeepSeek Harness 客服工作台,取代原先嵌入的第三方网页。""" def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setObjectName("PageRoot") self.view = None self._session_id = "" self._busy = False self._bridge: _DeskBridge | None = None layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) rail = QFrame() rail.setObjectName("DeskRail") rail.setFixedWidth(272) rail_layout = QVBoxLayout(rail) rail_layout.setContentsMargins(16, 22, 16, 18) rail_layout.setSpacing(10) rail_layout.addWidget(_label("ZHEN AI", "Eyebrow")) rail_layout.addWidget(_label("客服工作台", "CardTitle")) rail_layout.addWidget( _label("同一输入框里起草回复、整理话术;会话按 SDK 的 session id 延续。", "CardSubtitle") ) self.new_chat_button = _button("+ 开始新对话", "primary") self.new_chat_button.clicked.connect(self._new_session) rail_layout.addWidget(self.new_chat_button) rail_layout.addWidget(_label("最近对话", "SectionLabel")) self.session_list = QListWidget() self.session_list.setObjectName("DeskSessionList") self.session_list.setSpacing(2) self.session_list.currentItemChanged.connect(self._session_changed) rail_layout.addWidget(self.session_list, 1) self.backend_label = _label("", "CardSubtitle") rail_layout.addWidget(self.backend_label) layout.addWidget(rail) stage = QWidget() stage_layout = QVBoxLayout(stage) stage_layout.setContentsMargins(28, 22, 28, 22) stage_layout.setSpacing(12) stage_layout.addLayout( _page_header( "01", "AI 客服工作台", "打开即可对话,走本机已配置的 DeepSeek 接口,不远程下载。同一会话会保留上下文。", ) ) self.chat_scroll = QScrollArea() self.chat_scroll.setWidgetResizable(True) self.chat_scroll.setFrameShape(QFrame.NoFrame) self.chat_scroll.setStyleSheet( "QScrollArea{background:#EEF3F0;border:1px solid #DDE7E1;border-radius:16px;}" "QScrollArea>QWidget>QWidget{background:#EEF3F0;}" ) self.chat_root = QWidget() self.chat_layout = QVBoxLayout(self.chat_root) self.chat_layout.setContentsMargins(20, 20, 20, 20) self.chat_layout.setSpacing(13) self.chat_scroll.setWidget(self.chat_root) stage_layout.addWidget(self.chat_scroll, 1) composer_row = QHBoxLayout() composer_row.setSpacing(10) self.composer = QPlainTextEdit() self.composer.setObjectName("DeskComposer") self.composer.setPlaceholderText("描述你要完成的任务,Agent 会按当前客服人格执行") self.composer.installEventFilter(self) composer_row.addWidget(self.composer, 1) send_col = QVBoxLayout() send_col.addStretch(1) self.send_button = _button("发送", "primary") self.send_button.clicked.connect(self._send) send_col.addWidget(self.send_button) composer_row.addLayout(send_col) stage_layout.addLayout(composer_row) self.status_label = _label("Enter 发送,Shift+Enter 换行。", "CardSubtitle") stage_layout.addWidget(self.status_label) layout.addWidget(stage, 1) self.ensure_view() def eventFilter(self, watched, event) -> bool: if watched is self.composer and event.type() == QEvent.Type.KeyPress: if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not ( event.modifiers() & Qt.ShiftModifier ): self._send() return True return super().eventFilter(watched, event) def ensure_view(self) -> None: from dsh_agent import get_desk desk = get_desk() self.backend_label.setText(desk.backend_label()) sessions = desk.list_sessions() if not sessions: sessions = [desk.create_session()] current_id = desk.current_session_id() self._reload_session_list(sessions, current_id) self._open_session(current_id) def focus_view(self) -> None: self.composer.setFocus(Qt.OtherFocusReason) def reload(self) -> None: self.ensure_view() def open_external(self) -> None: QDesktopServices.openUrl( QUrl("https://deepseek-harness.github.io/deepseek-harness/guide/python-sdk") ) def _reload_session_list(self, sessions, current_id: str) -> None: self.session_list.blockSignals(True) self.session_list.clear() current_row = 0 for index, session in enumerate(sessions): item = QListWidgetItem() item.setData(Qt.UserRole, session.id) item.setSizeHint(QSize(240, 58)) card = QFrame() card.setObjectName("DeskSessionCard") card_layout = QVBoxLayout(card) card_layout.setContentsMargins(12, 9, 12, 9) card_layout.setSpacing(3) card_layout.addWidget(_label(session.title or "新对话", "DeskSessionTitle")) preview = str(session.preview or "还没有消息").replace("\n", " ") if len(preview) > 28: preview = preview[:27] + "…" card_layout.addWidget(_label(preview, "DeskSessionPreview")) self.session_list.addItem(item) self.session_list.setItemWidget(item, card) if session.id == current_id: current_row = index if self.session_list.count(): self.session_list.setCurrentRow(current_row) self.session_list.blockSignals(False) def _session_changed(self, current: QListWidgetItem | None, _previous=None) -> None: if current is None or self._busy: return session_id = str(current.data(Qt.UserRole) or "") if session_id: self._open_session(session_id) def _new_session(self) -> None: if self._busy: return from dsh_agent import get_desk session = get_desk().create_session() self.ensure_view() self._open_session(session.id) self.focus_view() def _open_session(self, session_id: str) -> None: from dsh_agent import get_desk desk = get_desk() session = desk.set_current_session(session_id) or desk.get_session(session_id) if session is None: return self._session_id = session.id self._render_messages(session.messages) def _render_messages(self, messages: list) -> None: while self.chat_layout.count(): item = self.chat_layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() if not messages: empty = QWidget() empty_layout = QVBoxLayout(empty) empty_layout.setAlignment(Qt.AlignCenter) empty_layout.addStretch(1) empty_layout.addWidget(_label("一个输入框,完成客服起草与对话", "PageTitle")) empty_layout.addWidget( _label( "复用同一个会话 id 会保留上下文。独立任务请点「开始新对话」。", "PageSubtitle", ) ) empty_layout.addStretch(1) self.chat_layout.addWidget(empty) else: for message in messages: self._add_bubble(message) self.chat_layout.addStretch(1) QTimer.singleShot(0, self._scroll_to_bottom) def _add_bubble(self, message: dict) -> None: role = str(message.get("role") or "system").lower() names = {"user": "坐席", "assistant": "Agent", "system": "系统"} bubble = QFrame() bubble.setObjectName( {"user": "CustomerBubble", "assistant": "AssistantBubble"}.get(role, "SystemBubble") ) bubble.setMaximumWidth(720) bubble_layout = QVBoxLayout(bubble) bubble_layout.setContentsMargins(15, 12, 15, 13) bubble_layout.setSpacing(6) meta = QHBoxLayout() meta.addWidget(_label(names.get(role, role), "MessageRole")) stamp = message.get("ts") if stamp: try: meta.addWidget( _label( time.strftime("%H:%M", time.localtime(float(stamp))), "MessageTime", ) ) except (TypeError, ValueError, OSError): pass meta.addStretch(1) bubble_layout.addLayout(meta) content = _label(str(message.get("content") or ""), "MessageContent") content.setTextInteractionFlags(Qt.TextSelectableByMouse) bubble_layout.addWidget(content) holder = QHBoxLayout() if role == "user": holder.addStretch(1) holder.addWidget(bubble, 0, Qt.AlignRight) else: holder.addWidget(bubble, 0, Qt.AlignLeft) holder.addStretch(1) wrap = QWidget() wrap.setLayout(holder) self.chat_layout.addWidget(wrap) def _scroll_to_bottom(self) -> None: bar = self.chat_scroll.verticalScrollBar() bar.setValue(bar.maximum()) def _send(self) -> None: if self._busy: return prompt = self.composer.toPlainText().strip() if not prompt: return from dsh_agent import get_desk desk = get_desk() session_id = self._session_id or desk.current_session_id() session = desk.get_session(session_id) pending = list(session.messages if session is not None else []) pending.append({"role": "user", "content": prompt, "ts": time.time()}) pending.append({"role": "system", "content": "Agent 正在处理当前任务…"}) self.composer.clear() self._busy = True self.send_button.setEnabled(False) self.new_chat_button.setEnabled(False) self.status_label.setText("Agent 正在处理…") self._render_messages(pending) bridge = _DeskBridge(self) self._bridge = bridge bridge.finished.connect(self._on_finished) def worker() -> None: try: result = desk.run(prompt, session_id=session_id) bridge.finished.emit(session_id, result) except Exception as exc: bridge.finished.emit(session_id, exc) threading.Thread(target=worker, daemon=True, name="dsh-desk-run").start() def _on_finished(self, session_id: str, payload) -> None: self._busy = False self.send_button.setEnabled(True) self.new_chat_button.setEnabled(True) from dsh_agent import get_desk desk = get_desk() self.backend_label.setText(desk.backend_label()) if isinstance(payload, Exception): self.status_label.setText(f"任务失败:{payload}") else: self.status_label.setText("Enter 发送,Shift+Enter 换行。") self._reload_session_list(desk.list_sessions(), session_id) self._open_session(session_id) self.focus_view() class PortalPage(AgentDeskPage): """AI 客服页:本地工作台,打开即可用,不经过 npx 远程下载。""" class MetricCard(QFrame): def __init__(self, label: str, value: str, meta: str): super().__init__() self.setObjectName("MetricCard") layout = QVBoxLayout(self) layout.setContentsMargins(19, 17, 19, 17) layout.setSpacing(6) layout.addWidget(_label(label, "MetricLabel")) self.value = _label(value, "MetricValue") layout.addWidget(self.value) layout.addWidget(_label(meta, "MetricMeta")) class DashboardPage(QScrollArea): startRequested = Signal() stopRequested = Signal() pageRequested = Signal(int) def __init__(self): super().__init__() self.setObjectName("PageScroll") self.setWidgetResizable(True) self.setFrameShape(QFrame.NoFrame) root = QWidget() root.setObjectName("PageRoot") self.setWidget(root) layout = QVBoxLayout(root) layout.setContentsMargins(28, 25, 28, 28) layout.setSpacing(18) layout.addLayout( _page_header( "02", "自动回复控制台", "监控企业微信窗口、AI 决策和客户沉淀状态。", ) ) hero = QFrame() hero.setObjectName("HeroCard") hero_layout = QHBoxLayout(hero) hero_layout.setContentsMargins(25, 22, 25, 22) hero_layout.setSpacing(20) status_box = QVBoxLayout() status_box.setSpacing(5) status_box.addWidget(_label("WECOM LIVE MONITOR", "Eyebrow")) self.status = _label("待命", "HeroStatus") status_box.addWidget(self.status) self.status_hint = _label("确认企业微信已登录后即可启动。", "HeroHint") status_box.addWidget(self.status_hint) self.timer = _label("运行时长 --:--:--", "CardSubtitle") status_box.addWidget(self.timer) hero_layout.addLayout(status_box, 1) self.hero_button = _button("开始监听", "primary") self.hero_button.setMinimumWidth(128) self.hero_button.clicked.connect(self.startRequested) hero_layout.addWidget(self.hero_button, 0, Qt.AlignVCenter) layout.addWidget(hero) metrics = QGridLayout() metrics.setSpacing(12) self.replied = MetricCard("本次已回复", "0", "自动回复数量") self.false_pos = MetricCard("识别误判", "0", "已跳过的会话") self.registration = MetricCard("待处理登记", "0", "挂号与回访线索") self.sessions = MetricCard("会话档案", "0", "已保存上下文") for column, card in enumerate( (self.replied, self.false_pos, self.registration, self.sessions) ): metrics.addWidget(card, 0, column) metrics.setColumnStretch(column, 1) layout.addLayout(metrics) workflow, workflow_layout = _card( "实际工作流", "每一步都映射到当前软件已经具备的执行能力。", ) steps = ( ("A1", "消息监听", "未读识别 → 会话提取 → 自动回复", 2), ("A2", "AI 决策", "上下文记忆 → 医疗客服人格 → MCP 工具", 4), ("A3", "业务沉淀", "挂号登记 → 会话档案 → 运行记录", 3), ) for code, title, text, page_index in steps: row = QFrame() row.setStyleSheet( "QFrame{background:#F8FAF9;border:1px solid #E4ECE8;border-radius:11px;}" ) row_layout = QHBoxLayout(row) row_layout.setContentsMargins(15, 12, 15, 12) badge = _label(code, "Eyebrow") badge.setFixedWidth(38) row_layout.addWidget(badge) copy = QVBoxLayout() copy.setSpacing(2) copy.addWidget(_label(title, "CardTitle")) copy.addWidget(_label(text, "CardSubtitle")) row_layout.addLayout(copy, 1) action = _button("打开") action.clicked.connect(lambda _checked=False, i=page_index: self.pageRequested.emit(i)) row_layout.addWidget(action) workflow_layout.addWidget(row) layout.addWidget(workflow) layout.addStretch(1) def set_status(self, state: str, text: str, hint: str) -> None: colors = { "running": COLORS["success"], "waiting": COLORS["warning"], "connecting": COLORS["warning"], "error": COLORS["danger"], "verification": COLORS["danger"], "stopping": COLORS["warning"], "stopped": COLORS["ink"], } self.status.setText(text) self.status.setStyleSheet( f"color:{colors.get(state, COLORS['ink'])};font-size:30px;font-weight:700;" ) self.status_hint.setText(hint) active = state in {"running", "waiting", "connecting", "stopping"} self.hero_button.setText("监听运行中" if active else "开始监听") self.hero_button.setEnabled(not active) class SettingsPage(QScrollArea): saved = Signal(dict) def __init__(self, settings: dict): super().__init__() self.setObjectName("PageScroll") self.setWidgetResizable(True) self.setFrameShape(QFrame.NoFrame) root = QWidget() root.setObjectName("PageRoot") self.setWidget(root) layout = QVBoxLayout(root) layout.setContentsMargins(28, 25, 28, 28) layout.setSpacing(18) layout.addLayout(_page_header( "03", "通用设置", "修改后自动保存;连续消息合并等待从下一轮回复生效。", )) runtime, runtime_layout = _card( "运行参数", "参数直接参与企业微信监听,不是静态展示。" ) runtime_grid = QGridLayout() runtime_grid.setHorizontalSpacing(18) runtime_grid.setVerticalSpacing(8) runtime_grid.addWidget(_label("固定回复(关闭 AI 时使用)", "CardSubtitle"), 0, 0, 1, 3) self.reply = QLineEdit(str(settings["auto_reply_text"])) self.reply.setPlaceholderText("请输入固定回复") runtime_grid.addWidget(self.reply, 1, 0, 1, 3) runtime_grid.addWidget(_label("轮询间隔(秒)", "CardSubtitle"), 2, 0) runtime_grid.addWidget(_label("鼠标静止等待(秒)", "CardSubtitle"), 2, 1) runtime_grid.addWidget(_label("连续消息合并等待(秒)", "CardSubtitle"), 2, 2) self.poll = QDoubleSpinBox() self.poll.setRange(0.2, 300.0) self.poll.setDecimals(1) self.poll.setValue(float(settings["poll_interval"])) self.idle_seconds = QDoubleSpinBox() self.idle_seconds.setRange(0.0, 3600.0) self.idle_seconds.setDecimals(1) self.idle_seconds.setValue(float(settings["mouse_idle_seconds"])) self.batch_window = QDoubleSpinBox() self.batch_window.setRange( float(MESSAGE_BATCH_WINDOW_MIN_SECONDS), float(MESSAGE_BATCH_WINDOW_MAX_SECONDS), ) self.batch_window.setDecimals(1) self.batch_window.setSingleStep(1.0) self.batch_window.setValue(float(settings["message_batch_window_seconds"])) self.batch_window.setToolTip( "同一客户连续发送多条消息时,等待后合并为一次回复;默认 20 秒。" ) runtime_grid.addWidget(self.poll, 3, 0) runtime_grid.addWidget(self.idle_seconds, 3, 1) runtime_grid.addWidget(self.batch_window, 3, 2) runtime_grid.setColumnStretch(0, 1) runtime_grid.setColumnStretch(1, 1) runtime_grid.setColumnStretch(2, 1) runtime_grid.addWidget( _label( "同一客户的连续消息会在该窗口内合并为一次回复(1–120 秒,默认 20 秒)。", "CardSubtitle", ), 4, 0, 1, 3, ) runtime_layout.addLayout(runtime_grid) self.save_status = _label("已加载保存配置", "SuccessText") self.save_status.setAlignment(Qt.AlignRight) runtime_layout.addWidget(self.save_status) layout.addWidget(runtime) safety, safety_layout = _card( "人机共存", "检测到用户正在操作时自动等待,避免与人工客服争抢鼠标。", ) self.mouse_idle = QCheckBox("启用鼠标空闲检测") self.mouse_idle.setChecked(bool(settings["mouse_idle_enabled"])) safety_layout.addWidget(self.mouse_idle) safety_layout.addWidget( _label( "企业微信未显示或最小化时,机器人会尝试还原到前台;本轮未就绪会暂停并自动重试。", "CardSubtitle", ) ) layout.addWidget(safety) checks, checks_layout = _card("启动前检查") for index, text in enumerate( ( "企业微信 PC 端已登录,且账号状态正常。", "软件启动时会自动获取云端 AI 配置,无需本机填写模型参数。", "双屏环境可以使用不同缩放比例,Qt 会跟随当前屏幕自动缩放。", ), start=1, ): row = QHBoxLayout() badge = _label(f"{index:02d}", "Eyebrow") badge.setFixedWidth(32) row.addWidget(badge) row.addWidget(_label(text, "CardSubtitle"), 1) checks_layout.addLayout(row) layout.addWidget(checks) layout.addStretch(1) self.save_timer = QTimer(self) self.save_timer.setSingleShot(True) self.save_timer.setInterval(500) self.save_timer.timeout.connect(self._emit_save) self.reply.textChanged.connect(self._schedule_save) self.poll.valueChanged.connect(self._schedule_save) self.idle_seconds.valueChanged.connect(self._schedule_save) self.batch_window.valueChanged.connect(self._schedule_save) self.mouse_idle.toggled.connect(self._schedule_save) def values(self) -> dict: return { "auto_reply_text": self.reply.text().strip() or "在的,您慢慢说,我这边看着呢。", "poll_interval": self.poll.value(), "mouse_idle_enabled": self.mouse_idle.isChecked(), "mouse_idle_seconds": self.idle_seconds.value(), "message_batch_window_seconds": float(self.batch_window.value()), } def _schedule_save(self, *_args) -> None: self.save_status.setText("正在保存…") self.save_status.setObjectName("WarningText") self.save_status.style().unpolish(self.save_status) self.save_status.style().polish(self.save_status) self.save_timer.start() def _emit_save(self) -> None: self.saved.emit(self.values()) def mark_saved(self, ok: bool, message: str) -> None: self.save_status.setText(message) self.save_status.setObjectName("SuccessText" if ok else "DangerText") self.save_status.style().unpolish(self.save_status) self.save_status.style().polish(self.save_status) class SessionDetailDialog(QDialog): def __init__( self, session_id: str, history: list[dict], last_lines: list[str], parent: QWidget | None = None, ): super().__init__(parent) self.setObjectName("SessionDetailDialog") self.setWindowTitle("客户会话记录") self.setModal(True) self.resize(920, 720) self.setMinimumSize(700, 520) self.session_id = session_id self.history = history self.last_lines = last_lines layout = QVBoxLayout(self) layout.setContentsMargins(24, 22, 24, 20) layout.setSpacing(16) header = QHBoxLayout() title_box = QVBoxLayout() title_box.setSpacing(4) title_box.addWidget(_label("客户会话记录", "PageTitle")) title_box.addWidget( _label( f"会话 ID {session_id} · 共 {len(history)} 条消息", "PageSubtitle", ) ) header.addLayout(title_box, 1) copy_button = _button("复制全部记录") copy_button.clicked.connect(self._copy_all) header.addWidget(copy_button, 0, Qt.AlignBottom) layout.addLayout(header) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) scroll.setStyleSheet( "QScrollArea{background:#EEF3F0;border:1px solid #DDE7E1;border-radius:15px;}" "QScrollArea>QWidget>QWidget{background:#EEF3F0;}" ) message_root = QWidget() message_layout = QVBoxLayout(message_root) message_layout.setContentsMargins(20, 20, 20, 20) message_layout.setSpacing(13) scroll.setWidget(message_root) if history: for message in history: self._add_message(message_layout, message) elif last_lines: self._add_message( message_layout, { "role": "system", "content": "仅保留了最近画面快照:\n\n" + "\n".join(last_lines), "ts": None, }, ) else: empty = _label("这份会话档案暂时没有可显示的消息。", "PageSubtitle") empty.setAlignment(Qt.AlignCenter) message_layout.addWidget(empty) message_layout.addStretch(1) layout.addWidget(scroll, 1) footer = QHBoxLayout() footer.addWidget(_label("按时间顺序展示已保存的客户与客服消息。", "CardSubtitle"), 1) close_button = _button("关闭") close_button.clicked.connect(self.accept) footer.addWidget(close_button) layout.addLayout(footer) QTimer.singleShot(0, lambda: scroll.verticalScrollBar().setValue(0)) @staticmethod def _message_time(value) -> str: try: return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(value))) except (TypeError, ValueError, OSError): return "" def _add_message(self, parent: QVBoxLayout, message: dict) -> None: role = str(message.get("role") or "system").lower() role_name = {"user": "客户", "assistant": "客服", "system": "系统记录"}.get( role, role ) bubble_name = { "user": "CustomerBubble", "assistant": "AssistantBubble", }.get(role, "SystemBubble") bubble = QFrame() bubble.setObjectName(bubble_name) bubble.setMaximumWidth(690) bubble_layout = QVBoxLayout(bubble) bubble_layout.setContentsMargins(15, 12, 15, 13) bubble_layout.setSpacing(6) meta = QHBoxLayout() meta.setSpacing(10) meta.addWidget(_label(role_name, "MessageRole")) stamp = self._message_time(message.get("ts")) if stamp: meta.addWidget(_label(stamp, "MessageTime")) meta.addStretch(1) bubble_layout.addLayout(meta) content = _label(str(message.get("content") or ""), "MessageContent") content.setTextInteractionFlags(Qt.TextSelectableByMouse) content.setMinimumWidth(260) bubble_layout.addWidget(content) self._add_snapshot(bubble_layout, message) row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) if role == "assistant": row.addStretch(1) row.addWidget(bubble) elif role == "system": row.addStretch(1) row.addWidget(bubble) row.addStretch(1) else: row.addWidget(bubble) row.addStretch(1) parent.addLayout(row) # 气泡最宽 690,留出左右内边距后图片可用的宽度 _SNAPSHOT_MAX_WIDTH = 640 _SNAPSHOT_MAX_HEIGHT = 420 @staticmethod def _snapshot_path(message: dict) -> str: """把档案里的文件名还原成 media 目录下的绝对路径。 存文件名而不是绝对路径,是为了档案换台机器打开也还能对上。 """ name = str(message.get("image") or "").strip() if not name or os.path.basename(name) != name: return "" path = os.path.join(str(SCRIPT_DIR), "media", name) return path if os.path.exists(path) else "" def _add_snapshot(self, bubble_layout: QVBoxLayout, message: dict) -> None: """客户发来图片/表情时,把当时的聊天画面贴在这条消息下面。 企微的图片没有可复制的文本,档案里只能写「(客户发来图片)」这样的占位; 光看这行字事后完全不知道客户发了什么。留存的是那一刻的整块聊天画面, 不是抠出来的单张图,所以标注清楚免得看的人误会。 """ path = self._snapshot_path(message) if not path: return pixmap = QPixmap(path) if pixmap.isNull(): return if ( pixmap.width() > self._SNAPSHOT_MAX_WIDTH or pixmap.height() > self._SNAPSHOT_MAX_HEIGHT ): pixmap = pixmap.scaled( self._SNAPSHOT_MAX_WIDTH, self._SNAPSHOT_MAX_HEIGHT, Qt.KeepAspectRatio, Qt.SmoothTransformation, ) caption = _label("客户发来消息时的聊天画面", "MessageTime") bubble_layout.addWidget(caption) view = QLabel() view.setPixmap(pixmap) view.setStyleSheet("border:1px solid #DDE7E1;border-radius:8px;") view.setCursor(Qt.PointingHandCursor) view.setToolTip("点击用系统看图工具打开原图") view.mouseReleaseEvent = lambda _event, target=path: QDesktopServices.openUrl( QUrl.fromLocalFile(target) ) bubble_layout.addWidget(view) def _copy_all(self) -> None: lines = [f"客户会话记录\n会话 ID:{self.session_id}\n"] role_names = {"user": "客户", "assistant": "客服", "system": "系统记录"} if self.history: for message in self.history: role = str(message.get("role") or "system").lower() name = role_names.get(role, role) stamp = self._message_time(message.get("ts")) heading = f"[{stamp}] {name}" if stamp else name body = str(message.get("content") or "") snapshot = self._snapshot_path(message) if snapshot: body = f"{body}\n[聊天画面] {snapshot}" lines.append(f"{heading}\n{body}") elif self.last_lines: lines.append("系统记录\n" + "\n".join(self.last_lines)) QApplication.clipboard().setText("\n\n".join(lines)) class BusinessPage(QScrollArea): logMessage = Signal(str, str) def __init__(self): super().__init__() self.setObjectName("PageScroll") self.setWidgetResizable(True) self.setFrameShape(QFrame.NoFrame) root = QWidget() root.setObjectName("PageRoot") self.setWidget(root) layout = QVBoxLayout(root) layout.setContentsMargins(28, 25, 28, 28) layout.setSpacing(18) header = QHBoxLayout() header.addLayout(_page_header("04", "业务数据", "管理挂号线索与客户会话档案。"), 1) refresh = _button("刷新数据") refresh.clicked.connect(self.refresh_data) header.addWidget(refresh, 0, Qt.AlignBottom) layout.addLayout(header) summary = QGridLayout() summary.setSpacing(12) self.registration_metric = MetricCard("待处理登记", "0", "需要人工跟进") self.session_metric = MetricCard("会话档案", "0", "长期上下文记录") summary.addWidget(self.registration_metric, 0, 0) summary.addWidget(self.session_metric, 0, 1) summary.setColumnStretch(0, 1) summary.setColumnStretch(1, 1) layout.addLayout(summary) registrations, registrations_layout = _card( "挂号与回访登记", "AI 识别出的明确挂号意向会沉淀在这里。" ) self.registration_table = self._table( ["最近更新", "微信客户", "症状 / 诉求", "状态"] ) registrations_layout.addWidget(self.registration_table) registration_actions = QHBoxLayout() registration_actions.addStretch(1) done = _button("标记已联系", "primary") done.clicked.connect(self._mark_registration_done) delete = _button("删除所选", "danger") delete.clicked.connect(self._delete_registrations) registration_actions.addWidget(done) registration_actions.addWidget(delete) registrations_layout.addLayout(registration_actions) layout.addWidget(registrations) sessions, sessions_layout = _card( "客户会话档案", "机器人重启后仍可继续使用已保存的上下文;双击任意一行查看完整记录。", ) self.session_table = self._table(["最近更新", "会话 ID", "消息数", "内容预览"]) self.session_table.setToolTip("双击任意会话查看完整记录") self.session_table.cellDoubleClicked.connect(self._open_session_detail) sessions_layout.addWidget(self.session_table) session_actions = QHBoxLayout() session_actions.addStretch(1) view_session = _button("查看记录", "primary") view_session.clicked.connect(self._open_selected_session) delete_session = _button("删除所选", "danger") delete_session.clicked.connect(self._delete_sessions) session_actions.addWidget(view_session) session_actions.addWidget(delete_session) sessions_layout.addLayout(session_actions) layout.addWidget(sessions) layout.addStretch(1) self.refresh_data() @staticmethod def _table(headers: list[str]) -> QTableWidget: table = QTableWidget(0, len(headers)) table.setHorizontalHeaderLabels(headers) table.setSelectionBehavior(QAbstractItemView.SelectRows) table.setSelectionMode(QAbstractItemView.ExtendedSelection) table.setEditTriggers(QAbstractItemView.NoEditTriggers) table.setAlternatingRowColors(True) table.verticalHeader().hide() table.verticalHeader().setDefaultSectionSize(42) table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) table.horizontalHeader().setStretchLastSection(True) table.setMinimumHeight(230) return table @staticmethod def _time_text(value) -> str: try: return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(value))) except (TypeError, ValueError, OSError): return "--" @staticmethod def _selected_ids(table: QTableWidget) -> list[str]: rows = sorted({index.row() for index in table.selectionModel().selectedRows()}) ids = [] for row in rows: item = table.item(row, 0) if item: ids.append(str(item.data(Qt.UserRole) or "")) return [item for item in ids if item] def refresh_data(self) -> None: try: from registration_store import RegistrationStore self.registration_store = RegistrationStore() leads = self.registration_store.list_leads(include_done=True) except Exception as exc: leads = [] self.logMessage.emit(f"读取挂号登记失败:{exc}", "err") status_map = {"pending_symptom": "待补症状", "booked": "待联系", "done": "已联系"} self.registration_table.setRowCount(len(leads)) for row, lead in enumerate(leads): values = ( self._time_text(lead.get("updated")), lead.get("contact") or "未知客户", (lead.get("symptom") or "待补充").replace("\n", " "), status_map.get(lead.get("status"), lead.get("status") or "--"), ) for column, value in enumerate(values): item = QTableWidgetItem(str(value)) if column == 0: item.setData(Qt.UserRole, lead.get("id")) self.registration_table.setItem(row, column, item) pending = sum(1 for item in leads if item.get("status") in {"pending_symptom", "booked"}) self.registration_metric.value.setText(str(pending)) try: from conversation_store import ConversationStore self.conversation_store = ConversationStore(str(SCRIPT_DIR / "conversations.json")) sessions = self.conversation_store.list_sessions(limit=500) except Exception as exc: sessions = [] self.logMessage.emit(f"读取会话档案失败:{exc}", "err") self.session_table.setRowCount(len(sessions)) for row, session in enumerate(sessions): values = ( self._time_text(session.get("updated")), session.get("session_id") or "--", session.get("message_count") or 0, session.get("preview") or "--", ) for column, value in enumerate(values): item = QTableWidgetItem(str(value)) if column == 0: item.setData(Qt.UserRole, session.get("session_id")) self.session_table.setItem(row, column, item) self.session_metric.value.setText(str(len(sessions))) def _mark_registration_done(self) -> None: ids = self._selected_ids(self.registration_table) if not ids: QMessageBox.information(self, "提示", "请先选择登记记录。") return count = self.registration_store.set_status_many(ids, "done") self.logMessage.emit(f"已将 {count} 条登记标记为已联系", "ok") self.refresh_data() def _delete_registrations(self) -> None: ids = self._selected_ids(self.registration_table) if not ids: QMessageBox.information(self, "提示", "请先选择要删除的登记。") return if QMessageBox.question(self, "确认删除", f"确定删除选中的 {len(ids)} 条登记吗?") != QMessageBox.Yes: return count = self.registration_store.delete_many(ids) self.logMessage.emit(f"已删除 {count} 条挂号登记", "warn") self.refresh_data() def _delete_sessions(self) -> None: ids = self._selected_ids(self.session_table) if not ids: QMessageBox.information(self, "提示", "请先选择要删除的会话档案。") return if QMessageBox.question(self, "确认删除", f"确定删除选中的 {len(ids)} 份档案吗?") != QMessageBox.Yes: return count = sum(1 for session_id in ids if self.conversation_store.delete(session_id)) self.logMessage.emit(f"已删除 {count} 份会话档案", "warn") self.refresh_data() def _open_selected_session(self) -> None: rows = self.session_table.selectionModel().selectedRows() if not rows: QMessageBox.information(self, "提示", "请先选择要查看的会话档案。") return self._open_session_detail(rows[0].row(), 0) def _open_session_detail(self, row: int, _column: int) -> None: id_item = self.session_table.item(row, 0) session_id = str(id_item.data(Qt.UserRole) or "") if id_item else "" if not session_id: QMessageBox.warning(self, "无法打开", "未找到这条会话的 ID。") return try: history = list(self.conversation_store.history(session_id)) last_lines = list(self.conversation_store.last_lines(session_id)) except Exception as exc: QMessageBox.critical(self, "无法读取会话", str(exc)) return SessionDetailDialog(session_id, history, last_lines, self).exec() class BackendLoginDialog(QDialog): completed = Signal(bool, str) def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setWindowTitle("登录配置后台") self.setObjectName("SessionDetailDialog") self.setModal(True) self.setMinimumWidth(480) layout = QVBoxLayout(self) layout.setContentsMargins(26, 24, 26, 24) layout.setSpacing(14) layout.addWidget(_label("登录配置后台", "CardTitle")) layout.addWidget( _label("登录成功后,模型与 MCP 参数会自动同步到当前电脑。", "CardSubtitle") ) import backend_client settings = backend_client.load_settings() layout.addWidget(_label("后台地址", "CardSubtitle")) self.server_url = QLineEdit(settings.get("server_url", backend_client.DEFAULT_SERVER_URL)) self.server_url.setPlaceholderText("http://127.0.0.1:8765") layout.addWidget(self.server_url) layout.addWidget(_label("用户名", "CardSubtitle")) self.username = QLineEdit(settings.get("username", "")) self.username.setPlaceholderText("请输入后台用户名") layout.addWidget(self.username) layout.addWidget(_label("密码", "CardSubtitle")) self.password = QLineEdit() self.password.setEchoMode(QLineEdit.Password) self.password.setPlaceholderText("密码不会保存在本地") self.password.returnPressed.connect(self.submit) layout.addWidget(self.password) self.auto_sync = QCheckBox("启动及运行期间自动同步") self.auto_sync.setChecked(bool(settings.get("auto_sync", True))) layout.addWidget(self.auto_sync) self.status = _label("", "DangerText") self.status.setWordWrap(True) layout.addWidget(self.status) actions = QHBoxLayout() actions.addStretch(1) cancel = _button("取消") cancel.clicked.connect(self.reject) actions.addWidget(cancel) self.login_button = _button("登录并同步", "primary") self.login_button.clicked.connect(self.submit) actions.addWidget(self.login_button) layout.addLayout(actions) self.completed.connect(self._finished) def submit(self) -> None: if not self.server_url.text().strip() or not self.username.text().strip() or not self.password.text(): self.status.setText("请填写后台地址、用户名和密码") return self.login_button.setEnabled(False) self.status.setObjectName("WarningText") self.status.setText("正在登录并获取模型配置…") self.status.style().unpolish(self.status) self.status.style().polish(self.status) server_url = self.server_url.text().strip() username = self.username.text().strip() password = self.password.text() auto_sync = self.auto_sync.isChecked() def worker() -> None: try: import backend_client backend_client.login(server_url, username, password, auto_sync=auto_sync) result = backend_client.sync_config(force=True) message = str(result.get("message") or "登录并同步成功") except Exception as exc: self.completed.emit(False, str(exc)) else: self.completed.emit(True, message) threading.Thread(target=worker, daemon=True).start() def _finished(self, ok: bool, message: str) -> None: self.login_button.setEnabled(True) self.status.setObjectName("SuccessText" if ok else "DangerText") self.status.setText(message) self.status.style().unpolish(self.status) self.status.style().polish(self.status) if ok: QTimer.singleShot(250, self.accept) class PersonaPage(QScrollArea): saved = Signal(bool, str) cloudSyncFinished = Signal(bool, str) def __init__(self): super().__init__() self.setObjectName("PageScroll") self.setWidgetResizable(True) self.setFrameShape(QFrame.NoFrame) root = QWidget() root.setObjectName("PageRoot") self.setWidget(root) layout = QVBoxLayout(root) layout.setContentsMargins(28, 25, 28, 28) layout.setSpacing(18) layout.addLayout( _page_header( "05", "AI 人格与能力", "模型与能力由云端统一管理,本机仅保留客服身份偏好。", ) ) import ai_config identity, identity_layout = _card( "客服与回复", "客服名称留空时自动采用云端名称;填写后优先使用本机名称。", ) form = QGridLayout() form.setHorizontalSpacing(18) form.setVerticalSpacing(8) self.agent_name = self._field( form, 0, 0, "本机客服名称(可留空)", ai_config.local_agent_name_override(), 1, 2, ) self.agent_name.setPlaceholderText( f"云端客服名称:{ai_config.AI_CLOUD_AGENT_NAME}" ) self.rounds = QSpinBox() self.rounds.setRange(1, 50) self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS)) self.max_tokens = QSpinBox() self.max_tokens.setRange(50, 32000) self.max_tokens.setValue(int(ai_config.AI_MAX_TOKENS)) self.temperature = QDoubleSpinBox() self.temperature.setRange(0.0, 2.0) self.temperature.setSingleStep(0.05) self.temperature.setValue(float(ai_config.AI_TEMPERATURE)) form.addWidget(_label("上下文轮数", "CardSubtitle"), 2, 0) form.addWidget(_label("最大回复 tokens", "CardSubtitle"), 2, 1) form.addWidget(self.rounds, 3, 0) form.addWidget(self.max_tokens, 3, 1) form.addWidget(_label("温度", "CardSubtitle"), 4, 0) form.addWidget(self.temperature, 5, 0) form.setColumnStretch(0, 1) form.setColumnStretch(1, 1) identity_layout.addLayout(form) layout.addWidget(identity) mcp, mcp_layout = _card("MCP 服务器", "填写服务器 JSON 数组,配置将与 AI 能力一起保存。") self.mcp_json = QPlainTextEdit() self.mcp_json.setMinimumHeight(160) self.mcp_json.setPlainText( json.dumps(getattr(ai_config, "AI_MCP_SERVERS", []) or [], ensure_ascii=False, indent=2) ) mcp_layout.addWidget(self.mcp_json) mcp_row = QHBoxLayout() mcp_row.addWidget(_label("单次回复最多工具轮数", "CardSubtitle")) self.mcp_rounds = QSpinBox() self.mcp_rounds.setRange(1, 20) self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5))) mcp_row.addWidget(self.mcp_rounds) mcp_row.addStretch(1) mcp_layout.addLayout(mcp_row) layout.addWidget(mcp) actions = QHBoxLayout() self.save_status = _label("", "SuccessText") actions.addWidget(self.save_status, 1) self.save_button = _button("保存本地偏好", "primary") self.save_button.clicked.connect(self.save_config) actions.addWidget(self.save_button) layout.addLayout(actions) layout.addStretch(1) self._cloud_sync_running = False self._cloud_sync_silent = False self.cloudSyncFinished.connect(self._cloud_sync_finished) @staticmethod def _field( layout: QGridLayout, row: int, column: int, title: str, value, row_span: int = 1, column_span: int = 1, ) -> QLineEdit: layout.addWidget(_label(title, "CardSubtitle"), row, column, 1, column_span) field = QLineEdit(str(value or "")) layout.addWidget(field, row + 1, column, row_span, column_span) return field def save_config(self) -> None: try: servers = json.loads(self.mcp_json.toPlainText().strip() or "[]") if not isinstance(servers, list): raise ValueError("MCP JSON 根节点必须是数组") import ai_config ai_config.set_local_agent_name_override(self.agent_name.text().strip()) ai_config.AI_CONTEXT_MAX_ROUNDS = self.rounds.value() ai_config.AI_MAX_TOKENS = self.max_tokens.value() ai_config.AI_TEMPERATURE = self.temperature.value() ai_config.AI_MCP_SERVERS = servers ai_config.AI_MCP_MAX_ROUNDS = self.mcp_rounds.value() ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt() ai_config.save_settings() except Exception as exc: self.save_status.setText(f"保存失败:{exc}") self.save_status.setObjectName("DangerText") self.saved.emit(False, str(exc)) else: self.save_status.setText("本地偏好已保存并立即生效") self.save_status.setObjectName("SuccessText") self.saved.emit(True, "AI 本地偏好已保存并生效") self.save_status.style().unpolish(self.save_status) self.save_status.style().polish(self.save_status) def sync_from_cloud(self, *, silent: bool = True) -> None: if self._cloud_sync_running: return self._cloud_sync_running = True self._cloud_sync_silent = silent def worker() -> None: try: import backend_client result = backend_client.sync_cloud_config() message = str(result.get("message") or "云端配置同步完成") diagnostics = [str(item) for item in result.get("diagnostics") or []] if diagnostics: message += "\n" + "\n".join(diagnostics) except Exception as exc: self.cloudSyncFinished.emit(False, str(exc)) else: self.cloudSyncFinished.emit(True, message) threading.Thread(target=worker, daemon=True).start() def _cloud_sync_finished(self, ok: bool, message: str) -> None: silent = self._cloud_sync_silent self._cloud_sync_running = False if ok: self.reload_from_ai_config() if not silent or not ok or "[开发模式]" in message: self.saved.emit(ok, message if ok else f"云端配置同步失败:{message}") def reload_from_ai_config(self) -> None: import ai_config self.agent_name.setText(ai_config.local_agent_name_override()) self.agent_name.setPlaceholderText( f"云端客服名称:{ai_config.AI_CLOUD_AGENT_NAME}" ) self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS)) self.max_tokens.setValue(int(ai_config.AI_MAX_TOKENS)) self.temperature.setValue(float(ai_config.AI_TEMPERATURE)) self.mcp_json.setPlainText( json.dumps(getattr(ai_config, "AI_MCP_SERVERS", []) or [], ensure_ascii=False, indent=2) ) self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5))) class QueuePage(QScrollArea): """回复队列:现在还欠谁一条回复,以及每个任务都经历了什么。 队列本身只存"未完成"的任务,做完就删,什么痕迹都不留。出问题时——某个客户 没收到回复、同一句话回了三遍——事后完全看不出它经历过什么。执行记录补的 就是这条时间线。 """ logMessage = Signal(str, str) deleteTasksRequested = Signal(object) EVENT_COLORS = { "视觉识别": COLORS["accent_dark"], "入队": COLORS["accent_dark"], "开始处理": COLORS["ink"], "调用模型": COLORS["accent_dark"], "生成回复": COLORS["ink"], "已发送": COLORS["success"], "完成": COLORS["success"], "对账": COLORS["muted"], "页面诊断": COLORS["muted"], "发送待核对": COLORS["warning"], "跳过": COLORS["warning"], "失败": COLORS["danger"], } STAGE_LABELS = { "queued": "等待处理", "opening": "正在视觉定位", "reading": "正在读取消息", "collecting": "正在合并消息", "generating": "正在生成回复", "ready_to_send": "等待发送", "sending": "正在发送", "receipt_check": "正在核对回执", "retry_wait": "重试等待", "blocked_modal": "弹窗阻塞", "call_paused": "视频/通话暂停", "manual_takeover": "人工接管", "uncertain": "发送待核对", } def __init__(self): super().__init__() self.setObjectName("PageScroll") self.setWidgetResizable(True) self.setFrameShape(QFrame.NoFrame) root = QWidget() root.setObjectName("PageRoot") self.setWidget(root) layout = QVBoxLayout(root) layout.setContentsMargins(28, 25, 28, 28) layout.setSpacing(18) header = QHBoxLayout() header.addLayout( _page_header("06", "回复队列", "排队等回复的会话,以及每个任务的执行流水。"), 1, ) refresh = _button("刷新") refresh.clicked.connect(self.refresh_data) header.addWidget(refresh, 0, Qt.AlignBottom) layout.addLayout(header) summary = QGridLayout() summary.setSpacing(12) self.waiting_metric = MetricCard("排队中", "0", "等待回复的会话") self.sent_metric = MetricCard("今日已回", "0", "成功发出的回复") self.failed_metric = MetricCard("今日失败", "0", "发送未成功,会自动重试") for column, card in enumerate( (self.waiting_metric, self.sent_metric, self.failed_metric) ): summary.addWidget(card, 0, column) summary.setColumnStretch(column, 1) layout.addLayout(summary) vision_card, vision_layout = _card( "实时视觉监控", "红框是客户未读,橙框是已排除的系统入口,蓝框是当前会话;模型生成期间仍会只读识别新消息。", ) self.vision_status = _label("尚未收到视觉画面", "CardSubtitle") self.vision_status.setWordWrap(True) vision_layout.addWidget(self.vision_status) self.vision_preview = QLabel("开始监听后,这里会显示企业微信会话列表的实时识别画面") self.vision_preview.setAlignment(Qt.AlignCenter) self.vision_preview.setMinimumHeight(260) self.vision_preview.setStyleSheet( f"background:{COLORS['surface_alt']};border:1px solid {COLORS['line']};" f"border-radius:8px;color:{COLORS['muted']};padding:8px;" ) vision_layout.addWidget(self.vision_preview) layout.addWidget(vision_card) queue_card, queue_layout = _card( "当前队列", "按进入顺序排队,一次只服务一个会话,聊天内容互不串扰。", ) self._running = False self.queue_notice = _label("", "CardSubtitle") self.queue_notice.setStyleSheet( f"color:{COLORS['warning']};background:#FFF7E8;" "border-radius:6px;padding:7px 10px;" ) self.queue_notice.hide() queue_layout.addWidget(self.queue_notice) self.queue_table = self._table( ["排队", "客户", "状态", "等待时长", "阶段耗时", "重试", "结果/原因", "会话 ID"] ) queue_layout.addWidget(self.queue_table) queue_actions = QHBoxLayout() queue_actions.addStretch(1) self.delete_queue_button = _button("删除所选任务", "danger") self.delete_queue_button.setToolTip("取消并删除所选未完成回复,不会删除会话档案和执行记录") self.delete_queue_button.clicked.connect(self._request_delete_selected) queue_actions.addWidget(self.delete_queue_button) queue_layout.addLayout(queue_actions) layout.addWidget(queue_card) history_card, history_layout = _card( "执行记录", "每个任务从入队到完成的每一步,最新的排在最上面。", ) self.history_table = self._table(["时间", "客户", "动作", "内容"]) history_layout.addWidget(self.history_table) history_actions = QHBoxLayout() history_actions.addStretch(1) clear = _button("清空记录", "danger") clear.clicked.connect(self._clear_history) history_actions.addWidget(clear) history_layout.addLayout(history_actions) layout.addWidget(history_card) layout.addStretch(1) self.refresh_data() @staticmethod def _table(headers: list[str]) -> QTableWidget: table = QTableWidget(0, len(headers)) table.setHorizontalHeaderLabels(headers) table.setSelectionBehavior(QAbstractItemView.SelectRows) table.setSelectionMode(QAbstractItemView.ExtendedSelection) table.setEditTriggers(QAbstractItemView.NoEditTriggers) table.setAlternatingRowColors(True) table.verticalHeader().hide() table.verticalHeader().setDefaultSectionSize(38) table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) table.horizontalHeader().setStretchLastSection(True) table.setMinimumHeight(220) return table def set_running(self, running: bool) -> None: """监听是否在跑。停着的时候队列里的等待时长会一直涨,不说明白会被当成卡死。""" self._running = bool(running) self._refresh_notice() def _refresh_notice(self) -> None: waiting = self.queue_table.rowCount() if self.queue_table.item(0, 1) else 0 if not self._running and waiting: self.queue_notice.setText( "监听已停止,这些任务原地保留;重新开始监听后会接着处理,等待时长仍在累计。" ) self.queue_notice.show() else: self.queue_notice.hide() @staticmethod def _show_placeholder(table: QTableWidget, text: str) -> None: """空表就是一大片白,看着像坏了。用一行字说明它本来就该是空的。""" table.setRowCount(1) item = QTableWidgetItem(text) item.setForeground(QColor(COLORS["muted"])) table.setItem(0, 0, item) table.setSpan(0, 0, 1, table.columnCount()) @staticmethod def _as_time(value) -> float: try: return float(value) except (TypeError, ValueError): return float("inf") @staticmethod def _waited_text(since: float) -> str: try: seconds = max(0, int(time.time() - float(since))) except (TypeError, ValueError): return "--" if seconds < 60: return f"{seconds} 秒" if seconds < 3600: return f"{seconds // 60} 分 {seconds % 60} 秒" return f"{seconds // 3600} 小时 {(seconds % 3600) // 60} 分" @staticmethod def _state_text(state: dict) -> str: send_state = str(state.get("send_state") or "") if send_state == "sending": return "正在发送" if send_state == "uncertain": return "发送待核对" if send_state == "sent_uncommitted": return "已发送,待归档" stage = str(state.get("stage") or "") if stage in QueuePage.STAGE_LABELS: return QueuePage.STAGE_LABELS[stage] if state.get("batch_ready"): return "正在生成回复" if state.get("confirmed_unread"): return "等待处理" return "排队中" @classmethod def _stage_elapsed_text(cls, state: dict) -> str: started = state.get("stage_started_at") if started in (None, ""): return "--" return cls._waited_text(started) def apply_visual_state(self, payload: dict) -> None: """显示机器人最近一次真实截图及其本地视觉判定。""" if not isinstance(payload, dict): return try: stamp = float(payload.get("ts") or 0.0) except (TypeError, ValueError): stamp = 0.0 parts = [ time.strftime("%H:%M:%S", time.localtime(stamp)) if stamp else "--:--:--", str(payload.get("source") or "视觉识别"), f"未读 {int(payload.get('unread_count') or 0)}", "消息页正常" if payload.get("message_page") else "消息页待确认", ] if payload.get("model_busy"): parts.append("模型生成中·并行监听") if payload.get("blocking_modal"): parts.append("检测到遮挡弹窗") if payload.get("call_window"): parts.append(f"通话保护:{payload.get('call_window')}") system_entries = payload.get("system_entries") or [] if system_entries: names = [str(item.get("name") or "系统入口") for item in system_entries if isinstance(item, dict)] parts.append("已排除 " + "、".join(names[:4])) self.vision_status.setText(" | ".join(parts)) path = str(payload.get("preview_path") or "") if not path or not os.path.isfile(path): return pixmap = QPixmap(path) if pixmap.isNull(): return width = max(480, self.viewport().width() - 120) self.vision_preview.setPixmap( pixmap.scaled(width, 330, Qt.KeepAspectRatio, Qt.SmoothTransformation) ) self.vision_preview.setText("") def _refresh_visual_monitor(self) -> None: try: with open(SCRIPT_DIR / "vision_status.json", encoding="utf-8") as handle: payload = json.load(handle) except FileNotFoundError: return except Exception as exc: self.vision_status.setText(f"视觉状态读取失败:{exc}") return self.apply_visual_state(payload) def refresh_data(self) -> None: self._refresh_visual_monitor() self._refresh_queue() self._refresh_history() self._refresh_notice() def _refresh_queue(self) -> None: try: with open(SCRIPT_DIR / "pending_replies.json", encoding="utf-8") as handle: raw = json.load(handle) except FileNotFoundError: raw = {} except Exception as exc: raw = {} self.logMessage.emit(f"读取回复队列失败:{exc}", "err") pending = raw.get("pending", raw) if isinstance(raw, dict) else {} rows = sorted( ( (key, value) for key, value in pending.items() if isinstance(value, dict) ), # 先来的排前面,界面上的顺序就是实际服务顺序。时间戳脏了也不能 # 让整页读不出来——排到最后就是了 key=lambda item: self._as_time(item[1].get("created_at")), ) self.waiting_metric.value.setText(str(len(rows))) self.queue_table.clearSpans() self.queue_table.setRowCount(len(rows)) if not rows: self._show_placeholder(self.queue_table, "没有排队的会话,所有消息都已回复。") return for index, (key, state) in enumerate(rows): values = ( str(index + 1), state.get("display_name") or "(未识别昵称)", self._state_text(state), self._waited_text(state.get("created_at")), self._stage_elapsed_text(state), str(int(state.get("resume_failures") or 0)), state.get("last_error") or state.get("stage_detail") or "--", str(key)[:32], ) for column, value in enumerate(values): item = QTableWidgetItem(str(value)) if column == 0: item.setData(Qt.UserRole, str(key)) self.queue_table.setItem(index, column, item) def _request_delete_selected(self) -> None: rows = sorted( {index.row() for index in self.queue_table.selectionModel().selectedRows()} ) selected = [] for row in rows: key_item = self.queue_table.item(row, 0) name_item = self.queue_table.item(row, 1) key = str(key_item.data(Qt.UserRole) or "") if key_item else "" if key: selected.append((key, name_item.text() if name_item else "(未识别昵称)")) if not selected: QMessageBox.information(self, "提示", "请先选择要删除的队列任务。") return names = "、".join(name for _key, name in selected[:3]) if len(selected) > 3: names += f" 等 {len(selected)} 条" running_note = ( "\n\n监听正在运行:若模型还在生成,本次结果也会被取消,不会继续粘贴或发送。" if self._running else "" ) prompt = ( f"确定删除所选任务吗?\n\n{names}" f"{running_note}\n\n会话档案和执行记录会保留;客户以后发来新消息仍可重新入队。" ) if QMessageBox.question(self, "确认删除队列任务", prompt) != QMessageBox.Yes: return self.deleteTasksRequested.emit([key for key, _name in selected]) def _refresh_history(self) -> None: try: from queue_log import QueueLog events = QueueLog().recent(200) except Exception as exc: events = [] self.logMessage.emit(f"读取队列执行记录失败:{exc}", "err") self.history_table.clearSpans() self.history_table.setRowCount(len(events)) if not events: self._show_placeholder(self.history_table, "还没有队列执行记录。") self.sent_metric.value.setText("0") self.failed_metric.value.setText("0") return midnight = time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1)) sent = failed = 0 for row, event in enumerate(events): try: stamp = float(event.get("ts") or 0.0) except (TypeError, ValueError): stamp = 0.0 action = str(event.get("event") or "") if stamp >= midnight: if action == "已发送": sent += 1 elif action == "失败": failed += 1 values = ( time.strftime("%m-%d %H:%M:%S", time.localtime(stamp)) if stamp else "--", event.get("name") or "(未识别昵称)", action, str(event.get("detail") or "").replace("\n", " "), ) for column, value in enumerate(values): item = QTableWidgetItem(str(value)) if column == 2 and action in self.EVENT_COLORS: item.setForeground(QColor(self.EVENT_COLORS[action])) self.history_table.setItem(row, column, item) self.sent_metric.value.setText(str(sent)) self.failed_metric.value.setText(str(failed)) def _clear_history(self) -> None: try: from queue_log import QueueLog QueueLog().clear() except Exception as exc: self.logMessage.emit(f"清空队列执行记录失败:{exc}", "err") return self._refresh_history() class LogPage(QWidget): def __init__(self): super().__init__() self.setObjectName("PageRoot") layout = QVBoxLayout(self) layout.setContentsMargins(28, 25, 28, 28) layout.setSpacing(18) header = QHBoxLayout() header.addLayout(_page_header("07", "运行日志", "实时查看连接、识别、AI 与业务沉淀事件。"), 1) clear = _button("清空日志") clear.clicked.connect(self.clear) header.addWidget(clear, 0, Qt.AlignBottom) layout.addLayout(header) card, card_layout = _card() self.editor = QTextEdit() self.editor.setReadOnly(True) self.editor.setAcceptRichText(True) # 机器人每轮轮询都在打日志,跑一天就是几万块富文本;不封顶的话文档 # 越长每次追加越慢,整个界面跟着卡。磁盘副本是全量的,界面只留近况。 self.editor.document().setMaximumBlockCount(2000) self.editor.setStyleSheet( "QTextEdit{font-family:'Cascadia Mono','Microsoft YaHei UI';font-size:13px;line-height:1.5;}" ) card_layout.addWidget(self.editor) layout.addWidget(card, 1) def append(self, message: str, tag: str = "") -> None: colors = { "ok": COLORS["success"], "warn": COLORS["warning"], "err": COLORS["danger"], "notify": COLORS["accent_dark"], } color = colors.get(tag, COLORS["ink"]) stamp = time.strftime("%H:%M:%S") safe = ( str(message) .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\n", "
") ) self.editor.append( f'[{stamp}] ' f'{safe}' ) def clear(self) -> None: self.editor.clear() class CapsuleActionButton(QPushButton): """不依赖字体字形的胶囊浮窗矢量操作按钮。""" def __init__(self, icon_kind: str, parent: QWidget | None = None): super().__init__(parent) self.icon_kind = icon_kind self.setCursor(Qt.PointingHandCursor) self.setFixedSize(30, 34) self.setFocusPolicy(Qt.NoFocus) self.setStyleSheet( "QPushButton{background:transparent;border:none;border-radius:9px;}" "QPushButton:hover{background:#F1F5F3;}" "QPushButton:pressed{background:#E4ECE8;}" ) def paintEvent(self, event) -> None: super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) color = "#006C51" if self.underMouse() or self.isDown() else "#5A5F5D" pen = QPen(QColor(color)) center_x = self.width() / 2.0 center_y = self.height() / 2.0 if self.icon_kind == "pause": pen.setWidthF(3.0) pen.setCapStyle(Qt.PenCapStyle.FlatCap) painter.setPen(pen) painter.drawLine( QPointF(center_x - 3.5, center_y - 6.0), QPointF(center_x - 3.5, center_y + 6.0), ) painter.drawLine( QPointF(center_x + 3.5, center_y - 6.0), QPointF(center_x + 3.5, center_y + 6.0), ) else: pen.setWidthF(1.8) pen.setCapStyle(Qt.PenCapStyle.SquareCap) painter.setPen(pen) left, right = center_x - 7.0, center_x + 7.0 top, bottom = center_y - 7.0, center_y + 7.0 arm = 4.5 for start, end in ( (QPointF(left, top + arm), QPointF(left, top)), (QPointF(left, top), QPointF(left + arm, top)), (QPointF(right - arm, top), QPointF(right, top)), (QPointF(right, top), QPointF(right, top + arm)), (QPointF(left, bottom - arm), QPointF(left, bottom)), (QPointF(left, bottom), QPointF(left + arm, bottom)), (QPointF(right - arm, bottom), QPointF(right, bottom)), (QPointF(right, bottom), QPointF(right, bottom - arm)), ): painter.drawLine(start, end) class CapsuleWindow(QWidget): expandRequested = Signal() stopRequested = Signal() def __init__(self): super().__init__(None) self.setWindowFlags( Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus ) self.setAttribute(Qt.WA_TranslucentBackground) self.setAttribute(Qt.WA_ShowWithoutActivating) self.setFixedSize(436, 112) self._drag_origin: QPoint | None = None self._window_origin = QPoint() outer = QVBoxLayout(self) outer.setContentsMargins(8, 7, 8, 11) self.panel = QFrame() self.panel.setObjectName("CapsulePanel") self.panel.setAccessibleName("监听状态浮窗") self.panel.setToolTip("按住空白区域可拖动浮窗") self.panel.setCursor(Qt.SizeAllCursor) self.panel.setStyleSheet( "QFrame#CapsulePanel{background:#FEFEFE;" "border:1px solid #EFF2F0;border-radius:47px;}" "QFrame#CapsulePanel QLabel{background:transparent;border:none;}" ) _shadow(self.panel, 20, 4, 18) panel_layout = QVBoxLayout(self.panel) panel_layout.setContentsMargins(34, 8, 10, 8) panel_layout.setSpacing(3) top_row = QWidget() top_row.setObjectName("CapsuleTopRow") top_row.setFixedHeight(46) row = QHBoxLayout(top_row) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(0) self.status_group = QWidget() self.status_group.setObjectName("CapsuleStatusGroup") self.status_group.setFixedSize(108, 46) status_row = QHBoxLayout(self.status_group) status_row.setContentsMargins(0, 0, 0, 0) status_row.setSpacing(4) self.dot = QFrame() self.dot.setObjectName("CapsuleStatusDot") self.dot.setAccessibleName("监听状态") self.dot.setFixedSize(10, 10) self.dot.setStyleSheet( "QFrame#CapsuleStatusDot{background:#7AB2A4;" "border:none;border-radius:5px;}" ) status_row.addWidget(self.dot, 0, Qt.AlignVCenter) copy = QVBoxLayout() copy.setContentsMargins(0, 0, 0, 0) copy.setSpacing(0) copy.addStretch(1) self.status = QLabel("监听中") self.status.setMinimumWidth(94) self.status.setStyleSheet( "QLabel{color:#006C51;font-size:13px;font-weight:600;}" ) self.hint = QLabel("安全运行中") self.hint.setStyleSheet( "QLabel{color:#666A68;font-size:10px;}" ) copy.addWidget(self.status) copy.addWidget(self.hint) copy.addStretch(1) status_row.addLayout(copy) row.addWidget(self.status_group, 0, Qt.AlignVCenter) row.addStretch(14) # 上排只保留运行时长。执行进程放在胶囊底部整行显示,不能再挤在 # 150px 宽、13px 高的小字里,否则稍长一点的客户名就几乎看不见。 clock = QWidget() clock.setObjectName("CapsuleClock") clock.setFixedSize(100, 46) clock_column = QVBoxLayout(clock) clock_column.setContentsMargins(0, 0, 0, 0) clock_column.setSpacing(0) clock_column.addStretch(1) self.timer = QLabel("00:00:00") self.timer.setObjectName("CapsuleTimer") self.timer.setAccessibleName("监听运行时长") self.timer.setAlignment(Qt.AlignCenter) self.timer.setFixedSize(84, 28) self.timer.setStyleSheet( "QLabel#CapsuleTimer{background:#F5F5F5;border:1px solid #E8E8E8;" "border-radius:8px;font-family:'Cascadia Mono','Consolas';" "font-size:10px;font-weight:600;color:#1A1C1C;}" ) clock_column.addWidget(self.timer, 0, Qt.AlignHCenter) clock_column.addStretch(1) row.addWidget(clock, 0, Qt.AlignVCenter) row.addStretch(39) self.divider = QFrame() self.divider.setObjectName("CapsuleDivider") self.divider.setFixedSize(1, 22) self.divider.setStyleSheet( "QFrame#CapsuleDivider{background:#EFEFEF;border:none;}" ) row.addWidget(self.divider, 0, Qt.AlignVCenter) row.addSpacing(5) actions = QWidget() actions.setObjectName("CapsuleActions") actions.setFixedSize(62, 34) action_row = QHBoxLayout(actions) action_row.setContentsMargins(0, 0, 0, 0) action_row.setSpacing(2) self.stop_button = CapsuleActionButton("pause") self.stop_button.setObjectName("CapsuleStopButton") self.stop_button.setAccessibleName("停止监听") self.stop_button.setToolTip("停止监听") self.stop_button.clicked.connect(self.stopRequested) action_row.addWidget(self.stop_button) self.expand_button = CapsuleActionButton("fullscreen") self.expand_button.setObjectName("CapsuleExpandButton") self.expand_button.setAccessibleName("展开控制台") self.expand_button.setToolTip("展开控制台") self.expand_button.clicked.connect(self.expandRequested) action_row.addWidget(self.expand_button) row.addWidget(actions, 0, Qt.AlignVCenter) panel_layout.addWidget(top_row) self._progress_text = "" self.progress = QLabel("") self.progress.setObjectName("CapsuleProgress") self.progress.setAccessibleName("当前执行进程") self.progress.setAlignment(Qt.AlignCenter) self.progress.setFixedHeight(21) self.progress.setStyleSheet( "QLabel#CapsuleProgress{background:#F2F7F5;color:#46635A;" "border-radius:7px;padding:0 8px;font-size:10px;font-weight:500;}" ) panel_layout.addWidget(self.progress) outer.addWidget(self.panel) def show_near(self, window: QWidget) -> None: screen = window.screen() or QApplication.primaryScreen() area = screen.availableGeometry() x = area.x() + (area.width() - self.width()) // 2 y = area.y() + 28 self.move(x, y) self.show() def mousePressEvent(self, event) -> None: if event.button() == Qt.LeftButton: self._drag_origin = event.globalPosition().toPoint() self._window_origin = self.pos() event.accept() def mouseMoveEvent(self, event) -> None: if self._drag_origin is not None and event.buttons() & Qt.LeftButton: self.move(self._window_origin + event.globalPosition().toPoint() - self._drag_origin) event.accept() def mouseReleaseEvent(self, event) -> None: self._drag_origin = None super().mouseReleaseEvent(event) def set_status(self, state: str, text: str) -> None: colors = { "running": ("#006C51", "#7AB2A4"), "waiting": ("#9B681D", "#D2A85F"), "connecting": ("#9B681D", "#D2A85F"), "stopping": ("#9B681D", "#D2A85F"), "error": ("#B84552", "#DF8E98"), "verification": ("#B84552", "#DF8E98"), } hints = { "running": "安全运行中", "waiting": "等待企业微信", "connecting": "正在连接窗口", "stopping": "正在结束任务", "error": "请查看运行日志", "verification": "请先用手机扫码验证", "stopped": "监听已停止", } title_color, dot_color = colors.get(state, ("#66736D", "#A8B2AD")) self.dot.setStyleSheet( f"QFrame#CapsuleStatusDot{{background:{dot_color};border:none;border-radius:5px;}}" ) self.status.setText(text) self.status.setStyleSheet( f"QLabel{{color:{title_color};font-size:13px;font-weight:600;}}" ) self.hint.setText(hints.get(state, text)) if state in ("stopped", "error", "verification"): # 停下来之后还挂着"正在回复 XXX"会让人以为它还在干活 self.set_progress("") elif state == "connecting" and not self._progress_text: self.set_progress("正在连接企业微信") elif state == "waiting" and not self._progress_text: self.set_progress("等待企业微信窗口") elif state == "running" and not self._progress_text: self.set_progress("等待新消息") def set_progress(self, text: str) -> None: """显示与“执行记录”同源的当前步骤;长内容保留在悬浮提示里。""" self._progress_text = str(text or "") display_text = f"当前进程 · {self._progress_text}" if self._progress_text else "" metrics = QFontMetrics(self.progress.font()) self.progress.setText( metrics.elidedText( display_text, Qt.ElideRight, max(0, self.progress.contentsRect().width() - 12), ) ) self.progress.setToolTip(display_text) class Sidebar(QFrame): pageRequested = Signal(int) startRequested = Signal() stopRequested = Signal() PAGE_NAMES = ( "AI 客服", "自动回复", "通用设置", "业务数据", "AI 人格", "回复队列", "运行日志", ) def __init__(self): super().__init__() self.setObjectName("Sidebar") self.setFixedWidth(258) layout = QVBoxLayout(self) layout.setContentsMargins(18, 24, 18, 18) layout.setSpacing(8) layout.addWidget(_label("ZHEN AI DESK", "Brand")) layout.addWidget(_label("AI 客服 · 企业微信自动回复", "BrandSub")) layout.addSpacing(13) status_card = QFrame() status_card.setObjectName("SideStatus") status_layout = QVBoxLayout(status_card) status_layout.setContentsMargins(15, 13, 15, 13) status_layout.setSpacing(3) self.status_title = _label("● 已停止", "SideStatusTitle") self.status_hint = _label("等待连接企业微信", "SideStatusHint") status_layout.addWidget(self.status_title) status_layout.addWidget(self.status_hint) layout.addWidget(status_card) layout.addSpacing(9) layout.addWidget(_label("CONTROL CENTER", "SectionLabel")) self.nav_buttons: list[QPushButton] = [] for index, name in enumerate(self.PAGE_NAMES, start=1): button = QPushButton(f"{index:02d} {name}") button.setObjectName("NavButton") button.setCheckable(True) button.setAutoExclusive(True) button.setCursor(Qt.PointingHandCursor) button.clicked.connect(lambda _checked=False, i=index - 1: self.pageRequested.emit(i)) layout.addWidget(button) self.nav_buttons.append(button) self.nav_buttons[0].setChecked(True) layout.addStretch(1) self.monitor_button = _button("开始监听", "side") self.monitor_button.clicked.connect(self.startRequested) layout.addWidget(self.monitor_button) layout.addWidget(_label("RPA DESKTOP / LOCAL", "SectionLabel")) def select(self, index: int) -> None: if 0 <= index < len(self.nav_buttons): self.nav_buttons[index].setChecked(True) def set_status(self, state: str, text: str, hint: str) -> None: colors = { "running": "#43E1AE", "waiting": "#F2BC64", "connecting": "#F2BC64", "stopping": "#F2BC64", "error": "#FF818E", "verification": "#FF818E", "stopped": "#A5B9B0", } self.status_title.setText(f"● {text}") self.status_title.setStyleSheet( f"color:{colors.get(state, '#A5B9B0')};font-size:15px;font-weight:700;" ) self.status_hint.setText(hint) active = state in {"running", "waiting", "connecting", "stopping"} self.monitor_button.setText("停止监听" if active else "开始监听") try: self.monitor_button.clicked.disconnect() except RuntimeError: pass self.monitor_button.clicked.connect(self.stopRequested if active else self.startRequested) self.monitor_button.setObjectName("DangerButton" if active else "SidePrimary") self.monitor_button.style().unpolish(self.monitor_button) self.monitor_button.style().polish(self.monitor_button) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(WIN_TITLE) self.setMinimumSize(1120, 720) self._set_initial_geometry() self._queue: queue.Queue = queue.Queue() self._thread = None self._running = False self._start_time: float | None = None self._status_key = "stopped" self._original_stdout = sys.stdout self._stdout_proxy = None self._saved_geometry = None self._was_maximized = False self._capsule_minimize_pending = False self.runtime_settings = self._load_runtime_settings() root = QWidget() root.setObjectName("AppRoot") self.setCentralWidget(root) root_layout = QHBoxLayout(root) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) self.sidebar = Sidebar() root_layout.addWidget(self.sidebar) self.stack = FadingStack() root_layout.addWidget(self.stack, 1) self.portal_page = PortalPage() self.dashboard_page = DashboardPage() self.settings_page = SettingsPage(self.runtime_settings) self.business_page = BusinessPage() self.persona_page = PersonaPage() self.queue_page = QueuePage() self.log_page = LogPage() for page in ( self.portal_page, self.dashboard_page, self.settings_page, self.business_page, self.persona_page, self.queue_page, self.log_page, ): self.stack.addWidget(page) self.capsule = CapsuleWindow() self._connect_signals() self.business_refresh_timer = QTimer(self) self.business_refresh_timer.setSingleShot(True) self.business_refresh_timer.setInterval(220) self.business_refresh_timer.timeout.connect(self._refresh_counts) self.set_status("stopped", "已停止") self._refresh_counts() self.append_log("新控制台已就绪。请确认企业微信已登录,再开始监听。", "notify") self.queue_timer = QTimer(self) self.queue_timer.setInterval(140) self.queue_timer.timeout.connect(self._process_queue) self.queue_timer.start() # 队列页面只在自己露脸时刷新:读的是磁盘上的两个 JSON, # 后台一直轮询纯属白白占着硬盘和 CPU self.queue_page_timer = QTimer(self) self.queue_page_timer.setInterval(2000) self.queue_page_timer.timeout.connect(self._refresh_queue_page) self.queue_page_timer.start() try: import backend_client sync_seconds = int( backend_client.load_settings().get("sync_interval_seconds", 300) ) except Exception: sync_seconds = 300 self.backend_sync_timer = QTimer(self) self.backend_sync_timer.setInterval(max(60, sync_seconds) * 1000) self.backend_sync_timer.timeout.connect( lambda: self.persona_page.sync_from_cloud(silent=True) ) self.backend_sync_timer.start() def _set_initial_geometry(self) -> None: screen = QApplication.primaryScreen() area = screen.availableGeometry() if screen else QRect(0, 0, 1440, 900) width = min(1480, max(1120, area.width() - 72)) height = min(940, max(720, area.height() - 72)) self.resize(width, height) self.move(area.x() + (area.width() - width) // 2, area.y() + (area.height() - height) // 2) def _connect_signals(self) -> None: self.sidebar.pageRequested.connect(self.show_page) self.sidebar.startRequested.connect(self.start_monitoring) self.sidebar.stopRequested.connect(self.stop_monitoring) self.dashboard_page.startRequested.connect(self.start_monitoring) self.dashboard_page.stopRequested.connect(self.stop_monitoring) self.dashboard_page.pageRequested.connect(self.show_page) self.settings_page.saved.connect(self.save_runtime_settings) self.business_page.logMessage.connect(self.append_log) self.queue_page.logMessage.connect(self.append_log) self.queue_page.deleteTasksRequested.connect(self._delete_queue_tasks) self.persona_page.saved.connect(self._persona_saved) self.capsule.expandRequested.connect(self.expand_console) self.capsule.stopRequested.connect(self.stop_monitoring) @staticmethod def _runtime_defaults() -> dict: return { "auto_reply_text": "在的,您慢慢说,我这边看着呢。", "poll_interval": 2.0, "mouse_idle_enabled": True, "mouse_idle_seconds": 20.0, "message_batch_window_seconds": MESSAGE_BATCH_WINDOW_SECONDS, } def _load_runtime_settings(self) -> dict: settings = self._runtime_defaults() try: saved = json.loads(APP_SETTINGS_FILE.read_text(encoding="utf-8")) if isinstance(saved, dict): settings.update({key: saved[key] for key in settings if key in saved}) except (OSError, ValueError, TypeError): pass try: settings["poll_interval"] = max(0.2, float(settings["poll_interval"])) settings["mouse_idle_seconds"] = max(0.0, float(settings["mouse_idle_seconds"])) settings["message_batch_window_seconds"] = ( normalize_message_batch_window_seconds( settings["message_batch_window_seconds"] ) ) settings["mouse_idle_enabled"] = bool(settings["mouse_idle_enabled"]) settings["auto_reply_text"] = ( str(settings["auto_reply_text"]).strip() or "在的,您慢慢说,我这边看着呢。" ) except (TypeError, ValueError): return self._runtime_defaults() return settings def save_runtime_settings(self, settings: dict | None = None) -> None: self.runtime_settings = dict(settings or self.settings_page.values()) try: temporary = APP_SETTINGS_FILE.with_suffix(".json.tmp") temporary.write_text( json.dumps(self.runtime_settings, ensure_ascii=False, indent=2), encoding="utf-8", ) os.replace(temporary, APP_SETTINGS_FILE) except OSError as exc: self.settings_page.mark_saved(False, f"保存失败:{exc}") self.append_log(f"运行参数保存失败:{exc}", "err") else: thread = getattr(self, "_thread", None) if thread is not None and thread.is_alive(): thread.set_message_batch_window_seconds(float( self.runtime_settings["message_batch_window_seconds"] )) self.settings_page.mark_saved(True, "已自动保存") def show_page(self, index: int) -> None: self.sidebar.select(index) self.stack.setCurrentIndexAnimated(index) if index == 3: self.business_page.refresh_data() if index == 5: self.queue_page.refresh_data() if index == 0: self.portal_page.ensure_view() QTimer.singleShot(80, self.portal_page.focus_view) def _refresh_queue_page(self) -> None: """队列页开着的时候,让它跟着机器人一起动。""" if self.stack.currentWidget() is self.queue_page and self.isVisible(): self.queue_page.refresh_data() def _delete_queue_tasks(self, keys) -> None: """Delete selected work through the live bot or the stopped queue file.""" requested = [str(key or "").strip() for key in (keys or [])] requested = [key for key in requested if key] if not requested: return thread = getattr(self, "_thread", None) try: if thread is not None and thread.is_alive(): result = thread.cancel_pending_tasks(requested) else: result = delete_pending_reply_file( requested, str(SCRIPT_DIR / "pending_replies.json"), ) except Exception as exc: QMessageBox.warning(self, "删除失败", f"无法删除队列任务:{exc}") self.append_log(f"删除队列任务失败:{exc}", "err") return error = str(result.get("error") or "") deleted = list(result.get("deleted") or []) protected = list(result.get("protected") or []) scheduled = list(result.get("scheduled") or []) if error: QMessageBox.warning(self, "删除失败", error) self.append_log(f"删除队列任务失败:{error}", "err") if deleted: self.append_log( f"已手动删除 {len(deleted)} 条未完成回复;模型中的旧结果不会发送。", "warn", ) if scheduled: self.append_log( f"已登记删除 {len(scheduled)} 条任务,后台初始化完成后立即取消。", "warn", ) if protected: QMessageBox.warning( self, "任务正在核对发送结果", f"有 {len(protected)} 条任务可能已经按下发送键,暂不能删除。" "请等待回执核对完成,或先停止监听后再删除,避免重复回复。", ) self.append_log( f"有 {len(protected)} 条任务处于发送回执保护中,未执行删除。", "warn", ) self.queue_page.refresh_data() def start_monitoring(self) -> None: if self._thread is not None and self._thread.is_alive(): self.append_log("监听线程已经在运行", "warn") return self.save_runtime_settings() self.persona_page.save_config() self._stdout_proxy = LogQueue(self._queue) sys.stdout = self._stdout_proxy self._thread = BotThread( self._queue, self.runtime_settings["auto_reply_text"], float(self.runtime_settings["poll_interval"]), mouse_idle_enabled=bool(self.runtime_settings["mouse_idle_enabled"]), mouse_idle_seconds=float(self.runtime_settings["mouse_idle_seconds"]), message_batch_window_seconds=float( self.runtime_settings["message_batch_window_seconds"] ), ) self._running = True self._start_time = time.time() self.set_status("connecting", "连接中") self.append_log("正在连接企业微信窗口…", "notify") self._thread.start() def stop_monitoring(self) -> None: if self._thread is None or not self._thread.is_alive(): self._finish_thread("stopped") return self._running = False self._thread.stop() self.set_status("stopping", "正在停止") self.append_log("正在停止监听…", "warn") def set_status(self, state: str, text: str) -> None: self._status_key = state hints = { "connecting": "正在查找企业微信窗口", "waiting": WECOM_WAITING_MESSAGE, "running": "安全运行中,检测到未读消息后自动处理", "stopping": "等待后台任务退出", "error": "连接失败,请查看运行日志", "verification": "请用手机企业微信扫码验证,完成后重新开始监听", "stopped": "等待连接企业微信", } hint = hints.get(state, text) self.sidebar.set_status(state, text, hint) self.dashboard_page.set_status(state, text, hint) self.capsule.set_status(state, text) self.queue_page.set_running(state not in ("stopped", "error", "verification")) def changeEvent(self, event) -> None: if ( event.type() == QEvent.Type.WindowStateChange and self.isMinimized() and getattr(self, "_running", False) and not self.capsule.isVisible() and not self._capsule_minimize_pending ): self._capsule_minimize_pending = True previous_state = event.oldState() saved_geometry = self.saveGeometry() was_maximized = bool(previous_state & Qt.WindowMaximized) portal_view = self.portal_page.view portal_was_visible = bool( self.stack.currentIndex() == 0 and portal_view is not None and not portal_view.isHidden() ) QTimer.singleShot( 0, lambda: self._enter_capsule_after_minimize( saved_geometry, was_maximized, portal_was_visible, ), ) super().changeEvent(event) def _enter_capsule_after_minimize( self, saved_geometry, was_maximized: bool, portal_was_visible: bool, ) -> None: self._capsule_minimize_pending = False if ( not self._running or not self.isMinimized() or self.capsule.isVisible() ): return self.enter_capsule_mode( saved_geometry=saved_geometry, was_maximized=was_maximized, portal_was_visible=portal_was_visible, ) def enter_capsule_mode( self, *, saved_geometry=None, was_maximized: bool | None = None, portal_was_visible: bool | None = None, ) -> None: if self.capsule.isVisible(): return self._saved_geometry = ( self.saveGeometry() if saved_geometry is None else saved_geometry ) self._was_maximized = ( self.isMaximized() if was_maximized is None else bool(was_maximized) ) # QWebEngineView owns a native Chromium surface. Hiding the top-level # window while that surface is still visible can leave a large black DWM # window above WeCom on some Windows/GPU combinations. Tear down the # visible surface first and let Qt flush that state before hiding us. portal_view = self.portal_page.view self._portal_was_visible = ( portal_view is not None and portal_view.isVisible() if portal_was_visible is None else bool(portal_was_visible) ) if self._portal_was_visible and self.portal_page.view is not None: self.portal_page.view.hide() QApplication.processEvents() self.capsule.show_near(self) self.hide() def expand_console(self) -> None: self.capsule.hide() if self._saved_geometry is not None: self.restoreGeometry(self._saved_geometry) if self._was_maximized: self.showMaximized() else: self.showNormal() self.raise_() self.activateWindow() if getattr(self, "_portal_was_visible", False) and self.stack.currentIndex() == 0: def restore_portal() -> None: if self.portal_page.view is not None: self.portal_page.view.show() self.portal_page.view.setFocus(Qt.OtherFocusReason) QTimer.singleShot(80, restore_portal) self._portal_was_visible = False def _finish_thread(self, final_state: str) -> None: self._running = False if self.capsule.isVisible(): self.expand_console() if sys.stdout is self._stdout_proxy: sys.stdout = self._original_stdout self._stdout_proxy = None if final_state == "error": self.set_status("error", "连接失败") self.show_page(5) else: self.set_status("stopped", "已停止") self.dashboard_page.timer.setText("运行时长 --:--:--") def append_log(self, message: str, tag: str = "") -> None: self.log_page.append(message, tag) def _persona_saved(self, ok: bool, message: str) -> None: self.append_log(message, "ok" if ok else "err") def _refresh_counts(self) -> None: try: self.business_page.refresh_data() self.dashboard_page.registration.value.setText( self.business_page.registration_metric.value.text() ) self.dashboard_page.sessions.value.setText( self.business_page.session_metric.value.text() ) except Exception as exc: self.append_log(f"刷新业务数据失败:{exc}", "err") def _schedule_business_refresh(self) -> None: """合并短时间内的多条挂号日志,避免在队列循环中反复重建表格。""" self.business_refresh_timer.start() def _process_queue(self) -> None: try: while True: kind, data = self._queue.get_nowait() if kind == "log": lowered = str(data).lower() if any(token in str(data) for token in ("[+]", "回复完成", "已保存")): tag = "ok" elif any(token in str(data) for token in ("[!]", "误判", "停止")): tag = "warn" elif any(token in lowered for token in ("错误", "[-]", "失败", "exception", "traceback")): tag = "err" elif any(token in str(data) for token in ("[MCP]", "[挂号]", "启动", "连接")): tag = "notify" else: tag = "" self.append_log(str(data), tag) if "[挂号]" in str(data): self._schedule_business_refresh() elif kind == "status": if data == "running": self.set_status("running", "监听中") self.enter_capsule_mode() elif data == "waiting": self.set_status("waiting", "等待企业微信") elif data == "error": self._finish_thread("error") elif data == "verification": self._finish_thread("verification") self.set_status("verification", "需要扫码验证") self.show_page(5) elif data == "stopped": self._finish_thread("stopped") elif kind == "progress": self.capsule.set_progress(str(data or "")) elif kind == "stats": self.dashboard_page.replied.value.setText(str(data.get("replied", 0))) self.dashboard_page.false_pos.value.setText(str(data.get("false_pos", 0))) elif kind == "visual": self.queue_page.apply_visual_state(data) except KeyboardInterrupt: # Qt 默认会把 Ctrl+C 打断留在当前槽函数中并打印堆栈;转为正常关闭。 QTimer.singleShot(0, self.close) return except queue.Empty: pass if self._start_time and self._running: elapsed = int(time.time() - self._start_time) runtime = f"{elapsed // 3600:02d}:{(elapsed % 3600) // 60:02d}:{elapsed % 60:02d}" self.dashboard_page.timer.setText(f"运行时长 {runtime}") self.capsule.timer.setText(runtime) def closeEvent(self, event) -> None: self.save_runtime_settings() self._running = False if self._thread is not None and self._thread.is_alive(): self._thread.stop() if sys.stdout is self._stdout_proxy: sys.stdout = self._original_stdout self.capsule.close() try: from dsh_agent import close_desk close_desk() except Exception: pass event.accept() def _app_icon() -> QIcon: icon = QIcon(str(APP_ICON_PATH)) if not icon.isNull(): return icon pixmap = QPixmap(64, 64) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(QColor(COLORS["accent"])) painter.setPen(Qt.NoPen) painter.drawRoundedRect(5, 5, 54, 54, 17, 17) painter.setBrush(QColor("#FFFFFF")) painter.drawEllipse(17, 18, 30, 24) painter.drawPolygon([QPoint(25, 39), QPoint(22, 49), QPoint(34, 41)]) painter.end() return QIcon(pixmap) def install_console_shutdown_handler(app: QApplication, window: MainWindow): """将控制台 Ctrl+C / Ctrl+Break 转换为 Qt 的正常关闭流程。""" shutdown_requested = False def request_shutdown(_signum=None, _frame=None) -> None: nonlocal shutdown_requested if shutdown_requested: return shutdown_requested = True QTimer.singleShot(0, window.close) QTimer.singleShot(0, app.quit) handled = [signal.SIGINT] if hasattr(signal, "SIGBREAK"): handled.append(signal.SIGBREAK) for signal_number in handled: try: signal.signal(signal_number, request_shutdown) except (OSError, ValueError): pass return request_shutdown def handle_startup_update(release: object) -> bool: """显示启动升级提示;返回 False 表示本次不能进入软件。""" status = release_status(release) if not status["update_available"]: return True forced = status["force_upgrade"] message = QMessageBox() message.setIcon(QMessageBox.Icon.Critical if forced else QMessageBox.Icon.Information) message.setWindowTitle("必须升级" if forced else "发现新版本") message.setText( f"当前版本:v{status['local_version']}\n" f"云端版本:v{status['latest_version']}" ) message.setInformativeText( "管理员已设置强制升级,升级前无法继续使用。" if forced else "是否现在前往下载新版本?也可以稍后继续使用。" ) if status["release_notes"]: message.setDetailedText(status["release_notes"][:4000]) upgrade_button = message.addButton("立即升级", QMessageBox.ButtonRole.AcceptRole) message.addButton( "退出软件" if forced else "稍后使用", QMessageBox.ButtonRole.RejectRole ) message.setDefaultButton(upgrade_button) message.exec() if message.clickedButton() is upgrade_button: download_url = status["download_url"] if not download_url or not QDesktopServices.openUrl(QUrl(download_url)): QMessageBox.warning( None, "无法打开下载地址", "升级下载地址暂时无法打开,请联系管理员。", ) return not forced return not forced class _StartupSyncBridge(QObject): finished = Signal(object) def _start_background_startup_sync(window: "MainWindow") -> None: """云端配置同步放到后台线程执行。 过去它在窗口出现之前同步跑,网络一慢冷启动就跟着慢(超时 3 秒起步、 DNS 卡住时更久)。现在窗口先出来,结果回来后再补日志和升级提示。 """ bridge = _StartupSyncBridge(window) def deliver(result: object) -> None: payload = result if isinstance(result, dict) else {} for diagnostic in payload.get("diagnostics") or []: window.append_log(str(diagnostic), "notify") if not handle_startup_update(payload.get("release")): window.close() bridge.finished.connect(deliver) def worker() -> None: try: import backend_client result = backend_client.startup_sync_config(timeout=3.0) except Exception: # 云端暂时不可用时仍执行上次成功同步的强制升级策略。 try: import backend_client result = {"release": backend_client.cached_release_status()} except Exception: result = {} bridge.finished.emit(result or {}) threading.Thread(target=worker, daemon=True, name="startup-sync").start() def run_packaging_self_check(app: QApplication) -> int: """离线验证随 EXE 打包的 Qt WebEngine 能否真正创建并加载页面。""" probe = QWebEngineView() state = {"finished": False} def finish(ok: bool) -> None: if state["finished"]: return state["finished"] = True probe.close() app.exit(0 if ok else 3) def timeout() -> None: if not state["finished"]: state["finished"] = True probe.close() app.exit(4) probe.loadFinished.connect(finish) probe.setGeometry(-32000, -32000, 320, 180) probe.show() probe.setHtml( "Qt WebEngine packaging check" ) QTimer.singleShot(30_000, timeout) return app.exec() def main() -> None: QApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(WIN_TITLE) app.setApplicationDisplayName(WIN_TITLE) app.setWindowIcon(_app_icon()) app.setStyle("Fusion") app.setStyleSheet(APP_QSS) font = QFont("Microsoft YaHei UI", 10) font.setHintingPreference(QFont.PreferFullHinting) app.setFont(font) if "--packaging-self-check" in sys.argv: raise SystemExit(run_packaging_self_check(app)) window = MainWindow() console_shutdown = install_console_shutdown_handler(app, window) window.show() QTimer.singleShot(160, window.portal_page.ensure_view) if "--qt-smoke-test" not in sys.argv: _start_background_startup_sync(window) if "--qt-smoke-test" in sys.argv: for index in range(window.stack.count()): def show_test_page(i=index) -> None: window.show_page(i) def capture(i=index) -> None: window.grab().save(str(SCRIPT_DIR / f"qt-ui-smoke-{i + 1}.png")) QTimer.singleShot(650 + index * 650, show_test_page) QTimer.singleShot(950 + index * 650, capture) QTimer.singleShot(1100 + window.stack.count() * 650, app.quit) try: exit_code = app.exec() except KeyboardInterrupt: console_shutdown() exit_code = 0 raise SystemExit(exit_code) if __name__ == "__main__": main()