# -*- 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, QPoint, QPropertyAnimation, QRect, QTimer, Qt, QUrl, Signal, ) from PySide6.QtGui import QColor, QDesktopServices, QFont, QIcon, QPainter, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QDialog, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect, QGraphicsOpacityEffect, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox, QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, ) from PySide6.QtWebEngineCore import ( QWebEnginePage, QWebEngineProfile, QWebEngineScript, QWebEngineSettings, ) from PySide6.QtWebEngineWidgets import QWebEngineView try: import __main__ as _legacy if not hasattr(_legacy, "BotThread"): raise ImportError except ImportError: import wechat_gui as _legacy BotThread = _legacy.BotThread LogQueue = _legacy.LogQueue SCRIPT_DIR = Path(__file__).resolve().parent APP_SETTINGS_FILE = SCRIPT_DIR / "app_settings.json" CUSTOMER_SERVICE_URL = "http://kf.zhenyangtang.com.cn/" WIN_TITLE = "甄养堂 · 企微客服助手" 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; } 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(22, 58, 43, 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 PortalWebView(QWebEngineView): def createWindow(self, _window_type): return self class PortalPage(QWidget): def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setObjectName("PageRoot") layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.progress = QProgressBar() self.progress.setRange(0, 100) self.progress.hide() layout.addWidget(self.progress) self.view = PortalWebView() self.view.setFocusPolicy(Qt.StrongFocus) self.view.settings().setAttribute( QWebEngineSettings.WebAttribute.LocalStorageEnabled, True ) self.view.settings().setAttribute( QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True ) profile_root = Path(os.environ.get("LOCALAPPDATA", SCRIPT_DIR)) / "ZhenYangTangRPA" / "QtPortal" profile_root.mkdir(parents=True, exist_ok=True) self.profile = QWebEngineProfile("zhen-ai-portal", self) self.profile.setPersistentStoragePath(str(profile_root / "storage")) self.profile.setCachePath(str(profile_root / "cache")) self.profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies ) self.page = QWebEnginePage(self.profile, self.view) self.view.setPage(self.page) self._install_light_theme() self.view.loadStarted.connect(self._load_started) self.view.loadProgress.connect(self.progress.setValue) self.view.loadFinished.connect(self._load_finished) layout.addWidget(self.view, 1) if os.environ.get("WECOM_RPA_DISABLE_PORTAL") != "1": self.view.setUrl(QUrl(CUSTOMER_SERVICE_URL)) else: self.view.setHtml( "" "
" "AI 客服网页在测试模式下未加载
" ) def _install_light_theme(self) -> None: css_path = SCRIPT_DIR / "edge_light_theme" / "light-theme.css" try: css = css_path.read_text(encoding="utf-8") except OSError: css = "html,body,#app{background:#f8faf9!important;color:#17251f!important}" source = f""" (() => {{ const css = {json.dumps(css)}; const applyTheme = () => {{ let style = document.getElementById('zhen-qt-light-theme'); if (!style) {{ style = document.createElement('style'); style.id = 'zhen-qt-light-theme'; (document.head || document.documentElement).appendChild(style); }} style.textContent = css; }}; applyTheme(); new MutationObserver(applyTheme).observe(document.documentElement, {{childList:true}}); }})(); """ script = QWebEngineScript() script.setName("zhen-light-theme") script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) script.setRunsOnSubFrames(True) script.setSourceCode(source) self.profile.scripts().insert(script) def _load_started(self) -> None: self.progress.setValue(2) self.progress.show() def _load_finished(self, _ok: bool) -> None: self.progress.hide() self.view.setFocus(Qt.OtherFocusReason) def reload(self) -> None: self.view.reload() def open_external(self) -> None: QDesktopServices.openUrl(self.view.url() or QUrl(CUSTOMER_SERVICE_URL)) 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"], "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, 2) self.reply = QLineEdit(str(settings["auto_reply_text"])) self.reply.setPlaceholderText("请输入固定回复") runtime_grid.addWidget(self.reply, 1, 0, 1, 2) runtime_grid.addWidget(_label("轮询间隔(秒)", "CardSubtitle"), 2, 0) runtime_grid.addWidget(_label("鼠标静止等待(秒)", "CardSubtitle"), 2, 1) 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"])) runtime_grid.addWidget(self.poll, 3, 0) runtime_grid.addWidget(self.idle_seconds, 3, 1) runtime_grid.setColumnStretch(0, 1) runtime_grid.setColumnStretch(1, 1) 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,API 地址、密钥与模型配置已经保存。", "双屏环境可以使用不同缩放比例,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.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(), } 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) 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) 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 lines.append(f"{heading}\n{message.get('content') or ''}") 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) backendSyncFinished = 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 人格与能力", "统一管理模型、客服身份、上下文和 MCP 工具。")) import ai_config remote, remote_layout = _card( "后台同步", "在管理后台配置模型后,本机启动及运行期间会自动获取并应用。", ) remote_row = QHBoxLayout() remote_copy = QVBoxLayout() self.remote_status = _label("正在读取后台连接…", "WarningText") self.remote_meta = _label("", "CardSubtitle") remote_copy.addWidget(self.remote_status) remote_copy.addWidget(self.remote_meta) remote_row.addLayout(remote_copy, 1) self.logout_backend_button = _button("退出后台") self.logout_backend_button.clicked.connect(self.logout_backend) remote_row.addWidget(self.logout_backend_button) self.manage_backend_button = _button("打开管理后台") self.manage_backend_button.clicked.connect(self.open_backend_page) remote_row.addWidget(self.manage_backend_button) self.sync_backend_button = _button("立即同步") self.sync_backend_button.clicked.connect(lambda: self.sync_from_backend(silent=False)) remote_row.addWidget(self.sync_backend_button) self.login_backend_button = _button("登录后台", "primary") self.login_backend_button.clicked.connect(self.open_backend_login) remote_row.addWidget(self.login_backend_button) remote_layout.addLayout(remote_row) layout.addWidget(remote) overview, overview_layout = _card("能力开关", "关闭 AI 后将使用通用设置中的固定回复。") switch_grid = QGridLayout() self.ai_enabled = QCheckBox("启用 AI 回复") self.ai_enabled.setChecked(bool(ai_config.AI_ENABLED)) self.context_enabled = QCheckBox("启用会话上下文") self.context_enabled.setChecked(bool(ai_config.AI_CONTEXT_ENABLED)) self.counter_enabled = QCheckBox("启用反辱骂策略") self.counter_enabled.setChecked(bool(ai_config.AI_COUNTER_INSULT_ENABLED)) self.mcp_enabled = QCheckBox("启用 MCP 工具") self.mcp_enabled.setChecked(bool(ai_config.AI_MCP_ENABLED)) for index, switch in enumerate( (self.ai_enabled, self.context_enabled, self.counter_enabled, self.mcp_enabled) ): switch_grid.addWidget(switch, index // 2, index % 2) overview_layout.addLayout(switch_grid) layout.addWidget(overview) identity, identity_layout = _card("模型与身份", "保存后立即写入 ai_settings.json。") form = QGridLayout() form.setHorizontalSpacing(18) form.setVerticalSpacing(8) self.api_base = self._field(form, 0, 0, "API 地址", ai_config.AI_API_BASE) self.model = self._field(form, 0, 1, "模型名称", ai_config.AI_MODEL) self.api_key = self._field(form, 2, 0, "API Key", ai_config.AI_API_KEY) self.api_key.setEchoMode(QLineEdit.Password) self.agent_name = self._field(form, 2, 1, "客服名称", ai_config.AI_AGENT_NAME) self.hospital = self._field(form, 4, 0, "机构名称", ai_config.AI_HOSPITAL_NAME, 1, 2) 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"), 6, 0) form.addWidget(_label("最大回复 tokens", "CardSubtitle"), 6, 1) form.addWidget(self.rounds, 7, 0) form.addWidget(self.max_tokens, 7, 1) form.addWidget(_label("温度", "CardSubtitle"), 8, 0) form.addWidget(self.temperature, 9, 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("保存 AI 配置", "primary") self.save_button.clicked.connect(self.save_config) actions.addWidget(self.save_button) layout.addLayout(actions) layout.addStretch(1) self._backend_sync_running = False self._backend_sync_silent = False self.backendSyncFinished.connect(self._backend_sync_finished) self._managed_widgets = ( self.ai_enabled, self.context_enabled, self.counter_enabled, self.mcp_enabled, self.api_base, self.model, self.api_key, self.agent_name, self.hospital, self.rounds, self.max_tokens, self.temperature, self.mcp_json, self.mcp_rounds, self.save_button, ) self.refresh_backend_status() @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: import backend_client remote_managed = backend_client.is_configured() and bool( backend_client.load_settings().get("auto_sync", True) ) except Exception: remote_managed = False if remote_managed: 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) return try: servers = json.loads(self.mcp_json.toPlainText().strip() or "[]") if not isinstance(servers, list): raise ValueError("MCP JSON 根节点必须是数组") import ai_config ai_config.AI_ENABLED = self.ai_enabled.isChecked() ai_config.AI_CONTEXT_ENABLED = self.context_enabled.isChecked() ai_config.AI_COUNTER_INSULT_ENABLED = self.counter_enabled.isChecked() ai_config.AI_MCP_ENABLED = self.mcp_enabled.isChecked() ai_config.AI_API_BASE = self.api_base.text().strip() ai_config.AI_API_KEY = self.api_key.text().strip() ai_config.AI_MODEL = self.model.text().strip() ai_config.AI_AGENT_NAME = self.agent_name.text().strip() ai_config.AI_HOSPITAL_NAME = self.hospital.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 open_backend_login(self) -> None: dialog = BackendLoginDialog(self) if dialog.exec() == QDialog.Accepted: self.reload_from_ai_config() self.refresh_backend_status() self.saved.emit(True, "后台已登录,模型配置已自动同步") def open_backend_page(self) -> None: try: import backend_client url = backend_client.load_settings().get( "server_url", backend_client.DEFAULT_SERVER_URL ) QDesktopServices.openUrl(QUrl(str(url))) except Exception as exc: self.saved.emit(False, f"无法打开管理后台:{exc}") def logout_backend(self) -> None: answer = QMessageBox.question( self, "退出配置后台", "退出后将停止自动同步,但已下发到本机的配置会继续保留。确定退出吗?", ) if answer != QMessageBox.Yes: return try: import backend_client backend_client.logout() except Exception as exc: self.saved.emit(False, f"退出后台失败:{exc}") return self.refresh_backend_status() self.saved.emit(True, "已退出配置后台,当前配置保留在本机") def refresh_backend_status(self) -> None: try: import backend_client summary = backend_client.connection_summary() except Exception as exc: self.remote_status.setObjectName("DangerText") self.remote_status.setText(f"后台连接配置读取失败:{exc}") return configured = bool(summary["configured"]) authenticated = bool(summary.get("authenticated")) managed = configured and bool(summary["auto_sync"]) if configured: last_error = str(summary.get("last_error") or "") self.remote_status.setObjectName("DangerText" if last_error else "SuccessText") self.remote_status.setText( f"最近同步失败 · {last_error}" if last_error else ( f"已连接 · {summary['username']} · 配置 v{summary['last_version']}" if authenticated else f"已发现本机后台 · 配置 v{summary['last_version']}" ) ) sync_at = summary.get("last_sync_at") or "尚未同步" self.remote_meta.setText(f"{summary['server_url']} · 最后同步 {sync_at}") else: self.remote_status.setObjectName("WarningText") self.remote_status.setText("尚未登录配置后台") self.remote_meta.setText(f"默认地址 {summary['server_url']} · 当前使用本机配置") self.remote_status.style().unpolish(self.remote_status) self.remote_status.style().polish(self.remote_status) self.login_backend_button.setVisible(not authenticated) self.login_backend_button.setText("账号登录" if configured else "登录后台") self.logout_backend_button.setVisible(authenticated) self.sync_backend_button.setVisible(configured) for widget in self._managed_widgets: widget.setEnabled(not managed) self.save_status.setText("后台统一管理中,本地字段为只读" if managed else "") self.save_status.setObjectName("SuccessText" if managed else "CardSubtitle") self.save_status.style().unpolish(self.save_status) self.save_status.style().polish(self.save_status) def sync_from_backend(self, *, silent: bool = True) -> None: if self._backend_sync_running: return try: import backend_client if not backend_client.is_configured(): return except Exception as exc: if not silent: self.saved.emit(False, f"后台连接配置读取失败:{exc}") return self._backend_sync_running = True self._backend_sync_silent = silent self.sync_backend_button.setEnabled(False) self.remote_status.setObjectName("WarningText") self.remote_status.setText("正在从后台同步模型配置…") self.remote_status.style().unpolish(self.remote_status) self.remote_status.style().polish(self.remote_status) def worker() -> None: try: result = backend_client.sync_config(force=not silent) message = str(result.get("message") or "后台配置同步完成") except Exception as exc: self.backendSyncFinished.emit(False, str(exc)) else: self.backendSyncFinished.emit(True, message) threading.Thread(target=worker, daemon=True).start() def _backend_sync_finished(self, ok: bool, message: str) -> None: silent = self._backend_sync_silent self._backend_sync_running = False self.sync_backend_button.setEnabled(True) if ok: self.reload_from_ai_config() self.refresh_backend_status() if not silent or not ok: self.saved.emit(ok, message if ok else f"后台同步失败:{message}") def reload_from_ai_config(self) -> None: import ai_config self.ai_enabled.setChecked(bool(ai_config.AI_ENABLED)) self.context_enabled.setChecked(bool(ai_config.AI_CONTEXT_ENABLED)) self.counter_enabled.setChecked(bool(ai_config.AI_COUNTER_INSULT_ENABLED)) self.mcp_enabled.setChecked(bool(ai_config.AI_MCP_ENABLED)) self.api_base.setText(str(ai_config.AI_API_BASE or "")) self.model.setText(str(ai_config.AI_MODEL or "")) self.api_key.setText(str(ai_config.AI_API_KEY or "")) self.agent_name.setText(str(ai_config.AI_AGENT_NAME or "")) self.hospital.setText(str(ai_config.AI_HOSPITAL_NAME or "")) 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 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("06", "运行日志", "实时查看连接、识别、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.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 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(430, 82) self._drag_origin: QPoint | None = None self._window_origin = QPoint() outer = QVBoxLayout(self) outer.setContentsMargins(12, 8, 12, 14) panel = QFrame() panel.setObjectName("CapsulePanel") panel.setStyleSheet( "QFrame#CapsulePanel{background:#FFFFFF;border:1px solid #CFE0D8;border-radius:16px;}" "QFrame#CapsulePanel QLabel{background:transparent;border:none;}" ) _shadow(panel, 28, 7, 38) row = QHBoxLayout(panel) row.setContentsMargins(17, 10, 10, 10) row.setSpacing(11) grip = QLabel("•••") grip.setAlignment(Qt.AlignCenter) grip.setFixedSize(38, 38) grip.setStyleSheet( "QLabel{background:#F0F6F3;border:1px solid #D8E6DF;border-radius:19px;" "color:#789087;font-family:'Cascadia Mono';font-weight:700;}" ) row.addWidget(grip) self.dot = QLabel("●") self.dot.setStyleSheet("QLabel{color:#10A57A;font-size:17px;}") row.addWidget(self.dot) copy = QVBoxLayout() copy.setSpacing(1) self.status = QLabel("监听中") self.status.setStyleSheet( "QLabel{color:#087A59;font-size:14px;font-weight:700;}" ) self.hint = QLabel("安全运行中") self.hint.setStyleSheet("QLabel{color:#7B8D84;font-size:11px;}") copy.addWidget(self.status) copy.addWidget(self.hint) row.addLayout(copy, 1) self.timer = QLabel("00:00:00") self.timer.setAlignment(Qt.AlignCenter) self.timer.setMinimumWidth(82) self.timer.setStyleSheet( "QLabel{background:#F2F7F4;border:1px solid #DCE8E2;border-radius:9px;" "padding:7px 9px;font-family:'Cascadia Mono';font-size:12px;color:#30473D;}" ) row.addWidget(self.timer) expand = QPushButton("↗") expand.setToolTip("展开控制台") expand.setCursor(Qt.PointingHandCursor) expand.setFixedSize(34, 34) expand.setStyleSheet( "QPushButton{background:#E7F4EE;color:#087A59;border:1px solid #CFE8DD;" "border-radius:8px;font-size:17px;font-weight:700;}" "QPushButton:hover{background:#D4EFE4;border-color:#AEDCC9;}" "QPushButton:pressed{background:#C5E8D9;}" ) expand.clicked.connect(self.expandRequested) row.addWidget(expand) stop = QPushButton("×") stop.setToolTip("停止监听") stop.setCursor(Qt.PointingHandCursor) stop.setFixedSize(34, 34) stop.setStyleSheet( "QPushButton{background:#FBEAEC;color:#C74452;border:1px solid #F2CDD2;" "border-radius:8px;font-size:18px;}" "QPushButton:hover{background:#D85260;color:#FFFFFF;border-color:#D85260;}" ) stop.clicked.connect(self.stopRequested) row.addWidget(stop) outer.addWidget(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": "#10A57A", "waiting": "#B67820", "connecting": "#B67820", "stopping": "#B67820", "error": "#D85260", } hints = { "running": "安全运行中", "waiting": "等待企业微信", "connecting": "正在连接窗口", "stopping": "正在结束任务", "error": "请查看运行日志", "stopped": "监听已停止", } color = colors.get(state, "#7B8D84") self.dot.setStyleSheet(f"QLabel{{color:{color};font-size:17px;}}") self.status.setText(text) self.status.setStyleSheet( f"QLabel{{color:{color};font-size:14px;font-weight:700;}}" ) self.hint.setText(hints.get(state, 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", "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.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.log_page = LogPage() for page in ( self.portal_page, self.dashboard_page, self.settings_page, self.business_page, self.persona_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() 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_backend(silent=True) ) self.backend_sync_timer.start() QTimer.singleShot(350, lambda: self.persona_page.sync_from_backend(silent=True)) 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.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, } 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["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: 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 == 0: QTimer.singleShot(80, lambda: self.portal_page.view.setFocus(Qt.OtherFocusReason)) 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"]), ) 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": "连接失败,请查看运行日志", "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) def enter_capsule_mode(self) -> None: if self.capsule.isVisible(): return self._saved_geometry = self.saveGeometry() self._was_maximized = self.isMaximized() 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() 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 == "stopped": self._finish_thread("stopped") 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))) 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() event.accept() def _app_icon() -> QIcon: 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 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) try: import backend_client backend_client.startup_sync_config(timeout=3.0) except Exception: # 启动检测失败不阻止软件打开,具体原因会显示在后台同步卡片中。 pass window = MainWindow() console_shutdown = install_console_shutdown_handler(app, window) window.show() 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()