Files
kefu/wechat_rpa/wechat_gui_qt.py
2026-08-18 17:25:22 +08:00

3126 lines
123 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>")
)
self.editor.append(
f'<span style="color:#94A29B">[{stamp}]</span> '
f'<span style="color:{color}">{safe}</span>'
)
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(
"<html><meta charset='utf-8'><body>Qt WebEngine packaging check</body></html>"
)
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()