Files
kefu/wechat_rpa/wechat_gui_qt.py
2026-08-27 14:04:28 +08:00

11019 lines
489 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 math
import os
import queue
import re
import shutil
import signal
import sys
import threading
import time
from pathlib import Path
from PySide6.QtCore import (
QEvent,
QObject,
QPoint,
QPointF,
QRect,
QSize,
QTimer,
Qt,
QUrl,
Signal,
Slot,
)
from PySide6.QtGui import (
QBrush,
QColor,
QDesktopServices,
QFont,
QFontDatabase,
QFontMetrics,
QIcon,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QPixmap,
QRadialGradient,
)
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QBoxLayout,
QCheckBox,
QComboBox,
QDialog,
QDoubleSpinBox,
QFileDialog,
QInputDialog,
QFrame,
QGraphicsDropShadowEffect,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QStyle,
QStyleOption,
QStyleOptionButton,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QVBoxLayout,
QWidget,
)
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEngineSettings
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_foreign_draft_autosend_minutes,
normalize_message_batch_window_seconds,
normalize_send_delay_seconds,
normalize_send_mode,
)
from gui_runtime import (
FOREIGN_DRAFT_AUTOSEND_MAX_MINUTES,
FOREIGN_DRAFT_AUTOSEND_MIN_MINUTES,
FOREIGN_DRAFT_AUTOSEND_MINUTES,
MESSAGE_BATCH_WINDOW_SECONDS,
MESSAGE_BATCH_WINDOW_MAX_SECONDS as _BATCH_MAX,
MESSAGE_BATCH_WINDOW_MIN_SECONDS as _BATCH_MIN,
SEND_DELAY_MAX_SECONDS,
SEND_DELAY_MIN_SECONDS,
SEND_DELAY_SECONDS,
SEND_MODE_AUTO,
SEND_MODE_REVIEW,
)
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"
UI_VERSION = "2.0"
WIN_TITLE = f"甄养堂 · 企业微信 AI 自动回复 v{UI_VERSION}"
APP_ICON_PATH = resource_path("assets", "brand", "zhenyangtang-icon.png")
WECOM_WAITING_MESSAGE = (
"企业微信主界面未显示、未在前台或已最小化,正在尝试切到前台;"
"本轮失败时下一轮将自动重试。"
)
COLORS = {
"canvas": "#F3F6FF",
"surface": "#FFFFFF",
"surface_alt": "#F8FAFF",
"sidebar": "#F8FAFF",
"sidebar_alt": "#EEF3FF",
"ink": "#101E49",
"muted": "#65729A",
"faint": "#98A3C2",
"line": "#DFE6F6",
"line_soft": "#EDF1FA",
"accent": "#316CFF",
"accent_dark": "#1951D8",
"accent_soft": "#EAF0FF",
"success": "#11AF7B",
"warning": "#F19B38",
"danger": "#F05252",
"danger_soft": "#FFF0F0",
}
UI_FONT_FAMILIES = (
"HarmonyOS Sans SC",
"Microsoft YaHei UI",
"Microsoft YaHei",
"Segoe UI",
)
NUMERIC_FONT_FAMILIES = (
"Inter",
"HarmonyOS Sans SC",
"Microsoft YaHei UI",
"Segoe UI",
)
BUNDLED_FONT_FILES = (
"HarmonyOS_Sans_SC_Regular.ttf",
"HarmonyOS_Sans_SC_Medium.ttf",
"HarmonyOS_Sans_SC_Bold.ttf",
"Inter-SemiBold.ttf",
)
def _register_bundled_fonts() -> tuple[str, ...]:
"""Register the exact design fonts for source and packaged app runs."""
loaded_families: set[str] = set()
for filename in BUNDLED_FONT_FILES:
font_path = resource_path("assets", "fonts", filename)
if not font_path.is_file():
continue
font_id = QFontDatabase.addApplicationFont(str(font_path))
if font_id >= 0:
loaded_families.update(QFontDatabase.applicationFontFamilies(font_id))
return tuple(sorted(loaded_families))
DESIGN_WIDTH = 1630
DESIGN_HEIGHT = 920
_UI_SCALE = 1.0
_UI_MIN_SCALE = 0.70
_UI_MAX_SCALE = 1.60
_UI_LAST_APPLIED = 0.0
_UI_RESCALING = False
_UI_HINT_ROLE = Qt.UserRole + 64
_UI_ORIG: dict = {}
_CSS_PX = re.compile(r"(-?\d+(?:\.\d+)?)px")
def ui_px(value: float, *, allow_zero: bool = False) -> int:
"""Convert a design-canvas pixel to the current window scale."""
scaled = float(value) * _UI_SCALE
if abs(scaled) < 0.001:
return 0
rounded = int(round(scaled))
if value > 0 and not allow_zero:
return max(1, rounded)
return rounded
def ui_scale_factor() -> float:
return _UI_SCALE
def _scale_from_window(width: int, height: int) -> float:
if width <= 0 or height <= 0:
return 1.0
return max(
_UI_MIN_SCALE,
min(_UI_MAX_SCALE, min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT)),
)
def _scale_css(css: str) -> str:
if not css or abs(_UI_SCALE - 1.0) < 0.0005:
return css
def repl(match: re.Match) -> str:
value = float(match.group(1))
if value == 0:
return "0px"
scaled = value * _UI_SCALE
if abs(scaled - round(scaled)) < 0.05:
number = int(round(scaled))
if value > 0:
number = max(1, number)
elif value < 0:
number = min(-1, number)
return f"{number}px"
return f"{scaled:.2f}px"
return _CSS_PX.sub(repl, css)
def _ui_font(
point_size: int,
weight: QFont.Weight = QFont.Weight.Normal,
*,
numeric: bool = False,
) -> QFont:
"""Build the design-system font with deterministic Windows fallbacks."""
font = QFont()
font.setFamilies(NUMERIC_FONT_FAMILIES if numeric else UI_FONT_FAMILIES)
font.setPointSizeF(max(6.0, float(point_size) * _UI_SCALE))
font.setWeight(weight)
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
return font
def _paint_font(
pixel_size: float,
weight: QFont.Weight = QFont.Weight.Normal,
*,
numeric: bool = False,
) -> QFont:
"""Pixel-sized font for painter-drawn text that already lives in widget space."""
font = QFont()
font.setFamilies(NUMERIC_FONT_FAMILIES if numeric else UI_FONT_FAMILIES)
font.setPixelSize(max(8, int(round(pixel_size))))
font.setWeight(weight)
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
return font
APP_QSS = """
* {
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI";
font-size: 14px;
font-weight: 400;
color: #101E49;
}
QMainWindow, QWidget#PageRoot, QScrollArea#PageScroll,
QScrollArea#PageScroll > QWidget > QWidget { background: transparent; }
QWidget#AppRoot { background: transparent; }
QMainWindow { background: #EAF0FA; }
QDialog#SessionDetailDialog { background: #F3F6FF; }
QFrame#Sidebar {
background: rgba(255, 255, 255, 168);
border: 1px solid rgba(255, 255, 255, 210);
border-radius: 28px;
}
QFrame#Sidebar QLabel { color: #65729A; }
QFrame#Sidebar QLabel#Brand {
color: #FFFFFF;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #23BFF4, stop:0.48 #316CFF, stop:1 #8A55F7);
border: 1px solid rgba(255,255,255,160);
border-radius: 10px;
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei";
font-size: 22px;
font-weight: 700;
padding: 2px 0px 4px 0px;
}
QFrame#Sidebar QLabel#BrandSub { color: #7783A8; font-size: 11px; }
QFrame#Sidebar QLabel#SectionLabel {
color: #A4AEC9;
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI";
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
}
QFrame#SideStatus {
background: #F3F7FF;
border: 1px solid #DEE7FA;
border-radius: 15px;
}
QFrame#Sidebar QLabel#SideStatusTitle { color: #1951D8; font-size: 13px; font-weight: 600; min-height: 20px; padding-bottom: 2px; }
QFrame#Sidebar QLabel#SideStatusHint { color: #8490B2; font-size: 10px; }
QPushButton#NavButton {
background: transparent;
color: #5F6C94;
border: 1px solid transparent;
border-radius: 22px;
text-align: center;
padding: 6px 3px;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
QPushButton#NavButton:hover { background: transparent; }
QPushButton#NavButton:checked {
background: transparent;
color: #245DE7;
border: 1px solid transparent;
font-weight: 600;
}
QFrame#DeskRail {
background: rgba(255,255,255,220);
border: none;
border-right: 1px solid #DFE6F6;
}
QListWidget#DeskSessionList {
background: transparent;
border: none;
outline: none;
}
QListWidget#DeskSessionList::item {
background: transparent;
border: none;
padding: 0px;
margin: 3px 8px;
}
QLabel#DeskSessionTitle { color: #101E49; font-size: 14px; font-weight: 600; }
QLabel#DeskSessionPreview { color: #8995B6; font-size: 12px; }
QFrame#DeskSessionCard {
background: transparent;
border-radius: 12px;
padding: 2px;
}
QListWidget#DeskSessionList::item:selected QFrame#DeskSessionCard {
background: #EAF0FF;
}
QPlainTextEdit#DeskComposer { min-height: 84px; }
QPushButton#PrimaryButton, QPushButton#SidePrimary {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #2EC6F4, stop:0.42 #4A72FF, stop:1 #8B5CF6);
color: #FFFFFF;
border: 1px solid rgba(255,255,255,150);
border-radius: 18px;
padding: 10px 20px;
font-weight: 600;
}
QPushButton[headerAction="true"] {
border-radius: 22px;
padding: 0px 18px;
min-height: 36px;
min-width: 80px;
font-size: 14px;
}
QPushButton#SecondaryButton[headerAction="true"] { color:#245DE7; }
QPushButton#PrimaryButton:hover, QPushButton#SidePrimary:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #45D0F8, stop:0.42 #5B82FF, stop:1 #9A6CFF);
}
QPushButton#PrimaryButton:pressed, QPushButton#SidePrimary:pressed {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #1BAED8, stop:0.42 #3A62EE, stop:1 #7748E2);
}
QPushButton#PrimaryButton:disabled, QPushButton#SidePrimary:disabled {
background: #BFC9E5; color: #F7F9FF;
}
QPushButton#SecondaryButton {
background: rgba(255,255,255,236);
color: #33446F;
border: 1px solid rgba(210, 222, 248, 220);
border-radius: 16px;
padding: 4px 14px;
}
QPushButton#SecondaryButton:hover { background: #F0F4FF; color:#245DE7; border-color: #BBCBFA; }
QPushButton#SecondaryButton:pressed { background:#E5ECFF; }
QPushButton#SecondaryButton:checked {
background:#EAF0FF; color:#245DE7; border-color:#CCD9FF; font-weight:600;
}
QPushButton#SecondaryButton[taskFilter="true"]:checked {
background:#3F72F5; color:#FFFFFF; border-color:#3F72F5; font-weight:600;
}
QPushButton#SecondaryButton[taskFilter="true"] {
padding: 0px 14px;
min-height: 36px;
}
QPushButton#SecondaryButton[personaTab="true"]:checked {
background:#FFFFFF; color:#245DE7; border-color:transparent; font-weight:600;
}
QPushButton#DangerButton {
background: #FFF1F1;
color: #E44343;
border: 1px solid #FFD1D1;
border-radius: 15px;
padding: 4px 16px;
font-weight: 600;
}
QPushButton#DangerButton:hover { background: #F05252; color: #FFFFFF; }
QFrame#Card, QFrame#HeroCard, QFrame#MetricCard, QFrame#SoftPanel,
QFrame#InfoTile, QFrame#WorkflowStep, QFrame#GlassBand, QFrame#CompactRow,
QFrame#ActivityTile, QFrame#MiniCard, QFrame#FilterBar {
background: rgba(255,255,255,198);
border: 1px solid rgba(255,255,255,230);
border-radius: 24px;
}
QFrame#CompactRow, QFrame#ActivityTile, QFrame#MiniCard {
background: rgba(255,255,255,168);
border: 1px solid rgba(232, 238, 252, 200);
border-radius: 14px;
}
QFrame#FilterBar { background:rgba(255,255,255,188); border-radius:20px; }
QFrame#GlassBand {
background: qlineargradient(x1:0,y1:0,x2:1,y2:1,
stop:0 rgba(255,255,255,228), stop:0.55 rgba(248,251,255,210), stop:1 rgba(244,240,255,198));
}
QFrame#MetricCard {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 rgba(255,255,255,236), stop:1 rgba(236,243,255,210));
}
QFrame#HeroCard {
background: qradialgradient(cx:0.84, cy:0.22, radius:1.0,
stop:0 #EBF2FF, stop:0.42 #FFFFFF, stop:1 #F8FBFF);
}
QFrame#CustomerBubble {
background: #FFFFFF;
border: 1px solid #E0E6F4;
border-radius: 14px;
}
QFrame#AssistantBubble {
background: #EDF2FF;
border: 1px solid #D2DEFF;
border-radius: 14px;
}
QFrame#SystemBubble {
background: #F1F4FA;
border: 1px solid #E0E6F4;
border-radius: 11px;
}
QLabel#MessageRole { color: #245DE7; font-size: 12px; font-weight: 600; }
QLabel#MessageTime { color: #A1ABC5; font-size: 11px; }
QLabel#MessageContent { color: #15244E; font-size: 14px; }
QLabel#Eyebrow {
color: #316CFF;
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI";
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
}
QLabel#PageTitle {
color: #0A1744;
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei";
font-size: 34px;
font-weight: 700;
letter-spacing:0px;
min-height: 48px;
padding-top: 2px;
padding-bottom: 6px;
}
QLabel#PageSubtitle { color: #69769C; font-size: 16px; min-height: 26px; padding-top: 1px; padding-bottom: 4px; }
QLabel#CardTitle { color: #14234F; font-size: 17px; font-weight: 600; min-height: 26px; padding-top: 1px; padding-bottom: 4px; }
QLabel#CardSubtitle { color: #5F6D94; font-size: 14px; min-height: 22px; padding-top: 1px; padding-bottom: 3px; }
QLabel#MetricValue {
color: #1951D8;
font-family: "Inter", "HarmonyOS Sans SC", "Microsoft YaHei UI", "Segoe UI";
font-size: 24px;
font-weight: 600;
min-height: 36px;
padding-top: 1px;
padding-bottom: 4px;
}
QLabel#MetricLabel { color: #53628B; font-size: 14px; min-height: 22px; padding-top: 1px; padding-bottom: 3px; }
QLabel#MetricMeta { color: #8793B4; font-size: 12px; min-height: 18px; padding-top: 1px; padding-bottom: 2px; }
QLabel#HeroStatus {
color: #0E1B48;
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei";
font-size: 29px;
font-weight: 700;
min-height: 42px;
padding-top: 2px;
padding-bottom: 4px;
}
QLabel#HeroHint { color: #69769C; font-size: 13px; }
QLabel#SuccessText { color: #11A878; font-weight: 600; }
QLabel#WarningText { color: #E48B2B; font-weight: 600; }
QLabel#BlueText { color: #245DE7; font-weight: 600; }
QLabel#DangerText { color: #E44949; font-weight: 600; }
QLabel#StatusChip {
background:#E8F8F2; color:#0B9A6C; border:1px solid #C5EEDF;
border-radius:16px; padding:6px 12px; font-weight:600;
}
QLabel#BlueChip {
background:#EEF3FF; color:#2F62F0; border:1px solid #D5E0FF;
border-radius:16px; padding:6px 12px; font-weight:600;
}
QLabel#WarningChip {
background:#FFF6E8; color:#E58924; border:1px solid #FFE2B8;
border-radius:16px; padding:6px 12px; font-weight:600;
}
QLabel#PurpleChip {
background:#F1EDFF; color:#7357E8; border:1px solid #E2D9FF;
border-radius:16px; padding:6px 12px; font-weight:600;
}
QLabel[headerChip="true"] {
border-radius: 22px;
padding: 0px 10px;
min-height: 36px;
min-width: 80px;
font-size: 14px;
}
QLabel[roundBadge="true"] {
background: transparent;
border: none;
border-radius: 26px;
padding: 0px;
}
QLineEdit, QPlainTextEdit, QTextEdit, QSpinBox, QDoubleSpinBox {
background: rgba(255,255,255,230);
color: #172650;
border: 1px solid #DDE4F3;
border-radius: 12px;
padding: 9px 11px;
selection-background-color: #C9D8FF;
font-weight: 400;
}
QComboBox {
background:rgba(255,255,255,230); color:#172650; border:1px solid #DDE4F3;
border-radius:12px; padding:8px 11px;
font-weight:400;
}
QComboBox::drop-down { border:none; width:24px; }
QComboBox::down-arrow { image:none; width:0px; height:0px; }
QSpinBox::up-button, QSpinBox::down-button,
QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {
border:none; width:0px; height:0px;
}
QSlider::groove:horizontal {
height:6px; background:#DDE5F4; border:none; border-radius:3px;
}
QSlider::sub-page:horizontal {
background:qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #316CFF,stop:1 #6E7AF6);
border-radius:3px;
}
QSlider::handle:horizontal {
width:18px; height:18px; margin:-6px 0;
border:2px solid #D6E2FF; border-radius:9px;
background:qradialgradient(cx:.36,cy:.32,radius:.85,stop:0 #8CC9FF,stop:.45 #4C87F7,stop:1 #5D66EC);
}
QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus {
background: #FFFFFF;
border: 2px solid #5F86F8;
padding: 8px 10px;
}
QLineEdit[readOnly="true"] { background: #F1F4FA; color: #7E89A9; }
QCheckBox { spacing: 9px; font-size: 12px; }
QCheckBox::indicator {
width: 42px; height: 22px;
border: 1px solid #C8D1E6;
border-radius: 11px;
background: qradialgradient(cx:0.26, cy:0.50, radius:0.58,
stop:0 #FFFFFF, stop:0.43 #FFFFFF, stop:0.47 #D4DBEA, stop:0.50 #C9D1E5, stop:1 #C9D1E5);
}
QCheckBox::indicator:checked {
border: 1px solid #2E68F5;
background: qradialgradient(cx:0.74, cy:0.50, radius:0.58,
stop:0 #FFFFFF, stop:0.43 #FFFFFF, stop:0.47 #6A95FF, stop:0.50 #316CFF, stop:1 #316CFF);
}
QTableWidget {
background: rgba(255,255,255,205);
alternate-background-color: #F7F9FF;
border: 1px solid #E1E7F4;
border-radius: 14px;
gridline-color: #EEF1F8;
selection-background-color: #E5EDFF;
selection-color: #12224E;
}
QHeaderView::section {
background: #F2F5FC;
color: #637097;
border: none;
border-bottom: 1px solid #E1E7F4;
padding: 10px;
font-weight: 600;
}
QScrollBar:vertical { width: 10px; background: transparent; margin: 3px; }
QScrollBar::handle:vertical { background: #C8D3EC; border-radius: 4px; min-height: 30px; }
QScrollBar::handle:vertical:hover { background: #9DB1DF; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
QProgressBar { background: transparent; border: none; max-height: 3px; }
QProgressBar::chunk { background: #316CFF; }
QToolTip {
background: #172650; color: #FFFFFF; border: none; padding: 7px;
}
"""
def _is_main_window(widget: QWidget) -> bool:
return isinstance(widget, QMainWindow)
def _token_size(value) -> bool:
try:
number = int(value)
except (TypeError, ValueError):
return False
return 0 <= number < 10_000
def _parse_wh(args) -> tuple[int, int] | None:
if len(args) == 1 and isinstance(args[0], QSize):
return args[0].width(), args[0].height()
if len(args) == 2:
return int(args[0]), int(args[1])
return None
def _parse_margins(args) -> tuple[int, int, int, int] | None:
if len(args) == 4:
return int(args[0]), int(args[1]), int(args[2]), int(args[3])
if len(args) == 1:
margins = args[0]
try:
return margins.left(), margins.top(), margins.right(), margins.bottom()
except AttributeError:
return None
return None
def _install_ui_scale_hooks() -> None:
if _UI_ORIG:
return
def wrap_fixed_both(original, key_w: str, key_h: str, both_key: str):
def patched(self, *args):
parsed = _parse_wh(args)
if parsed is None or _is_main_window(self):
return original(self, *args)
width, height = parsed
if not _token_size(width) or not _token_size(height):
return original(self, *args)
if not _UI_RESCALING:
if _token_size(width):
self.setProperty(key_w, int(width))
if _token_size(height):
self.setProperty(key_h, int(height))
self.setProperty(both_key, True)
else:
stored_w = self.property(key_w)
stored_h = self.property(key_h)
width = int(stored_w) if stored_w is not None else width
height = int(stored_h) if stored_h is not None else height
return original(self, ui_px(width), ui_px(height))
return patched
def wrap_one_axis(original, key: str):
def patched(self, value):
if _is_main_window(self) or not _token_size(value):
return original(self, value)
if not _UI_RESCALING:
self.setProperty(key, int(value))
else:
stored = self.property(key)
value = int(stored) if stored is not None else value
return original(self, ui_px(value, allow_zero=True))
return patched
def wrap_stylesheet(original):
def patched(self, css):
text = "" if css is None else str(css)
if not _UI_RESCALING:
self.setProperty("uiCss", text)
raw = self.property("uiCss")
if raw is None:
raw = text
return original(self, _scale_css(raw))
return patched
def wrap_margins(original):
def patched(self, *args):
parsed = _parse_margins(args)
if parsed is None:
return original(self, *args)
left, top, right, bottom = parsed
if not _UI_RESCALING:
self.setProperty("uiML", left)
self.setProperty("uiMT", top)
self.setProperty("uiMR", right)
self.setProperty("uiMB", bottom)
else:
left = int(self.property("uiML") if self.property("uiML") is not None else left)
top = int(self.property("uiMT") if self.property("uiMT") is not None else top)
right = int(self.property("uiMR") if self.property("uiMR") is not None else right)
bottom = int(self.property("uiMB") if self.property("uiMB") is not None else bottom)
return original(
self,
ui_px(left, allow_zero=True),
ui_px(top, allow_zero=True),
ui_px(right, allow_zero=True),
ui_px(bottom, allow_zero=True),
)
return patched
def wrap_spacing(original, key: str):
def patched(self, value):
if value is None or int(value) < 0:
return original(self, value)
if not _UI_RESCALING:
self.setProperty(key, int(value))
else:
stored = self.property(key)
value = int(stored) if stored is not None else value
return original(self, ui_px(value, allow_zero=True))
return patched
def wrap_add_spacing(original):
def patched(self, size):
original(self, ui_px(size, allow_zero=True))
recorded = list(self.property("uiAddSpacings") or [])
recorded.append((self.count() - 1, int(size)))
self.setProperty("uiAddSpacings", recorded)
return patched
def wrap_item_hint(original):
def patched(self, size):
if not isinstance(size, QSize):
return original(self, size)
if not _UI_RESCALING:
self.setData(_UI_HINT_ROLE, QSize(size.width(), size.height()))
design = self.data(_UI_HINT_ROLE)
if isinstance(design, QSize):
size = QSize(ui_px(design.width()), ui_px(design.height()))
return original(self, size)
return patched
def wrap_section_size(original, key: str):
def patched(self, value):
if not _token_size(value):
return original(self, value)
if not _UI_RESCALING:
self.setProperty(key, int(value))
else:
stored = self.property(key)
value = int(stored) if stored is not None else value
return original(self, ui_px(value))
return patched
_UI_ORIG["fixedSize"] = QWidget.setFixedSize
_UI_ORIG["fixedWidth"] = QWidget.setFixedWidth
_UI_ORIG["fixedHeight"] = QWidget.setFixedHeight
_UI_ORIG["minSize"] = QWidget.setMinimumSize
_UI_ORIG["minWidth"] = QWidget.setMinimumWidth
_UI_ORIG["minHeight"] = QWidget.setMinimumHeight
_UI_ORIG["maxSize"] = QWidget.setMaximumSize
_UI_ORIG["maxWidth"] = QWidget.setMaximumWidth
_UI_ORIG["maxHeight"] = QWidget.setMaximumHeight
_UI_ORIG["styleSheet"] = QWidget.setStyleSheet
_UI_ORIG["widgetMargins"] = QWidget.setContentsMargins
_UI_ORIG["layoutMargins"] = QLayout.setContentsMargins
_UI_ORIG["layoutSpacing"] = QLayout.setSpacing
_UI_ORIG["gridHSpacing"] = QGridLayout.setHorizontalSpacing
_UI_ORIG["gridVSpacing"] = QGridLayout.setVerticalSpacing
_UI_ORIG["addSpacing"] = QBoxLayout.addSpacing
_UI_ORIG["itemHint"] = QListWidgetItem.setSizeHint
_UI_ORIG["sectionSize"] = QHeaderView.setDefaultSectionSize
QWidget.setFixedSize = wrap_fixed_both(_UI_ORIG["fixedSize"], "uiFixW", "uiFixH", "uiFixBoth")
QWidget.setFixedWidth = wrap_one_axis(_UI_ORIG["fixedWidth"], "uiFixW")
QWidget.setFixedHeight = wrap_one_axis(_UI_ORIG["fixedHeight"], "uiFixH")
QWidget.setMinimumSize = wrap_fixed_both(_UI_ORIG["minSize"], "uiMinW", "uiMinH", "uiMinBoth")
QWidget.setMinimumWidth = wrap_one_axis(_UI_ORIG["minWidth"], "uiMinW")
QWidget.setMinimumHeight = wrap_one_axis(_UI_ORIG["minHeight"], "uiMinH")
QWidget.setMaximumSize = wrap_fixed_both(_UI_ORIG["maxSize"], "uiMaxW", "uiMaxH", "uiMaxBoth")
QWidget.setMaximumWidth = wrap_one_axis(_UI_ORIG["maxWidth"], "uiMaxW")
QWidget.setMaximumHeight = wrap_one_axis(_UI_ORIG["maxHeight"], "uiMaxH")
QWidget.setStyleSheet = wrap_stylesheet(_UI_ORIG["styleSheet"])
QWidget.setContentsMargins = wrap_margins(_UI_ORIG["widgetMargins"])
QLayout.setContentsMargins = wrap_margins(_UI_ORIG["layoutMargins"])
QLayout.setSpacing = wrap_spacing(_UI_ORIG["layoutSpacing"], "uiSpace")
QGridLayout.setHorizontalSpacing = wrap_spacing(_UI_ORIG["gridHSpacing"], "uiGridH")
QGridLayout.setVerticalSpacing = wrap_spacing(_UI_ORIG["gridVSpacing"], "uiGridV")
QBoxLayout.addSpacing = wrap_add_spacing(_UI_ORIG["addSpacing"])
QListWidgetItem.setSizeHint = wrap_item_hint(_UI_ORIG["itemHint"])
QHeaderView.setDefaultSectionSize = wrap_section_size(_UI_ORIG["sectionSize"], "uiSection")
def _replay_widget_metrics(widget: QWidget) -> None:
if _is_main_window(widget):
return
css = widget.property("uiCss")
if css:
_UI_ORIG["styleSheet"](widget, _scale_css(css))
if widget.property("uiFixBoth") and widget.property("uiFixW") is not None and widget.property("uiFixH") is not None:
_UI_ORIG["fixedSize"](widget, ui_px(int(widget.property("uiFixW"))), ui_px(int(widget.property("uiFixH"))))
else:
if widget.property("uiFixW") is not None:
_UI_ORIG["fixedWidth"](widget, ui_px(int(widget.property("uiFixW"))))
if widget.property("uiFixH") is not None:
_UI_ORIG["fixedHeight"](widget, ui_px(int(widget.property("uiFixH"))))
if widget.property("uiMinBoth") and widget.property("uiMinW") is not None and widget.property("uiMinH") is not None:
_UI_ORIG["minSize"](widget, ui_px(int(widget.property("uiMinW"))), ui_px(int(widget.property("uiMinH"))))
else:
if widget.property("uiMinW") is not None:
_UI_ORIG["minWidth"](widget, ui_px(int(widget.property("uiMinW"))))
if widget.property("uiMinH") is not None:
_UI_ORIG["minHeight"](widget, ui_px(int(widget.property("uiMinH"))))
if widget.property("uiMaxBoth") and widget.property("uiMaxW") is not None and widget.property("uiMaxH") is not None:
_UI_ORIG["maxSize"](widget, ui_px(int(widget.property("uiMaxW"))), ui_px(int(widget.property("uiMaxH"))))
else:
if widget.property("uiMaxW") is not None:
_UI_ORIG["maxWidth"](widget, ui_px(int(widget.property("uiMaxW"))))
if widget.property("uiMaxH") is not None:
_UI_ORIG["maxHeight"](widget, ui_px(int(widget.property("uiMaxH"))))
if widget.property("uiML") is not None:
_UI_ORIG["widgetMargins"](
widget,
ui_px(int(widget.property("uiML")), allow_zero=True),
ui_px(int(widget.property("uiMT") or 0), allow_zero=True),
ui_px(int(widget.property("uiMR") or 0), allow_zero=True),
ui_px(int(widget.property("uiMB") or 0), allow_zero=True),
)
effect = widget.graphicsEffect()
if isinstance(effect, QGraphicsDropShadowEffect) and effect.property("uiBlur") is not None:
effect.setBlurRadius(ui_px(int(effect.property("uiBlur"))))
effect.setOffset(0, ui_px(int(effect.property("uiOffY") or 0), allow_zero=True))
def _replay_layout_metrics(layout: QLayout) -> None:
if layout.property("uiML") is not None:
_UI_ORIG["layoutMargins"](
layout,
ui_px(int(layout.property("uiML")), allow_zero=True),
ui_px(int(layout.property("uiMT") or 0), allow_zero=True),
ui_px(int(layout.property("uiMR") or 0), allow_zero=True),
ui_px(int(layout.property("uiMB") or 0), allow_zero=True),
)
if layout.property("uiSpace") is not None:
_UI_ORIG["layoutSpacing"](layout, ui_px(int(layout.property("uiSpace")), allow_zero=True))
if isinstance(layout, QGridLayout):
if layout.property("uiGridH") is not None:
_UI_ORIG["gridHSpacing"](layout, ui_px(int(layout.property("uiGridH")), allow_zero=True))
if layout.property("uiGridV") is not None:
_UI_ORIG["gridVSpacing"](layout, ui_px(int(layout.property("uiGridV")), allow_zero=True))
for index, design in list(layout.property("uiAddSpacings") or []):
item = layout.itemAt(int(index))
spacer = item.spacerItem() if item is not None else None
if spacer is None:
continue
policy = spacer.sizePolicy()
width = ui_px(design, allow_zero=True) if policy.horizontalPolicy() == QSizePolicy.Policy.Fixed else 0
height = ui_px(design, allow_zero=True) if policy.verticalPolicy() == QSizePolicy.Policy.Fixed else 0
if width or height:
spacer.changeSize(width, height, policy.horizontalPolicy(), policy.verticalPolicy())
def sync_ui_scale(width: int, height: int, *roots: QWidget | None) -> None:
"""Scale fonts and layout tokens from the 1630×920 design canvas."""
global _UI_SCALE, _UI_LAST_APPLIED, _UI_RESCALING
factor = _scale_from_window(width, height)
trees = [root for root in roots if root is not None]
if abs(factor - _UI_LAST_APPLIED) < 0.004 and _UI_LAST_APPLIED:
return
_UI_SCALE = factor
app = QApplication.instance()
if app is not None:
app.setStyleSheet(_scale_css(APP_QSS))
app.setFont(_ui_font(9))
if not trees:
_UI_LAST_APPLIED = factor
return
_UI_RESCALING = True
try:
for root in trees:
_replay_widget_metrics(root)
for widget in root.findChildren(QWidget):
_replay_widget_metrics(widget)
for layout in root.findChildren(QLayout):
_replay_layout_metrics(layout)
for lst in root.findChildren(QListWidget):
for row in range(lst.count()):
item = lst.item(row)
design = item.data(_UI_HINT_ROLE) if item is not None else None
if isinstance(design, QSize):
_UI_ORIG["itemHint"](
item,
QSize(ui_px(design.width()), ui_px(design.height())),
)
for header in root.findChildren(QHeaderView):
if header.property("uiSection") is not None:
_UI_ORIG["sectionSize"](header, ui_px(int(header.property("uiSection"))))
finally:
_UI_RESCALING = False
_UI_LAST_APPLIED = factor
for root in trees:
root.update()
_install_ui_scale_hooks()
class HeaderActionButton(QPushButton):
"""Header action with a deterministic line icon instead of font glyphs."""
ICON_TOKENS = {
"⟳", "↻", "▣", "✈", "↓", "↑", "Ⅱ", "▯", "◇", "♢", "▶", "●", "⌕", "⌫",
"♧", "⚙", "▱", "☁", "♨", "⌁", "✓", "♲", "⊗", "♙", "❐",
}
def __init__(self, text: str):
super().__init__("")
self.icon_token = ""
self.label_text = ""
self.setText(text)
def setText(self, text: str) -> None:
parts = text.strip().split(maxsplit=1)
token = parts[0] if parts else ""
self.icon_token = token if token in self.ICON_TOKENS else ""
if self.icon_token:
self.label_text = parts[1] if len(parts) > 1 else ""
else:
self.label_text = text.strip()
super().setText(self.label_text)
def _button_colors(self) -> tuple[QColor, QColor]:
custom_color = self.property("iconColor")
hover = self.underMouse() and self.isEnabled()
if self.objectName() in {"PrimaryButton", "SidePrimary"}:
return QColor("#FFFFFF"), QColor("#FFFFFF")
if self.objectName() == "DangerButton":
color = QColor("#FFFFFF" if hover else (str(custom_color) if custom_color else "#E44343"))
return color, color
if self.property("taskFilter") and self.isChecked():
return QColor("#FFFFFF"), QColor("#FFFFFF")
if custom_color:
color = QColor(str(custom_color))
return color, color
if self.property("headerAction") or self.objectName() == "SecondaryButton":
color = QColor("#245DE7" if hover or self.isChecked() else "#245DE7")
if self.objectName() == "SecondaryButton" and not self.property("headerAction"):
color = QColor("#245DE7" if hover or self.isChecked() else "#33446F")
return color, color
return QColor("#245DE7"), QColor("#33446F")
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
option = QStyleOptionButton()
self.initStyleOption(option)
option.text = ""
self.style().drawControl(QStyle.ControlElement.CE_PushButton, option, painter, self)
icon_color, text_color = self._button_colors()
painter.setFont(self.font())
metrics = QFontMetrics(self.font())
text = self.label_text
icon_span = ui_px(16) if self.icon_token else 0
gap = ui_px(7) if self.icon_token and text else 0
text_w = metrics.horizontalAdvance(text) if text else 0
total = icon_span + gap + text_w
contents = self.contentsRect()
start = contents.x() + max(0, (contents.width() - total) // 2)
cy = contents.center().y()
token = self.icon_token
if token:
cx = start + icon_span / 2.0
pen = QPen(icon_color, 1.8)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.save()
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
painter.translate(cx, cy)
painter.scale(icon_span / 16.0, icon_span / 16.0)
painter.translate(-cx, -cy)
if token in {"⟳", "↻"}:
painter.drawArc(QRect(int(cx - 8), int(cy - 8), 16, 16), 35 * 16, 285 * 16)
painter.drawLine(QPointF(cx + 6, cy - 8), QPointF(cx + 10, cy - 4))
painter.drawLine(QPointF(cx + 6, cy - 8), QPointF(cx + 2, cy - 7))
elif token in {"▣", "▯"}:
painter.drawRoundedRect(QRect(int(cx - 8), int(cy - 8), 16, 16), 2, 2)
painter.drawLine(QPointF(cx - 4, cy - 8), QPointF(cx - 4, cy - 2))
painter.drawLine(QPointF(cx + 4, cy - 8), QPointF(cx + 4, cy - 2))
painter.drawLine(QPointF(cx - 4, cy + 4), QPointF(cx + 4, cy + 4))
elif token == "✈":
path = QPainterPath(QPointF(cx - 9, cy - 3))
path.lineTo(cx + 10, cy - 8)
path.lineTo(cx + 4, cy + 9)
path.lineTo(cx - 1, cy + 2)
path.closeSubpath()
painter.drawPath(path)
painter.drawLine(QPointF(cx - 1, cy + 2), QPointF(cx + 10, cy - 8))
elif token in {"↓", "↑"}:
direction = -1 if token == "↑" else 1
painter.drawLine(QPointF(cx, cy - 8 * direction), QPointF(cx, cy + 5 * direction))
painter.drawLine(QPointF(cx - 4, cy + 1 * direction), QPointF(cx, cy + 5 * direction))
painter.drawLine(QPointF(cx + 4, cy + 1 * direction), QPointF(cx, cy + 5 * direction))
painter.drawLine(QPointF(cx - 8, cy + 8), QPointF(cx + 8, cy + 8))
elif token == "Ⅱ":
painter.drawLine(QPointF(cx - 4, cy - 8), QPointF(cx - 4, cy + 8))
painter.drawLine(QPointF(cx + 4, cy - 8), QPointF(cx + 4, cy + 8))
elif token == "▶":
path = QPainterPath(QPointF(cx - 5, cy - 8))
path.lineTo(cx + 8, cy)
path.lineTo(cx - 5, cy + 8)
path.closeSubpath()
painter.drawPath(path)
elif token == "⌕":
painter.drawEllipse(QRect(int(cx - 8), int(cy - 8), 12, 12))
painter.drawLine(QPointF(cx + 2, cy + 2), QPointF(cx + 8, cy + 8))
elif token in {"♧", "♙"}:
painter.drawEllipse(QPointF(cx, cy - 5), 4.2, 4.2)
painter.drawArc(QRect(int(cx - 8), int(cy - 1), 16, 14), 20 * 16, 140 * 16)
elif token == "⚙":
painter.drawEllipse(QPointF(cx, cy), 4, 4)
for angle in range(0, 360, 45):
rad = math.radians(angle)
painter.drawLine(
QPointF(cx + math.cos(rad) * 5.5, cy + math.sin(rad) * 5.5),
QPointF(cx + math.cos(rad) * 8.5, cy + math.sin(rad) * 8.5),
)
elif token == "▱":
painter.drawRoundedRect(QRect(int(cx - 8), int(cy - 6), 16, 13), 2, 2)
painter.drawLine(QPointF(cx - 8, cy - 3), QPointF(cx - 2, cy - 3))
painter.drawLine(QPointF(cx - 2, cy - 3), QPointF(cx, cy - 6))
elif token == "☁":
painter.drawArc(QRect(int(cx - 8), int(cy - 7), 10, 10), 20 * 16, 200 * 16)
painter.drawArc(QRect(int(cx - 2), int(cy - 9), 12, 12), -20 * 16, 220 * 16)
painter.drawLine(QPointF(cx - 7, cy + 4), QPointF(cx + 8, cy + 4))
elif token == "♨":
painter.drawLine(QPointF(cx - 2, cy - 8), QPointF(cx + 6, cy + 7))
painter.drawLine(QPointF(cx - 8, cy + 7), QPointF(cx + 8, cy + 7))
painter.drawLine(QPointF(cx - 6, cy + 2), QPointF(cx + 1, cy + 2))
elif token == "⌁":
painter.drawArc(QRect(int(cx - 8), int(cy - 4), 16, 12), 20 * 16, 140 * 16)
painter.drawArc(QRect(int(cx - 5), int(cy - 1), 10, 8), 30 * 16, 120 * 16)
painter.drawEllipse(QPointF(cx, cy + 6), 1.4, 1.4)
elif token == "✓":
painter.drawLine(QPointF(cx - 5, cy), QPointF(cx - 1, cy + 5))
painter.drawLine(QPointF(cx - 1, cy + 5), QPointF(cx + 7, cy - 6))
elif token in {"♲", "⌫"}:
painter.drawRoundedRect(QRect(int(cx - 6), int(cy - 3), 12, 12), 2, 2)
painter.drawLine(QPointF(cx - 8, cy - 3), QPointF(cx + 8, cy - 3))
painter.drawLine(QPointF(cx - 3, cy - 7), QPointF(cx + 3, cy - 7))
painter.drawLine(QPointF(cx - 2, cy + 1), QPointF(cx - 2, cy + 6))
painter.drawLine(QPointF(cx + 2, cy + 1), QPointF(cx + 2, cy + 6))
elif token == "⊗":
painter.drawEllipse(QPointF(cx, cy), 8, 8)
painter.drawLine(QPointF(cx - 3.5, cy - 3.5), QPointF(cx + 3.5, cy + 3.5))
painter.drawLine(QPointF(cx + 3.5, cy - 3.5), QPointF(cx - 3.5, cy + 3.5))
elif token == "❐":
painter.drawRoundedRect(QRect(int(cx - 7), int(cy - 8), 11, 11), 2, 2)
painter.drawRoundedRect(QRect(int(cx - 3), int(cy - 4), 11, 11), 2, 2)
elif token == "◇":
painter.drawLine(QPointF(cx, cy - 8), QPointF(cx + 7, cy))
painter.drawLine(QPointF(cx + 7, cy), QPointF(cx, cy + 8))
painter.drawLine(QPointF(cx, cy + 8), QPointF(cx - 7, cy))
painter.drawLine(QPointF(cx - 7, cy), QPointF(cx, cy - 8))
elif token == "♢":
path = QPainterPath(QPointF(cx, cy - 8))
path.lineTo(cx + 6, cy - 4)
path.lineTo(cx + 5, cy + 4)
path.quadTo(cx, cy + 8, cx, cy + 8)
path.quadTo(cx - 5, cy + 4, cx - 6, cy - 4)
path.closeSubpath()
painter.drawPath(path)
else:
painter.drawEllipse(QPointF(cx, cy), 7, 7)
painter.restore()
start += icon_span + gap
if text:
painter.setPen(text_color)
painter.drawText(
QRect(int(start), contents.y(), max(text_w + ui_px(6), contents.right() - int(start)), contents.height()),
Qt.AlignVCenter | Qt.AlignLeft,
text,
)
def sizeHint(self) -> QSize:
maximum = self.maximumSize()
if maximum.width() < 16_000 and maximum.height() < 16_000:
return maximum
hint = super().sizeHint()
extra = ui_px(16) + (ui_px(7) if self.label_text else 0) if self.icon_token else 0
hint.setWidth(hint.width() + extra)
hint.setHeight(max(hint.height(), ui_px(36)))
return hint
def minimumSizeHint(self) -> QSize:
return self.sizeHint()
def _button(text: str, kind: str = "secondary") -> QPushButton:
parts = text.strip().split(maxsplit=1)
button = (
HeaderActionButton(text)
if parts and parts[0] in HeaderActionButton.ICON_TOKENS
else QPushButton(text)
)
names = {
"primary": "PrimaryButton",
"danger": "DangerButton",
"side": "SidePrimary",
}
button.setObjectName(names.get(kind, "SecondaryButton"))
button.setCursor(Qt.PointingHandCursor)
return button
def _painted_ui_icon(kind: str, size: int = 18, color: str = "#7D8BAF") -> QIcon:
"""Small deterministic UI icon for controls where symbol fonts vary by PC."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen(QColor(color), max(1.4, size / 11.0))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
if kind == "search":
diameter = max(7, int(size * 0.52))
painter.drawEllipse(QRect(2, 2, diameter, diameter))
start = 2 + diameter - 1
painter.drawLine(start, start, size - 2, size - 2)
elif kind == "calendar":
painter.drawRoundedRect(QRect(2, 3, size - 4, size - 5), 2, 2)
painter.drawLine(2, 7, size - 2, 7)
painter.drawLine(6, 1, 6, 5)
painter.drawLine(size - 6, 1, size - 6, 5)
elif kind == "chevron":
painter.drawLine(4, 7, size // 2, size - 5)
painter.drawLine(size // 2, size - 5, size - 4, 7)
painter.end()
return QIcon(pixmap)
def _header_button(text: str, kind: str = "secondary") -> QPushButton:
button = HeaderActionButton(text)
names = {
"primary": "PrimaryButton",
"danger": "DangerButton",
"side": "SidePrimary",
}
button.setObjectName(names.get(kind, "SecondaryButton"))
button.setCursor(Qt.PointingHandCursor)
button.setProperty("headerAction", True)
if kind == "primary":
_shadow(button, blur=24, y=4, alpha=42)
return button
def _shadow(widget: QWidget, blur: int = 36, y: int = 10, alpha: int = 28) -> None:
effect = QGraphicsDropShadowEffect(widget)
effect.setProperty("uiBlur", int(blur))
effect.setProperty("uiOffY", int(y))
effect.setBlurRadius(ui_px(blur))
effect.setOffset(0, ui_px(y, allow_zero=True))
effect.setColor(QColor(72, 98, 176, alpha))
widget.setGraphicsEffect(effect)
class AppCanvas(QWidget):
"""Reference-board atmosphere: pastel light blobs behind the glass cards."""
def __init__(self):
super().__init__()
self.setObjectName("AppRoot")
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
width, height = max(1, self.width()), max(1, self.height())
painter.fillRect(self.rect(), QColor("#EAF0FA"))
blobs = (
(0.86, 0.02, 0.62, QColor(176, 208, 255, 150), QColor(176, 208, 255, 0)),
(0.08, 0.38, 0.55, QColor(214, 196, 255, 120), QColor(214, 196, 255, 0)),
(0.58, 1.08, 0.58, QColor(255, 214, 232, 110), QColor(255, 214, 232, 0)),
(0.22, 0.92, 0.42, QColor(186, 236, 255, 90), QColor(186, 236, 255, 0)),
(0.96, 0.62, 0.40, QColor(196, 214, 255, 80), QColor(196, 214, 255, 0)),
)
for cx, cy, radius, inner, outer in blobs:
gradient = QRadialGradient(QPointF(width * cx, height * cy), width * radius)
gradient.setColorAt(0.0, inner)
gradient.setColorAt(1.0, outer)
painter.fillRect(self.rect(), QBrush(gradient))
def _paint_glass_orb(painter: QPainter, center: QPointF, radius: float, active: bool = True) -> None:
bloom = QRadialGradient(center, radius * 1.35)
bloom.setColorAt(0.42, QColor(90, 150, 255, 95 if active else 40))
bloom.setColorAt(1.0, QColor(140, 90, 255, 0))
painter.setPen(Qt.NoPen)
painter.setBrush(bloom)
painter.drawEllipse(center, radius * 1.18, radius * 1.18)
sphere = QRadialGradient(
QPointF(center.x() - radius * 0.28, center.y() - radius * 0.32),
radius * 1.2,
)
if active:
sphere.setColorAt(0.0, QColor("#FFFFFF"))
sphere.setColorAt(0.16, QColor("#CFF6FF"))
sphere.setColorAt(0.40, QColor("#8EA0FF"))
sphere.setColorAt(0.68, QColor("#6A5AEF"))
sphere.setColorAt(1.0, QColor("#3F78F4"))
else:
sphere.setColorAt(0.0, QColor("#F7FBFF"))
sphere.setColorAt(0.45, QColor("#C9D4EA"))
sphere.setColorAt(1.0, QColor("#9AA8C6"))
painter.setBrush(sphere)
painter.setPen(QPen(QColor(255, 255, 255, 190), 1.4))
painter.drawEllipse(center, radius, radius)
spec = QRadialGradient(
QPointF(center.x() - radius * 0.22, center.y() - radius * 0.28),
radius * 0.38,
)
spec.setColorAt(0.0, QColor(255, 255, 255, 210))
spec.setColorAt(1.0, QColor(255, 255, 255, 0))
painter.setPen(Qt.NoPen)
painter.setBrush(spec)
painter.drawEllipse(
QPointF(center.x() - radius * 0.18, center.y() - radius * 0.22),
radius * 0.30,
radius * 0.22,
)
class MonitorOrb(QPushButton):
def __init__(self, size: int = 64):
super().__init__("")
self._active = True
self.setCursor(Qt.PointingHandCursor)
self.setFixedSize(size, size)
self.setStyleSheet("QPushButton{background:transparent;border:none;}")
def set_active(self, active: bool) -> None:
self._active = active
self.update()
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
radius = min(self.width(), self.height()) / 2.0 - 3.0
_paint_glass_orb(painter, QPointF(self.width() / 2.0, self.height() / 2.0), radius, self._active)
def _label(text: str, name: str = "") -> QLabel:
item = QLabel(text)
if name:
item.setObjectName(name)
item.setWordWrap(name in {"MessageContent", "HeroHint", "DeskSessionPreview"})
return item
def _page_header(index: str, title: str, subtitle: str) -> QVBoxLayout:
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
title_label = _label(title, "PageTitle")
title_label.setWordWrap(False)
subtitle_label = _label(subtitle, "PageSubtitle")
subtitle_label.setWordWrap(False)
layout.addWidget(title_label)
layout.addWidget(subtitle_label)
return layout
def _card(title: str = "", subtitle: str = "") -> tuple[QFrame, QVBoxLayout]:
frame = QFrame()
frame.setObjectName("Card")
layout = QVBoxLayout(frame)
layout.setContentsMargins(20, 18, 20, 19)
layout.setSpacing(10)
if title:
layout.addWidget(_label(title, "CardTitle"))
if subtitle:
layout.addWidget(_label(subtitle, "CardSubtitle"))
return frame, layout
def _chip(text: str, tone: str = "success") -> QLabel:
names = {"success": "StatusChip", "blue": "BlueChip", "warning": "WarningChip"}
item = _label(text, names.get(tone, "BlueChip"))
item.setAlignment(Qt.AlignCenter)
item.setWordWrap(False)
return item
class HeaderChipLabel(QLabel):
def __init__(self, text: str):
parts = text.strip().split(maxsplit=1)
self.has_status_icon = bool(parts and parts[0] in {"●", "✓"})
self.label_text = parts[1] if self.has_status_icon and len(parts) > 1 else text.strip()
super().__init__("")
self.setAlignment(Qt.AlignCenter)
self.setWordWrap(False)
def paintEvent(self, event) -> None:
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setFont(self.font())
metrics = QFontMetrics(self.font())
text = self.label_text
icon_span = ui_px(16) if self.has_status_icon else 0
gap = ui_px(6) if self.has_status_icon and text else 0
text_w = metrics.horizontalAdvance(text) if text else 0
total = icon_span + gap + text_w
start = max(ui_px(8), (self.width() - total) // 2)
cy = self.height() / 2.0
if self.has_status_icon:
cx = start + icon_span / 2.0
radius = max(6.0, icon_span / 2.2)
color = QColor("#12AE7B" if self.objectName() == "StatusChip" else "#316CFF")
painter.setPen(Qt.NoPen)
painter.setBrush(color)
painter.drawEllipse(QPointF(cx, cy), radius, radius)
if "监控" in self.label_text:
glow = QRadialGradient(QPointF(cx, cy), radius + 1)
glow.setColorAt(0.0, QColor("#FFFFFF"))
glow.setColorAt(0.42, QColor("#7AE8FF"))
glow.setColorAt(1.0, QColor("#4A75FF"))
painter.setPen(QPen(QColor(255, 255, 255, 180), 1))
painter.setBrush(glow)
painter.drawEllipse(QPointF(cx, cy), radius, radius)
else:
pen = QPen(QColor("#FFFFFF"), max(1.4, radius / 4.5))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.drawLine(
QPointF(cx - radius * 0.42, cy),
QPointF(cx - radius * 0.12, cy + radius * 0.38),
)
painter.drawLine(
QPointF(cx - radius * 0.12, cy + radius * 0.38),
QPointF(cx + radius * 0.48, cy - radius * 0.38),
)
start += icon_span + gap
if text:
if self.objectName() == "StatusChip":
text_color = QColor("#0B9A6C")
elif self.objectName() == "WarningChip":
text_color = QColor("#E58924")
else:
text_color = QColor("#2F62F0")
painter.setPen(text_color)
painter.drawText(
QRect(int(start), 0, max(text_w + ui_px(8), self.width() - int(start) - ui_px(6)), self.height()),
Qt.AlignVCenter | Qt.AlignLeft,
text,
)
def sizeHint(self) -> QSize:
maximum = self.maximumSize()
if maximum.width() < 16_000 and maximum.height() < 16_000:
return maximum
return super().sizeHint()
def minimumSizeHint(self) -> QSize:
return self.sizeHint()
def _header_chip(text: str, tone: str = "success") -> QLabel:
names = {"success": "StatusChip", "blue": "BlueChip", "warning": "WarningChip"}
item = HeaderChipLabel(text)
item.setObjectName(names.get(tone, "BlueChip"))
item.setProperty("headerChip", True)
return item
class LineIconBadge(QLabel):
"""Small reference-style icon that never depends on symbol-font glyphs."""
def __init__(self, icon: str, object_name: str = "BlueChip"):
super().__init__("")
self.icon = icon
self.setObjectName(object_name)
self.setAlignment(Qt.AlignCenter)
def paintEvent(self, event) -> None:
if not self.property("roundBadge"):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
color = QColor("#FFFFFF" if self.objectName() == "StatusChip" and self.property("solidIcon") else
"#E94A4A" if self.objectName() in {"DangerText", "DangerButton"} else
"#13A978" if self.objectName() in {"StatusChip", "SuccessText"} else
"#E88C27" if self.objectName() in {"WarningChip", "WarningText"} else
"#7357E8" if self.objectName() == "PurpleChip" else "#3F6EFF")
cx, cy = self.width() / 2.0, self.height() / 2.0
size = min(self.width(), self.height())
scale = size / 52.0
if self.property("roundBadge"):
well_r = size / 2.0 - 1.0
glow = QRadialGradient(QPointF(cx, cy), well_r)
glow.setColorAt(0.50, QColor(color.red(), color.green(), color.blue(), 48))
glow.setColorAt(1.0, QColor(color.red(), color.green(), color.blue(), 0))
painter.setPen(Qt.NoPen)
painter.setBrush(glow)
painter.drawEllipse(QPointF(cx, cy), well_r, well_r)
well = QRadialGradient(QPointF(cx - well_r * 0.18, cy - well_r * 0.22), well_r)
well.setColorAt(0.0, QColor(255, 255, 255, 245))
well.setColorAt(1.0, QColor(color.red(), color.green(), color.blue(), 58))
painter.setBrush(well)
painter.setPen(QPen(QColor(color.red(), color.green(), color.blue(), 70), 1))
painter.drawEllipse(QPointF(cx, cy), well_r * 0.78, well_r * 0.78)
pen = QPen(color, max(1.7, min(self.width(), self.height()) / 20.0))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
token = self.icon.strip()
if token in {"AI"}:
painter.setFont(_paint_font(max(9, size * 0.28), QFont.Weight.DemiBold))
painter.drawText(self.rect().adjusted(2, 1, -2, -1), Qt.AlignCenter, "AI")
elif token in {"!"}:
path = QPainterPath(QPointF(cx, cy - 13 * scale))
path.lineTo(cx + 12 * scale, cy + 10 * scale)
path.lineTo(cx - 12 * scale, cy + 10 * scale)
path.closeSubpath()
painter.drawPath(path)
painter.drawLine(QPointF(cx, cy - 5 * scale), QPointF(cx, cy + 3 * scale))
painter.drawPoint(QPointF(cx, cy + 7 * scale))
elif token in {"⌛"}:
painter.drawLine(QPointF(cx - 8 * scale, cy - 11 * scale), QPointF(cx + 8 * scale, cy - 11 * scale))
painter.drawLine(QPointF(cx - 8 * scale, cy + 11 * scale), QPointF(cx + 8 * scale, cy + 11 * scale))
self._hourglass(painter, cx, cy, scale)
elif token in {"⟳", "↻"}:
rect = QRect(int(cx - 11 * scale), int(cy - 11 * scale), int(22 * scale), int(22 * scale))
painter.drawArc(rect, 35 * 16, 285 * 16)
painter.drawLine(QPointF(cx + 8 * scale, cy - 10 * scale), QPointF(cx + 12 * scale, cy - 5 * scale))
painter.drawLine(QPointF(cx + 8 * scale, cy - 10 * scale), QPointF(cx + 3 * scale, cy - 9 * scale))
elif token in {"◷", "◴"}:
painter.drawEllipse(QPointF(cx, cy), 11 * scale, 11 * scale)
painter.drawLine(QPointF(cx, cy), QPointF(cx, cy - 7 * scale))
painter.drawLine(QPointF(cx, cy), QPointF(cx + 6 * scale, cy + 3 * scale))
elif token in {"✈"}:
path = QPainterPath(QPointF(cx - 12 * scale, cy - 4 * scale))
path.lineTo(cx + 12 * scale, cy - 11 * scale)
path.lineTo(cx + 5 * scale, cy + 12 * scale)
path.lineTo(cx - 1 * scale, cy + 3 * scale)
path.closeSubpath()
painter.drawPath(path)
painter.drawLine(QPointF(cx - 1 * scale, cy + 3 * scale), QPointF(cx + 12 * scale, cy - 11 * scale))
elif token in {"▣", "▱"}:
painter.drawRoundedRect(QRect(int(cx - 11 * scale), int(cy - 9 * scale), int(22 * scale), int(18 * scale)), 3, 3)
painter.drawLine(QPointF(cx - 5 * scale, cy - 3 * scale), QPointF(cx + 6 * scale, cy - 3 * scale))
painter.drawLine(QPointF(cx - 5 * scale, cy + 3 * scale), QPointF(cx + 3 * scale, cy + 3 * scale))
elif token in {"power", "◯"}:
painter.drawArc(QRect(int(cx - 10 * scale), int(cy - 10 * scale), int(20 * scale), int(20 * scale)), 38 * 16, 284 * 16)
painter.drawLine(QPointF(cx, cy - 13 * scale), QPointF(cx, cy - 2 * scale))
elif token in {"headset", "♧"}:
painter.drawArc(QRect(int(cx - 11 * scale), int(cy - 11 * scale), int(22 * scale), int(22 * scale)), 0, 180 * 16)
painter.drawRoundedRect(QRect(int(cx - 13 * scale), int(cy - 3 * scale), int(5 * scale), int(11 * scale)), 2, 2)
painter.drawRoundedRect(QRect(int(cx + 8 * scale), int(cy - 3 * scale), int(5 * scale), int(11 * scale)), 2, 2)
painter.drawLine(QPointF(cx + 10 * scale, cy + 8 * scale), QPointF(cx + 4 * scale, cy + 11 * scale))
elif token in {"person", "♙"}:
painter.drawEllipse(QPointF(cx, cy - 7 * scale), 5 * scale, 5 * scale)
painter.drawArc(QRect(int(cx - 10 * scale), int(cy * 1 + 1 * scale), int(20 * scale), int(17 * scale)), 15 * 16, 150 * 16)
elif token in {"shield", "♢"}:
path = QPainterPath(QPointF(cx, cy - 13 * scale))
path.lineTo(cx + 11 * scale, cy - 8 * scale)
path.lineTo(cx + 9 * scale, cy + 5 * scale)
path.quadTo(cx, cy + 14 * scale, cx, cy + 14 * scale)
path.quadTo(cx - 9 * scale, cy + 5 * scale, cx - 11 * scale, cy - 8 * scale)
path.closeSubpath()
painter.drawPath(path)
painter.drawLine(QPointF(cx - 4 * scale, cy), QPointF(cx - 1 * scale, cy + 4 * scale))
painter.drawLine(QPointF(cx - 1 * scale, cy + 4 * scale), QPointF(cx + 5 * scale, cy - 4 * scale))
elif token in {"cloud", "☁"}:
path = QPainterPath(QPointF(cx - 12 * scale, cy + 7 * scale))
path.cubicTo(cx - 17 * scale, cy, cx - 11 * scale, cy - 7 * scale, cx - 5 * scale, cy - 6 * scale)
path.cubicTo(cx - 2 * scale, cy - 14 * scale, cx + 10 * scale, cy - 12 * scale, cx + 11 * scale, cy - 3 * scale)
path.cubicTo(cx + 18 * scale, cy - 2 * scale, cx + 17 * scale, cy + 8 * scale, cx + 10 * scale, cy + 8 * scale)
path.lineTo(cx - 10 * scale, cy + 8 * scale)
painter.drawPath(path)
elif token in {"flag", "⚑"}:
painter.drawLine(QPointF(cx - 8 * scale, cy - 12 * scale), QPointF(cx - 8 * scale, cy + 13 * scale))
path = QPainterPath(QPointF(cx - 7 * scale, cy - 11 * scale))
path.lineTo(cx + 10 * scale, cy - 8 * scale)
path.lineTo(cx + 4 * scale, cy)
path.lineTo(cx - 7 * scale, cy - 2 * scale)
painter.drawPath(path)
elif token in {"✦", "spark"}:
painter.drawLine(QPointF(cx, cy - 11 * scale), QPointF(cx, cy + 11 * scale))
painter.drawLine(QPointF(cx - 11 * scale, cy), QPointF(cx + 11 * scale, cy))
painter.drawLine(QPointF(cx - 6 * scale, cy - 6 * scale), QPointF(cx + 6 * scale, cy + 6 * scale))
painter.drawLine(QPointF(cx + 6 * scale, cy - 6 * scale), QPointF(cx - 6 * scale, cy + 6 * scale))
elif token in {"❐", "copy"}:
painter.drawRoundedRect(
QRect(int(cx - 10 * scale), int(cy - 11 * scale), int(14 * scale), int(14 * scale)), 2, 2
)
painter.drawRoundedRect(
QRect(int(cx - 4 * scale), int(cy - 5 * scale), int(14 * scale), int(14 * scale)), 2, 2
)
elif token in {"chat", "bubble"}:
painter.drawRoundedRect(
QRect(int(cx - 11 * scale), int(cy - 12 * scale), int(22 * scale), int(16 * scale)), 5, 5
)
painter.drawLine(QPointF(cx - 3 * scale, cy + 4 * scale), QPointF(cx - 8 * scale, cy + 12 * scale))
painter.drawLine(QPointF(cx - 8 * scale, cy + 12 * scale), QPointF(cx + 3 * scale, cy + 4 * scale))
elif token in {"monitor", "screen"}:
painter.drawRoundedRect(QRect(int(cx - 13 * scale), int(cy - 10 * scale), int(26 * scale), int(18 * scale)), 2, 2)
painter.drawLine(QPointF(cx, cy + 8 * scale), QPointF(cx, cy + 13 * scale))
painter.drawLine(QPointF(cx - 7 * scale, cy + 13 * scale), QPointF(cx + 7 * scale, cy + 13 * scale))
elif token in {"book", "database"}:
painter.drawRoundedRect(QRect(int(cx - 12 * scale), int(cy - 11 * scale), int(10 * scale), int(22 * scale)), 2, 2)
painter.drawRoundedRect(QRect(int(cx + 2 * scale), int(cy - 11 * scale), int(10 * scale), int(22 * scale)), 2, 2)
painter.drawLine(QPointF(cx, cy - 9 * scale), QPointF(cx, cy + 11 * scale))
elif token in {"bell"}:
painter.drawArc(QRect(int(cx - 9 * scale), int(cy - 10 * scale), int(18 * scale), int(20 * scale)), 0, 180 * 16)
painter.drawLine(QPointF(cx - 9 * scale, cy), QPointF(cx - 11 * scale, cy + 7 * scale))
painter.drawLine(QPointF(cx + 9 * scale, cy), QPointF(cx + 11 * scale, cy + 7 * scale))
painter.drawLine(QPointF(cx - 11 * scale, cy + 7 * scale), QPointF(cx + 11 * scale, cy + 7 * scale))
painter.drawEllipse(QPointF(cx, cy + 11 * scale), 2 * scale, 2 * scale)
elif token in {"pill"}:
painter.save()
painter.translate(cx, cy)
painter.rotate(-42)
painter.drawRoundedRect(QRect(int(-6 * scale), int(-13 * scale), int(12 * scale), int(26 * scale)), 6, 6)
painter.drawLine(QPointF(-6 * scale, 0), QPointF(6 * scale, 0))
painter.restore()
elif token in {"link", "↗"}:
painter.drawRoundedRect(QRect(int(cx - 13 * scale), int(cy - 5 * scale), int(17 * scale), int(10 * scale)), 5, 5)
painter.drawRoundedRect(QRect(int(cx - 4 * scale), int(cy - 5 * scale), int(17 * scale), int(10 * scale)), 5, 5)
elif token in {"◇"}:
for offset in (-7, 0, 7):
painter.drawRoundedRect(QRect(int(cx - 10 * scale), int(cy + (offset - 3) * scale), int(20 * scale), int(6 * scale)), 2, 2)
elif token.isdigit():
painter.setFont(_paint_font(max(9, size * 0.28), QFont.Weight.DemiBold, numeric=True))
painter.drawText(self.rect().adjusted(2, 1, -2, -1), Qt.AlignCenter, token)
elif token in {"●"}:
painter.drawRoundedRect(QRect(int(cx - 12 * scale), int(cy - 9 * scale), int(18 * scale), int(14 * scale)), 4, 4)
painter.drawRoundedRect(QRect(int(cx - 4 * scale), int(cy - 2 * scale), int(17 * scale), int(13 * scale)), 4, 4)
elif token in {"⌁", "◉"}:
for sx, sy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
x1, y1 = cx + sx * 12 * scale, cy + sy * 12 * scale
painter.drawLine(QPointF(x1, y1), QPointF(x1 - sx * 6 * scale, y1))
painter.drawLine(QPointF(x1, y1), QPointF(x1, y1 - sy * 6 * scale))
painter.drawEllipse(QPointF(cx, cy), 3 * scale, 3 * scale)
else:
painter.drawEllipse(QPointF(cx, cy), 10 * scale, 10 * scale)
painter.drawLine(QPointF(cx - 5 * scale, cy), QPointF(cx - 1 * scale, cy + 4 * scale))
painter.drawLine(QPointF(cx - 1 * scale, cy + 4 * scale), QPointF(cx + 6 * scale, cy - 5 * scale))
@staticmethod
def _hourglass(painter: QPainter, cx: float, cy: float, scale: float) -> None:
painter.drawLine(QPointF(cx - 7 * scale, cy - 9 * scale), QPointF(cx + 7 * scale, cy - 9 * scale))
painter.drawLine(QPointF(cx - 7 * scale, cy + 9 * scale), QPointF(cx + 7 * scale, cy + 9 * scale))
painter.drawLine(QPointF(cx - 6 * scale, cy - 8 * scale), QPointF(cx + 6 * scale, cy + 8 * scale))
painter.drawLine(QPointF(cx + 6 * scale, cy - 8 * scale), QPointF(cx - 6 * scale, cy + 8 * scale))
def _info_tile(title: str, value: str, accent: str = "blue") -> tuple[QFrame, QLabel]:
frame = QFrame()
frame.setObjectName("InfoTile")
box = QVBoxLayout(frame)
box.setContentsMargins(16, 14, 16, 14)
box.setSpacing(4)
box.addWidget(_label(title, "MetricLabel"))
value_label = _label(value, "MetricValue")
if accent == "green":
value_label.setStyleSheet(f"color:{COLORS['success']};font-size:23px;font-weight:600;")
elif accent == "orange":
value_label.setStyleSheet(f"color:{COLORS['warning']};font-size:23px;font-weight:600;")
box.addWidget(value_label)
return frame, value_label
def _compact_card(
title: str = "", subtitle: str = "", *, margins: tuple[int, int, int, int] = (24, 16, 24, 18)
) -> tuple[QFrame, QVBoxLayout]:
"""Design-board card: compact enough for a complete 1600×900 dashboard."""
frame = QFrame()
frame.setObjectName("Card")
_shadow(frame, blur=34, y=8, alpha=22)
box = QVBoxLayout(frame)
box.setContentsMargins(*margins)
box.setSpacing(8)
if title:
box.addWidget(_label(title, "CardTitle"))
if subtitle:
box.addWidget(_label(subtitle, "CardSubtitle"))
return frame, box
def _compact_row(
title: str,
value: str = "",
*,
icon: str = "✓",
tone: str = "success",
detail: str = "",
) -> tuple[QFrame, QLabel]:
row = QFrame()
row.setObjectName("CompactRow")
row.setMinimumHeight(38)
line = QHBoxLayout(row)
line.setContentsMargins(11, 7, 11, 7)
line.setSpacing(9)
icon_label = LineIconBadge(icon, "SuccessText" if tone == "success" else "BlueChip")
icon_label.setAlignment(Qt.AlignCenter)
icon_label.setFixedWidth(24)
line.addWidget(icon_label)
copy = QVBoxLayout()
copy.setSpacing(1)
copy.addWidget(_label(title, "CardSubtitle"))
if detail:
copy.addWidget(_label(detail, "MetricMeta"))
line.addLayout(copy, 1)
value_label = _label(value, {
"success": "SuccessText", "warning": "WarningText", "danger": "DangerText"
}.get(tone, "BlueChip"))
value_label.setWordWrap(False)
line.addWidget(value_label)
return row, value_label
class WaveBand(QWidget):
"""Thin multi-wave decoration used by all six supplied boards."""
def __init__(self, colors: tuple[str, ...] = ("#A5BAFF", "#CDBBFF", "#9EDDF4")):
super().__init__()
self.colors = colors
self.setMinimumHeight(34)
self.setSizePolicy(self.sizePolicy().horizontalPolicy(), self.sizePolicy().verticalPolicy())
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
width = max(1, self.width())
height = max(1, self.height())
mid = height * 0.52
for group, color in enumerate(self.colors):
for offset in range(4):
path = QPainterPath(QPointF(0, mid))
amplitude = height * (0.18 + 0.025 * offset)
phase = group * 1.7 + offset * 0.28
steps = max(24, width // 5)
for index in range(1, steps + 1):
x = width * index / steps
# Clamp the last sample: floating-point sin(pi) can be a
# tiny negative value, and a fractional power would then
# produce a complex number that QPainter cannot consume.
envelope = max(0.0, math.sin(math.pi * x / width)) ** 1.3
y = mid + math.sin((x / width) * math.pi * (3.1 + group * 0.45) + phase) * amplitude * envelope
path.lineTo(x, y)
shade = QColor(color)
shade.setAlpha(92 - offset * 12)
painter.setPen(QPen(shade, 1.15))
painter.drawPath(path)
class StatusRing(QWidget):
def __init__(
self,
text: str = "✓",
color: str = "#18B887",
size: int = 76,
subtitle: str = "",
):
super().__init__()
self.text = text
self.color = color
self.subtitle = subtitle
self.setFixedSize(size, size)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
ring_width = max(6, round(self.width() * 0.075))
rect = self.rect().adjusted(ring_width + 3, ring_width + 3, -ring_width - 3, -ring_width - 3)
halo = QColor(self.color)
halo.setAlpha(45)
painter.setPen(QPen(halo, ring_width + 7))
painter.drawEllipse(rect)
painter.setPen(QPen(QColor("#E6EDF8"), ring_width))
painter.drawEllipse(rect)
if self.text == "✓":
ring_brush = QBrush(QColor(self.color))
else:
ring_gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
ring_gradient.setColorAt(0.0, QColor("#3E6FF3"))
ring_gradient.setColorAt(0.48, QColor(self.color))
ring_gradient.setColorAt(1.0, QColor("#67D4E2"))
ring_brush = QBrush(ring_gradient)
pen = QPen(ring_brush, ring_width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawArc(rect, 90 * 16, (360 if self.text == "✓" else 302) * 16)
center = self.rect().center()
if self.text == "✓":
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(self.color))
dot = max(11, round(self.width() * 0.17))
painter.drawEllipse(center, dot, dot)
painter.setPen(QColor("#FFFFFF"))
painter.setFont(_paint_font(max(9, self.width() * 0.16), QFont.Weight.DemiBold))
painter.drawText(self.rect(), Qt.AlignCenter, "✓")
else:
painter.setPen(QColor("#12224E"))
if self.subtitle:
title_rect = self.rect().adjusted(ui_px(8), ui_px(18), -ui_px(8), -ui_px(36))
painter.setFont(_paint_font(max(9, self.width() * 0.10)))
painter.drawText(title_rect, Qt.AlignCenter, self.text)
value_rect = self.rect().adjusted(ui_px(8), ui_px(40), -ui_px(8), -ui_px(14))
painter.setFont(_paint_font(max(13, self.width() * 0.16), QFont.Weight.DemiBold, numeric=True))
painter.drawText(value_rect, Qt.AlignCenter, self.subtitle)
else:
painter.setFont(_paint_font(max(11, self.width() * 0.14), QFont.Weight.DemiBold, numeric=True))
painter.drawText(self.rect().adjusted(ui_px(6), ui_px(6), -ui_px(6), -ui_px(6)), Qt.AlignCenter, self.text)
class FlowConnector(QWidget):
"""Reference connector: a thin, quiet line with one centered status dot."""
def __init__(self, complete: bool = False):
super().__init__()
self.complete = complete
self.setMinimumWidth(26)
self.setFixedHeight(24)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
y = self.height() / 2
gradient = QLinearGradient(0, y, self.width(), y)
if self.complete:
gradient.setColorAt(0, QColor("#72D5BF"))
gradient.setColorAt(1, QColor("#A8C5F8"))
dot = QColor("#5BC9AD")
else:
gradient.setColorAt(0, QColor("#9EBBFC"))
gradient.setColorAt(1, QColor("#C8B8F8"))
dot = QColor("#7168F2")
painter.setPen(QPen(QBrush(gradient), 2))
painter.drawLine(QPointF(0, y), QPointF(self.width(), y))
painter.setPen(Qt.NoPen)
painter.setBrush(dot)
painter.drawEllipse(QPointF(self.width() / 2, y), 2.5, 2.5)
class GradientOrb(QWidget):
def __init__(self, text: str = "AI", size: int = 84):
super().__init__()
self.text = text
self.setFixedSize(size, size)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
radius = min(self.width(), self.height()) / 2.0 - 4.0
_paint_glass_orb(painter, QPointF(self.width() / 2.0, self.height() / 2.0), radius, True)
painter.setPen(QColor("#FFFFFF"))
painter.setFont(_paint_font(max(12, self.width() * 0.22), QFont.Weight.Bold))
painter.drawText(self.rect(), Qt.AlignCenter, self.text)
class WeComWindowPreview(QWidget):
"""Painted enterprise-WeChat thumbnail used by the automation board."""
def __init__(self, width: int = 166, height: int = 158):
super().__init__()
self.setFixedSize(width, height)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
outer = self.rect().adjusted(1, 1, -1, -1)
painter.setPen(QPen(QColor("#CAD9F4"), 1))
painter.setBrush(QColor("#F8FAFF"))
painter.drawRoundedRect(outer, 11, 11)
painter.save()
painter.setClipRect(outer)
title = QRect(1, 1, self.width() - 2, 24)
title_gradient = QLinearGradient(title.topLeft(), title.topRight())
title_gradient.setColorAt(0, QColor("#659BFA"))
title_gradient.setColorAt(1, QColor("#87B5FA"))
painter.setPen(Qt.NoPen)
painter.setBrush(title_gradient)
painter.drawRect(title)
painter.setBrush(QColor(255, 255, 255, 200))
for x in (12, 22, 32):
painter.drawEllipse(QPointF(x, 13), 2.5, 2.5)
sidebar = QRect(1, 25, 29, self.height() - 26)
side_gradient = QLinearGradient(sidebar.topLeft(), sidebar.bottomLeft())
side_gradient.setColorAt(0, QColor("#397BF0"))
side_gradient.setColorAt(1, QColor("#2B69D7"))
painter.setBrush(side_gradient)
painter.drawRect(sidebar)
painter.setBrush(QColor(211, 230, 255, 205))
for y in (42, 69, 97, 126):
painter.drawRoundedRect(QRect(10, y, 10, 10), 2, 2)
for row, y in enumerate((40, 66, 92, 118)):
painter.setBrush(QColor("#E7ECF7" if row != 1 else "#D8E3F8"))
painter.drawRoundedRect(QRect(39, y, 23, 18), 3, 3)
painter.setBrush(QColor("#E9EDF6"))
painter.drawRoundedRect(QRect(69, y + 2, 56, 6), 3, 3)
painter.drawRoundedRect(QRect(69, y + 12, 42, 5), 2, 2)
painter.setBrush(QColor("#C9DAFB"))
painter.drawRoundedRect(QRect(132, y + 2, 22, 13), 3, 3)
painter.restore()
center = QPointF(self.width() - 27, self.height() - 27)
painter.setPen(QPen(QColor("#FFFFFF"), 3))
painter.setBrush(QColor("#19B684"))
painter.drawEllipse(center, 19, 19)
painter.drawLine(QPointF(center.x() - 7, center.y()), QPointF(center.x() - 2, center.y() + 6))
painter.drawLine(QPointF(center.x() - 2, center.y() + 6), QPointF(center.x() + 8, center.y() - 7))
class HumanCoexistVisual(QWidget):
"""Robot → cursor → manual-operation diagram from the reference."""
def __init__(self):
super().__init__()
self.setMinimumHeight(96)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
w, h = self.width(), self.height()
left = QPointF(28, h * 0.48)
center = QPointF(w * 0.52, h * 0.43)
right = QPointF(w - 30, h * 0.48)
blue = QPen(QColor("#82AFF8"), 1.6, Qt.PenStyle.DotLine)
orange = QPen(QColor("#F4A256"), 1.6, Qt.PenStyle.DotLine)
path = QPainterPath(left)
path.cubicTo(w * 0.26, h * 0.20, w * 0.34, h * 0.68, center.x() - 30, center.y())
painter.setPen(blue)
painter.drawPath(path)
path = QPainterPath(QPointF(center.x() + 30, center.y()))
path.cubicTo(w * 0.68, h * 0.72, w * 0.76, h * 0.20, right.x(), right.y())
painter.setPen(orange)
painter.drawPath(path)
painter.setPen(QPen(QColor("#FFFFFF"), 2))
painter.setBrush(QColor("#4F86F7"))
painter.drawEllipse(left, 18, 18)
painter.setBrush(QColor("#F39A3E"))
painter.drawEllipse(right, 18, 18)
glow = QRadialGradient(center, 38)
glow.setColorAt(0, QColor("#FFFFFF"))
glow.setColorAt(0.38, QColor("#DDE5FF"))
glow.setColorAt(0.72, QColor(89, 100, 238, 150))
glow.setColorAt(1, QColor(89, 100, 238, 0))
painter.setPen(QPen(QColor("#C7D3FF"), 1))
painter.setBrush(glow)
painter.drawEllipse(center, 36, 36)
cursor = QPainterPath(QPointF(center.x() - 9, center.y() - 17))
cursor.lineTo(center.x() + 11, center.y() + 5)
cursor.lineTo(center.x() + 3, center.y() + 6)
cursor.lineTo(center.x() + 9, center.y() + 18)
cursor.lineTo(center.x() + 3, center.y() + 21)
cursor.lineTo(center.x() - 3, center.y() + 8)
cursor.lineTo(center.x() - 10, center.y() + 14)
cursor.closeSubpath()
painter.setPen(QPen(QColor("#FFFFFF"), 2))
painter.setBrush(QColor("#101E49"))
painter.drawPath(cursor)
painter.setPen(QColor("#65729A"))
painter.setFont(_paint_font(max(10, h * 0.07)))
painter.drawText(QRect(ui_px(4), int(h - ui_px(26)), ui_px(88), ui_px(22)), Qt.AlignCenter, "AI 运行中")
painter.drawText(QRect(int(w - ui_px(108)), int(h - ui_px(36)), ui_px(104), ui_px(20)), Qt.AlignCenter, "检测到人工操作")
painter.setPen(QColor("#E88C27"))
painter.drawText(QRect(int(w - ui_px(108)), int(h - ui_px(18)), ui_px(104), ui_px(18)), Qt.AlignCenter, "AI 暂停")
def _activity_tile(time_text: str, title: str, detail: str, tone: str = "success") -> QFrame:
tile = QFrame()
tile.setObjectName("ActivityTile")
box = QHBoxLayout(tile)
box.setContentsMargins(10, 10, 10, 10)
box.setSpacing(9)
if "AI" in title:
icon = "AI"
elif "人工" in title:
icon = "!"
elif "读取" in title or "提取" in title:
icon = "link"
elif "入队" in title:
icon = "✓"
elif "生成" in title or "回复" in title:
icon = "◉"
else:
icon = "⌁"
badge = LineIconBadge(
icon,
"WarningChip" if tone == "warning" else
"BlueChip" if tone == "blue" else
"StatusChip" if tone == "success" else "BlueChip",
)
badge.setProperty("roundBadge", True)
badge.setFixedSize(38, 38)
box.addWidget(badge, 0, Qt.AlignVCenter)
copy = QVBoxLayout()
copy.setSpacing(2)
top = QHBoxLayout()
top.addWidget(_label(time_text, "MetricMeta"))
top.addStretch(1)
status_name = {
"success": "成功",
"blue": "进行中",
"warning": "警告",
}.get(tone, "等待中")
top.addWidget(_label("● " + status_name, {
"success": "SuccessText", "warning": "WarningText", "blue": "BlueText"
}.get(tone, "MetricMeta")))
copy.addLayout(top)
copy.addWidget(_label(title, "CardSubtitle"))
copy.addWidget(_label(detail, "MetricMeta"))
box.addLayout(copy, 1)
return tile
class EventStreamRow(QFrame):
"""Timeline row with the continuous guide visible in the reference board."""
def paintEvent(self, event) -> None:
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(QColor("#DEE7F5"), 2))
painter.drawLine(QPointF(7, 0), QPointF(7, self.height()))
def _event_stream_row(
time_text: str,
title: str,
detail: str,
status: str,
tone: str,
category: str,
) -> QFrame:
"""One fixed-height event row matching the reference timeline."""
row = EventStreamRow()
row.setObjectName("EventStreamRow")
row.setFixedHeight(52)
line = QHBoxLayout(row)
line.setContentsMargins(0, 3, 0, 3)
line.setSpacing(10)
marker = QLabel("✓")
marker.setStyleSheet(
"border:2px solid #FFFFFF;border-radius:7px;padding:0;color:#FFFFFF;font-size:8px;font-weight:700;background:"
+ (COLORS["success"] if tone == "success" else COLORS["warning"] if tone == "warning" else COLORS["accent"])
+ ";"
)
marker.setFixedSize(14, 14)
marker.setAlignment(Qt.AlignCenter)
line.addWidget(marker)
time_label = _label(time_text, "MetricMeta")
time_label.setFixedWidth(120)
line.addWidget(time_label)
icon = "AI" if category == "AI" else "!" if tone == "warning" else "✈" if category == "发送" else "●" if category == "识别" else "◎"
badge = LineIconBadge(icon, "StatusChip" if tone == "success" else "WarningChip" if tone == "warning" else "BlueChip")
badge.setProperty("roundBadge", True)
badge.setFixedSize(44, 44)
line.addWidget(badge)
copy = QVBoxLayout()
copy.setSpacing(1)
title_label = _label(title, "CardSubtitle")
detail_label = _label(detail, "MetricMeta")
copy.addWidget(title_label)
copy.addWidget(detail_label)
line.addLayout(copy, 1)
status_label = _chip(status, "success" if tone == "success" else "warning" if tone == "warning" else "blue")
status_label.setFixedSize(64, 28)
line.addWidget(status_label)
row.time_label = time_label
row.title_label = title_label
row.detail_label = detail_label
row.status_label = status_label
row.badge = badge
row.marker = marker
row.event_category = category
row.event_tone = tone
row.event_empty = False
return row
def _status_band(items: list[tuple[str, str, str, str]]) -> tuple[QFrame, list[QLabel]]:
band = QFrame()
band.setObjectName("GlassBand")
band.setMinimumHeight(78)
_shadow(band, blur=28, y=6, alpha=18)
row = QHBoxLayout(band)
row.setContentsMargins(18, 10, 18, 10)
row.setSpacing(8)
values: list[QLabel] = []
for index, (icon, title, value, tone) in enumerate(items):
segment = QWidget()
segment_row = QHBoxLayout(segment)
segment_row.setContentsMargins(0, 0, 0, 0)
segment_row.setSpacing(10)
badge = LineIconBadge(icon, "BlueChip" if tone == "blue" else "StatusChip" if tone == "success" else "WarningChip")
badge.setAlignment(Qt.AlignCenter)
badge.setProperty("roundBadge", True)
badge.setFixedSize(52, 52)
segment_row.addWidget(badge)
copy = QVBoxLayout()
copy.setSpacing(0)
copy.addWidget(_label(title, "MetricLabel"))
value_label = _label(value, "MetricValue")
if tone == "success":
value_label.setStyleSheet("color:#11AF7B;font-size:22px;font-weight:600;")
elif tone == "warning":
value_label.setStyleSheet("color:#E98B28;font-size:22px;font-weight:600;")
copy.addWidget(value_label)
segment_row.addLayout(copy)
wave = WaveBand(("#AFC1FF", "#D1C3FF") if tone != "success" else ("#A9E8DC", "#B8CEFF"))
wave.setMinimumWidth(64)
segment_row.addWidget(wave, 1)
row.addWidget(segment, 1)
values.append(value_label)
if index < len(items) - 1:
divider = QFrame()
divider.setFrameShape(QFrame.VLine)
divider.setStyleSheet("color:#E6EBF6;")
row.addWidget(divider)
return band, values
def _flow_track(items: tuple[tuple[str, str], ...], active: int = -1) -> QFrame:
frame = QFrame()
frame.setObjectName("FlowTrack")
frame.setStyleSheet("QFrame#FlowTrack{background:transparent;border:none;}")
row = QHBoxLayout(frame)
row.setContentsMargins(12, 7, 12, 7)
row.setSpacing(2)
for index, (icon, title) in enumerate(items):
node = QVBoxLayout()
badge = LineIconBadge(
icon,
"BlueChip" if index == active or index > active else "StatusChip",
)
badge.setProperty("roundBadge", True)
badge.setFixedSize(54, 54)
node.addWidget(badge, 0, Qt.AlignHCenter)
title_label = _label(title, "CardSubtitle")
title_label.setAlignment(Qt.AlignCenter)
node.addWidget(title_label)
row.addLayout(node, 0)
if index < len(items) - 1:
connector = FlowConnector(complete=index < active)
row.addWidget(connector, 1)
return frame
class FadingStack(QStackedWidget):
def setCurrentIndexAnimated(self, index: int) -> None:
if index == self.currentIndex():
return
self.setCurrentIndex(index)
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(252)
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_hint = _label("同一输入框里起草回复、整理话术;会话按 SDK 的 session id 延续。", "CardSubtitle")
rail_hint.setWordWrap(True)
rail_layout.addWidget(rail_hint)
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.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
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 人格与工具能力。",
)
)
self.chat_scroll = QScrollArea()
self.chat_scroll.setWidgetResizable(True)
self.chat_scroll.setFrameShape(QFrame.NoFrame)
self.chat_scroll.setStyleSheet(
"QScrollArea{background:#F7F9FF;border:1px solid #E0E6F4;border-radius:20px;}"
"QScrollArea>QWidget>QWidget{background:#F7F9FF;}"
)
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_hint = _label(
"复用同一个会话 id 会保留上下文。独立任务请点「开始新对话」。",
"PageSubtitle",
)
empty_hint.setWordWrap(True)
empty_layout.addWidget(empty_hint)
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)
height = max(ui_px(76), bubble.sizeHint().height() + ui_px(12))
_UI_ORIG["maxHeight"](wrap, height)
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 QueueKpiCard(QFrame):
"""Four independent glass metric cards from the task-queue board."""
def __init__(self, icon: str, title: str, value: str, tone: str):
super().__init__()
self.setObjectName("MetricCard")
self.setFixedHeight(104)
_shadow(self, blur=26, y=6, alpha=18)
row = QHBoxLayout(self)
row.setContentsMargins(16, 14, 14, 14)
row.setSpacing(10)
badge = LineIconBadge(
icon,
"StatusChip" if tone == "success" else
"WarningChip" if tone == "warning" else
"PurpleChip" if tone == "purple" else "BlueChip",
)
badge.setProperty("roundBadge", True)
badge.setFixedSize(52, 52)
row.addWidget(badge, 0, Qt.AlignVCenter)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(title, "MetricLabel"))
self.value = _label(value, "MetricValue")
if tone == "success":
self.value.setStyleSheet("color:#12B07C;font-size:28px;font-weight:600;min-height:38px;padding-bottom:4px;")
elif tone == "warning":
self.value.setStyleSheet("color:#F08A27;font-size:28px;font-weight:600;min-height:38px;padding-bottom:4px;")
elif tone == "purple":
self.value.setStyleSheet("color:#7357E8;font-size:28px;font-weight:600;min-height:38px;padding-bottom:4px;")
else:
self.value.setStyleSheet("color:#2F62F0;font-size:28px;font-weight:600;min-height:38px;padding-bottom:4px;")
copy.addWidget(self.value)
row.addLayout(copy)
waves = {
"success": ("#A9E8DC", "#B8CEFF"),
"warning": ("#FFD0A8", "#FFC4B0"),
"purple": ("#D1C3FF", "#C5B8FF"),
}.get(tone, ("#AFC1FF", "#C5D4FF"))
wave = WaveBand(waves)
wave.setMinimumWidth(72)
row.addWidget(wave, 1)
class QueueStepBadge(QWidget):
def __init__(self):
super().__init__()
self.mode = "wait"
self.token = "1"
self.setFixedSize(40, 40)
def set_state(self, mode: str, token: str) -> None:
self.mode = mode
self.token = token
self.setFixedSize(44 if mode == "active" else 40, 44 if mode == "active" else 40)
self.update()
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
cx, cy = self.width() / 2.0, self.height() / 2.0
design = 44.0 if self.mode == "active" else 40.0
scale = self.width() / design if design else 1.0
painter.save()
painter.translate(cx, cy)
painter.scale(scale, scale)
painter.translate(-cx, -cy)
if self.mode == "active":
glow = QRadialGradient(QPointF(cx, cy), 24)
glow.setColorAt(0.0, QColor("#FFFFFF"))
glow.setColorAt(0.28, QColor("#6B8CFF"))
glow.setColorAt(0.62, QColor(91, 114, 255, 90))
glow.setColorAt(1.0, QColor(122, 94, 246, 0))
painter.setPen(Qt.NoPen)
painter.setBrush(glow)
painter.drawEllipse(QPointF(cx, cy), 22, 22)
core = QRadialGradient(QPointF(cx - 3, cy - 4), 18)
core.setColorAt(0.0, QColor("#C4F0FF"))
core.setColorAt(0.38, QColor("#4A72FF"))
core.setColorAt(1.0, QColor("#725BF3"))
painter.setBrush(core)
painter.setPen(QPen(QColor("#D7E6FF"), 1.6))
painter.drawEllipse(QPointF(cx, cy), 16, 16)
painter.restore()
painter.setPen(QColor("#FFFFFF"))
painter.setFont(_paint_font(max(12, self.height() * 0.36), QFont.Weight.DemiBold, numeric=True))
painter.drawText(self.rect().adjusted(2, 1, -2, -1), Qt.AlignCenter, self.token)
return
if self.mode == "done":
painter.setPen(QPen(QColor("#BDECDD"), 1))
painter.setBrush(QColor("#E8FAF4"))
painter.drawEllipse(QPointF(cx, cy), 16, 16)
pen = QPen(QColor("#10AD7A"), 2.2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawLine(QPointF(cx - 6, cy), QPointF(cx - 1, cy + 5))
painter.drawLine(QPointF(cx - 1, cy + 5), QPointF(cx + 7, cy - 6))
painter.restore()
return
painter.setPen(QPen(QColor("#D5DDEF"), 1))
painter.setBrush(QColor("#FFFFFF"))
painter.drawEllipse(QPointF(cx, cy), 16, 16)
painter.restore()
painter.setPen(QColor("#6E7B9E"))
painter.setFont(_paint_font(max(11, self.height() * 0.34), QFont.Weight.DemiBold, numeric=True))
painter.drawText(self.rect().adjusted(2, 1, -2, -1), Qt.AlignCenter, self.token)
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)
settingsChanged = Signal(dict)
def __init__(self, engine_settings: dict | None = None):
super().__init__()
engine_settings = engine_settings or {}
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
root = QWidget()
root.setObjectName("PageRoot")
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(30, 24, 30, 28)
layout.setSpacing(15)
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)
engine_card, engine_box = _card("检测引擎", "选择消息检测方案;引擎 A 截图检测 与 引擎 B 数据直读 可独立开关。")
self.engine_a_check = QCheckBox("引擎 A · 截图 RPA 检测(红点识别 → 视觉提取)")
self.engine_a_check.setChecked(bool(engine_settings.get("engine_a_enabled", True)))
self.engine_b_check = QCheckBox("引擎 B · 数据直读检测(解密 message.db / 档案增量)")
self.engine_b_check.setChecked(bool(engine_settings.get("enable_engine_b", True)))
engine_box.addWidget(self.engine_a_check)
engine_box.addWidget(self.engine_b_check)
source_label = _label("引擎 B 数据源", "CardSubtitle")
engine_box.addWidget(source_label)
self.engine_b_parallel = QRadioButton("并行双跑(DB 直读 + 档案,互为兜底)")
self.engine_b_db_only = QRadioButton("仅 DB 直读(解密 message.db,无密钥时退化档案)")
self.engine_b_json_only = QRadioButton("仅档案监听(conversations.json")
source_mode = str(engine_settings.get("engine_b_data_source", "parallel") or "parallel").lower()
self.engine_b_parallel.setChecked(source_mode == "parallel")
self.engine_b_db_only.setChecked(source_mode == "db")
self.engine_b_json_only.setChecked(source_mode == "json")
engine_box.addWidget(self.engine_b_parallel)
engine_box.addWidget(self.engine_b_db_only)
engine_box.addWidget(self.engine_b_json_only)
for widget in (self.engine_a_check, self.engine_b_check,
self.engine_b_parallel, self.engine_b_db_only, self.engine_b_json_only):
widget.setCursor(Qt.PointingHandCursor)
self.engine_a_check.toggled.connect(self._engine_settings_changed)
self.engine_b_check.toggled.connect(self._engine_settings_changed)
for radio in (self.engine_b_parallel, self.engine_b_db_only, self.engine_b_json_only):
radio.toggled.connect(self._engine_settings_changed)
self._sync_engine_b_source_enabled()
self.engine_b_check.toggled.connect(self._sync_engine_b_source_enabled)
layout.addWidget(engine_card)
layout.addStretch(1)
def _sync_engine_b_source_enabled(self) -> None:
"""引擎 B 关闭时,数据源单选组置灰,避免保存出无意义配置。"""
enabled = self.engine_b_check.isChecked()
for radio in (self.engine_b_parallel, self.engine_b_db_only, self.engine_b_json_only):
radio.setEnabled(enabled)
def _engine_settings_changed(self, *_args) -> None:
self._sync_engine_b_source_enabled()
mode = "parallel"
if self.engine_b_db_only.isChecked():
mode = "db"
elif self.engine_b_json_only.isChecked():
mode = "json"
self.settingsChanged.emit({
"engine_a_enabled": self.engine_a_check.isChecked(),
"enable_engine_b": self.engine_b_check.isChecked(),
"engine_b_data_source": mode,
})
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:600;"
)
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)
securityRulesRequested = Signal()
def __init__(self, settings: dict):
super().__init__()
self.reference_preview = "--qt-smoke-test" in sys.argv
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
root = QWidget()
root.setObjectName("PageRoot")
root.setMinimumWidth(1040)
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(12, 18, 16, 8)
layout.setSpacing(13)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(12)
header.addLayout(_page_header("05", "自动化设置", "配置企业微信监听、消息合并与自动发送规则"), 1)
wecom_chip = _header_chip("● 企业微信已识别", "success")
wecom_chip.setFixedSize(193, 48)
header.addWidget(wecom_chip, 0, Qt.AlignVCenter)
rule_chip = _header_chip("● 规则已生效", "success")
rule_chip.setFixedSize(166, 48)
header.addWidget(rule_chip, 0, Qt.AlignVCenter)
# 保存反馈就贴在「保存设置」旁边。原来它挂在页面最底下那张「启动前检查」
# 卡片里、还只显示 1.6 秒:人在页面顶上点完按钮,提示在几百像素之外一闪
# 而过,等于没有反馈——"点了没反应,不知道存没存上"就是这么来的。
self.save_status = _label("", "SuccessText")
self.save_status.setMinimumWidth(150)
self.save_status.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
header.addWidget(self.save_status, 0, Qt.AlignVCenter)
save_now = _header_button("▣ 保存设置", "primary")
save_now.setFixedSize(160, 51)
save_now.clicked.connect(self._emit_save)
header.addWidget(save_now, 0, Qt.AlignVCenter)
layout.addLayout(header)
modes = QFrame()
modes.setObjectName("FilterBar")
modes.setMaximumWidth(370)
modes.setFixedHeight(55)
mode_row = QHBoxLayout(modes)
mode_row.setContentsMargins(5, 4, 5, 4)
mode_row.setSpacing(4)
self.auto_send_mode = HeaderActionButton("✈ 自动发送")
self.review_send_mode = HeaderActionButton("♧ 审核后发送")
for mode_button in (self.auto_send_mode, self.review_send_mode):
mode_button.setObjectName("SecondaryButton")
mode_button.setCursor(Qt.PointingHandCursor)
mode_button.setCheckable(True)
mode_button.setAutoExclusive(True)
self.auto_send_mode.setToolTip("AI 回复通过全部安全校验后自动发送。")
self.review_send_mode.setToolTip("AI 回复只填入输入框,不按 Enter,等待人工核对发送。")
send_mode = normalize_send_mode(settings.get("send_mode", SEND_MODE_AUTO))
self.auto_send_mode.setChecked(send_mode == SEND_MODE_AUTO)
self.review_send_mode.setChecked(send_mode == SEND_MODE_REVIEW)
self._refresh_send_mode_buttons()
self.auto_send_mode.toggled.connect(self._send_mode_toggled)
self.review_send_mode.toggled.connect(self._send_mode_toggled)
mode_row.addWidget(self.auto_send_mode)
mode_row.addWidget(self.review_send_mode)
layout.addWidget(modes)
layout.addSpacing(7)
self.reply = QLineEdit(str(settings["auto_reply_text"]))
self.reply.setMaxLength(120)
self.reply.setPlaceholderText("固定回复:在的,请稍等")
self.poll = QDoubleSpinBox()
self.poll.setRange(0.2, 300.0)
self.poll.setDecimals(1)
self.poll.setSuffix(" 秒")
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.setSuffix(" 秒")
self.idle_seconds.setValue(float(settings["mouse_idle_seconds"]))
self.send_delay = QDoubleSpinBox()
self.send_delay.setRange(float(SEND_DELAY_MIN_SECONDS), float(SEND_DELAY_MAX_SECONDS))
self.send_delay.setDecimals(1)
self.send_delay.setSingleStep(0.5)
self.send_delay.setSuffix(" 秒")
self.send_delay.setValue(float(settings["send_delay_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.setSuffix(" 秒")
self.batch_window.setValue(float(settings["message_batch_window_seconds"]))
# 输入框里有人工草稿时,压够多久就直接把框里那句发出去。
#
# 开关和分钟数分开两个控件:只给一个"0 = 关闭"的数字框,等于把最要紧的
# 那个决定(要不要让机器人替人按回车)藏在一个数值里。想临时关掉的人还得
# 先把原来填的分钟数记在别处,回头再敲回去。开关关掉时分钟数保留但置灰,
# 随时能原样开回来。
self.draft_autosend_enabled = QCheckBox("启用草稿超时直发")
self.draft_autosend_enabled.setCursor(Qt.PointingHandCursor)
self.draft_autosend = QDoubleSpinBox()
self.draft_autosend.setRange(
float(FOREIGN_DRAFT_AUTOSEND_MIN_MINUTES),
float(FOREIGN_DRAFT_AUTOSEND_MAX_MINUTES),
)
self.draft_autosend.setDecimals(0)
self.draft_autosend.setSingleStep(5.0)
self.draft_autosend.setSuffix(" 分钟")
self.draft_autosend.setMinimum(1.0) # 关不关由开关说了算
saved_autosend = float(settings["foreign_draft_autosend_minutes"])
self.draft_autosend_enabled.setChecked(saved_autosend > 0)
self.draft_autosend.setValue(saved_autosend if saved_autosend > 0 else 15.0)
self.draft_autosend.setEnabled(saved_autosend > 0)
autosend_hint = (
"输入框里有人工草稿时,机器人默认只是跳过这个会话、不动那段字。\n"
"打开这个开关后:草稿放够设定的时间,机器人会把**输入框里已有的那\n"
"句话**原样发给客户(不是它自己生成的回复)。关掉就永远不会替人按\n"
"回车。命中审核规则的内容仍然会停下等人确认,不受这里影响。"
)
self.draft_autosend.setToolTip(autosend_hint)
self.draft_autosend_enabled.setToolTip(autosend_hint)
self.draft_autosend_enabled.toggled.connect(self.draft_autosend.setEnabled)
self.mouse_idle = QCheckBox()
self.mouse_idle.setChecked(bool(settings["mouse_idle_enabled"]))
from PySide6.QtWidgets import QAbstractSpinBox
for control in (
self.poll, self.idle_seconds, self.send_delay,
self.batch_window, self.draft_autosend,
):
control.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
control.setFixedHeight(34)
control.setMaximumWidth(82)
if self.reference_preview:
self.poll.setValue(2.0)
self.batch_window.setValue(20.0)
self.send_delay.setValue(1.0)
self.idle_seconds.setValue(5.0)
strategy, strategy_box = _compact_card("监听与发送策略")
strategy.setFixedHeight(279)
running = QHBoxLayout()
running_icon = LineIconBadge("⌁", "StatusChip")
running_icon.setProperty("roundBadge", True)
running_icon.setFixedSize(44, 44)
running.addWidget(running_icon)
running.addWidget(_label("● 规则运行中", "SuccessText"))
running.addWidget(WaveBand(), 1)
strategy_box.addLayout(running)
strategy_box.addWidget(_flow_track((("◎", "扫描未读"), ("▣", "提取消息"), ("◇", "合并消息"), ("AI", "请求 AI"), ("✈", "回填发送")), 4))
params = QHBoxLayout()
for title, control in (("◷ 扫描间隔", self.poll), ("⌛ 连续消息等待", self.batch_window), ("◷ 发送前等待", self.send_delay)):
capsule = QFrame()
capsule.setObjectName("MiniCard")
cap = QHBoxLayout(capsule)
cap.setContentsMargins(10, 5, 10, 5)
cap.addWidget(_label(title, "CardSubtitle"))
cap.addWidget(control)
params.addWidget(capsule)
strategy_box.addLayout(params)
wecom, wecom_box = _compact_card("企业微信窗口")
wecom.setFixedHeight(279)
wecom_body = QHBoxLayout()
mock = WeComWindowPreview(166, 158)
wecom_body.addWidget(mock)
info = QVBoxLayout()
info.setSpacing(5)
for icon, title, value, tone in (("window", "主窗口:", "已识别", "success"), ("person", "当前账号:", "甄养堂客服", "blue"), ("⌁", "DPI", "200%", "blue"), ("monitor", "最小化恢复:", "已开启", "success")):
row = QFrame()
line = QHBoxLayout(row)
line.setContentsMargins(4, 3, 4, 3)
badge = LineIconBadge(icon, "BlueChip")
badge.setFixedSize(28, 28)
line.addWidget(badge)
line.addWidget(_label(title, "CardSubtitle"), 1)
line.addWidget(_label(value, "SuccessText" if tone == "success" else "CardSubtitle"))
info.addWidget(row)
wecom_body.addLayout(info, 1)
wecom_box.addLayout(wecom_body)
self.detect_wecom_button = _button("⟳ 重新检测")
self.detect_wecom_button.clicked.connect(self._redetect_wecom_window)
wecom_box.addWidget(self.detect_wecom_button, 0, Qt.AlignRight)
self.wecom_probe = {"hwnd": 0, "title": "", "dpi": "", "connected": False}
top = QGridLayout()
top.setHorizontalSpacing(22)
top.addWidget(strategy, 0, 0)
top.addWidget(wecom, 0, 1)
top.setColumnStretch(0, 7)
top.setColumnStretch(1, 4)
layout.addLayout(top)
layout.addSpacing(8)
coexist, coexist_box = _compact_card("人机共存")
coexist.setFixedHeight(245)
toggle_capsule = QFrame()
toggle_capsule.setObjectName("MiniCard")
toggle_row = QHBoxLayout(toggle_capsule)
toggle_row.setContentsMargins(10, 4, 10, 4)
toggle_row.addWidget(self.mouse_idle)
toggle_row.addWidget(_label("启用人工操作保护", "CardSubtitle"), 1)
coexist_box.addWidget(toggle_capsule)
idle_capsule = QFrame()
idle_capsule.setObjectName("MiniCard")
idle_row = QHBoxLayout(idle_capsule)
idle_row.setContentsMargins(10, 3, 10, 3)
idle_row.addWidget(_label("鼠标静止", "MetricMeta"))
idle_row.addWidget(_chip(f"{self.idle_seconds.value():g} 秒", "blue"))
idle_row.addWidget(_label("后恢复", "MetricMeta"))
idle_row.addStretch(1)
coexist_box.addWidget(idle_capsule)
draft_toggle_capsule = QFrame()
draft_toggle_capsule.setObjectName("MiniCard")
draft_toggle_capsule.setToolTip(self.draft_autosend_enabled.toolTip())
draft_toggle_row = QHBoxLayout(draft_toggle_capsule)
draft_toggle_row.setContentsMargins(10, 4, 10, 4)
draft_toggle_row.addWidget(self.draft_autosend_enabled, 1)
coexist_box.addWidget(draft_toggle_capsule)
draft_capsule = QFrame()
draft_capsule.setObjectName("MiniCard")
draft_capsule.setToolTip(self.draft_autosend.toolTip())
draft_row = QHBoxLayout(draft_capsule)
draft_row.setContentsMargins(10, 3, 10, 3)
draft_row.addWidget(_label("草稿放", "MetricMeta"))
draft_row.addWidget(self.draft_autosend)
draft_row.addWidget(_label("后直发", "MetricMeta"))
draft_row.addStretch(1)
coexist_box.addWidget(draft_capsule)
coexist_box.addWidget(HumanCoexistVisual(), 1)
processing, processing_box = _compact_card("消息处理")
processing.setFixedHeight(245)
for title, value, tone in (("同一客户连续消息自动合并", "", "success"), ("保留最近 8 轮上下文", "8 轮", "blue"), ("一次只处理 1 个会话", "1 个", "blue")):
row, value_label = _compact_row(title, value, tone=tone)
if not value:
value_label.hide()
processing_box.addWidget(row)
recovery, recovery_box = _compact_card("异常与兜底")
recovery.setFixedHeight(245)
for title, value, tone in (("AI 超时:重试 2 次", "2 次", "warning"), ("发送失败:自动重试", "已开启", "success")):
row, _ = _compact_row(title, value, tone=tone, icon="!" if tone == "warning" else "✓")
recovery_box.addWidget(row)
reply_row = QFrame()
reply_row.setObjectName("CompactRow")
reply_line = QHBoxLayout(reply_row)
reply_line.setContentsMargins(10, 4, 10, 4)
reply_badge = LineIconBadge("●", "BlueChip")
reply_badge.setFixedSize(28, 28)
reply_line.addWidget(reply_badge)
reply_line.addWidget(_label("固定回复:", "CardSubtitle"))
self.reply.setReadOnly(True)
self.reply.setStyleSheet("QLineEdit{background:transparent;border:none;padding:0;color:#5F6D94;}QLineEdit:focus{background:#FFFFFF;border:1px solid #BFD0FA;border-radius:8px;padding:3px 6px;}")
reply_line.addWidget(self.reply, 1)
edit_reply = _button("编辑")
edit_reply.setFixedSize(54, 28)
edit_reply.clicked.connect(lambda: (self.reply.setReadOnly(False), self.reply.setFocus()))
reply_line.addWidget(edit_reply)
recovery_box.addWidget(reply_row)
handoff, handoff_box = _compact_card("高风险转人工")
handoff.setFixedHeight(245)
handoff_box.setSpacing(4)
handoff_box.addWidget(_chip("● 已开启", "success"), 0, Qt.AlignRight)
self.risk_rule_buttons = []
for text in ("♧ 诊断", "◇ 用药调整", "♢ 投诉退款"):
button = _button(text)
button.setFixedSize(122, 36)
button.setStyleSheet("QPushButton{background:#FFFFFF;color:#6554E8;border:1px solid #D8D0FB;border-radius:12px;padding:4px 10px;font-size:13px;}QPushButton:hover{color:#5440DA;border-color:#BDAFFA;}")
button.setEnabled(False)
button.setToolTip("内置高风险转人工类别;请通过“规则管理”查看安全策略。")
self.risk_rule_buttons.append(button)
handoff_box.addWidget(button, 0, Qt.AlignLeft)
self.manage_rules_button = _button("⚙ 规则管理")
self.manage_rules_button.setFixedSize(145, 36)
self.manage_rules_button.setStyleSheet("QPushButton{background:#FFFFFF;color:#6554E8;border:1px solid #D8D0FB;border-radius:12px;padding:4px 10px;font-size:13px;}QPushButton:hover{color:#5440DA;border-color:#BDAFFA;}")
self.manage_rules_button.clicked.connect(self.securityRulesRequested)
handoff_box.addWidget(self.manage_rules_button, 0, Qt.AlignCenter)
middle = QGridLayout()
middle.setHorizontalSpacing(14)
middle.setVerticalSpacing(14)
for column, card in enumerate((coexist, processing, recovery, handoff)):
middle.addWidget(card, 0, column)
for column, stretch in enumerate((351, 390, 314, 283)):
middle.setColumnStretch(column, stretch)
layout.addLayout(middle)
layout.addSpacing(6)
checks, checks_box = _compact_card("启动前检查", margins=(16, 8, 16, 8))
checks.setFixedHeight(132)
check_row = QHBoxLayout()
check_row.setSpacing(0)
for index, (icon, title) in enumerate((("person", "企业微信已登录"), ("cloud", "AI 服务正常"), ("⌁", "窗口坐标已校准"), ("shield", "规则配置有效"))):
tile = QFrame()
tile.setObjectName("MiniCard")
tile.setFixedWidth(245)
tile_box = QHBoxLayout(tile)
tile_box.setContentsMargins(12, 8, 12, 8)
badge = LineIconBadge(icon, "StatusChip")
badge.setFixedSize(42, 42)
tile_box.addWidget(badge)
text_box = QVBoxLayout()
text_box.addWidget(_label(title, "CardSubtitle"))
text_box.addWidget(_label("检查完成", "SuccessText"))
tile_box.addLayout(text_box, 1)
check_row.addWidget(tile)
if index < 3:
connector = FlowConnector(True)
connector.setFixedWidth(54)
check_row.addWidget(connector)
complete_orb = GradientOrb("✓", 88)
check_row.addSpacing(26)
check_row.addWidget(complete_orb, 0, Qt.AlignVCenter)
checks_box.addLayout(check_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.send_delay.valueChanged.connect(self._schedule_save)
self.batch_window.valueChanged.connect(self._schedule_save)
self.draft_autosend.valueChanged.connect(self._schedule_save)
self.draft_autosend_enabled.toggled.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(),
"send_delay_seconds": float(self.send_delay.value()),
"send_mode": (
SEND_MODE_REVIEW
if self.review_send_mode.isChecked()
else SEND_MODE_AUTO
),
"message_batch_window_seconds": float(self.batch_window.value()),
# 开关关掉就存 0(= 关闭)。分钟数留在控件里不动,下次打开还是老值。
"foreign_draft_autosend_minutes": (
float(self.draft_autosend.value())
if self.draft_autosend_enabled.isChecked()
else 0.0
),
}
def _refresh_send_mode_buttons(self) -> None:
active_style = (
"background:#E8EDFF;color:#245DE7;border:none;border-radius:14px;"
"padding:9px 20px;font-weight:600;"
)
self.auto_send_mode.setStyleSheet(
active_style if self.auto_send_mode.isChecked() else ""
)
self.review_send_mode.setStyleSheet(
active_style if self.review_send_mode.isChecked() else ""
)
def _send_mode_toggled(self, checked: bool) -> None:
if not checked:
return
self._refresh_send_mode_buttons()
self._schedule_save()
def _redetect_wecom_window(self) -> None:
"""Run the backend's read-only window selection and report its real result."""
try:
from wechat_bot import find_wx_hwnd
hwnd = int(find_wx_hwnd() or 0)
except Exception as exc:
QMessageBox.warning(self, "重新检测失败", f"无法检测企业微信窗口:{exc}")
return
title = ""
dpi = ""
if hwnd:
try:
import win32gui
title = str(win32gui.GetWindowText(hwnd) or "").strip()
except Exception:
title = ""
try:
import ctypes
raw = int(ctypes.windll.user32.GetDpiForWindow(hwnd) or 0)
if raw > 0:
dpi = f"{int(round(raw / 96.0 * 100))}%"
except Exception:
dpi = ""
self.wecom_probe = {
"hwnd": hwnd,
"title": title,
"dpi": dpi,
"connected": bool(hwnd),
"checked_at": time.time(),
}
if hwnd:
QMessageBox.information(
self,
"重新检测完成",
f"已检测到企业微信主窗口(句柄 0x{hwnd:08X}"
+ (f"\n标题:{title}" if title else "")
+ (f"\nDPI{dpi}" if dpi else ""),
)
else:
QMessageBox.warning(
self,
"未检测到企业微信",
"没有找到企业微信主窗口。请先登录并打开企业微信主界面后重试。",
)
def _set_save_status(self, text: str, tone: str) -> None:
self.save_status.setText(text)
self.save_status.setObjectName(tone)
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
self.save_status.show()
def _schedule_save(self, *_args) -> None:
self._set_save_status("正在保存…", "WarningText")
self.save_timer.start()
def _emit_save(self) -> None:
# 手动点「保存设置」时也要立刻有反应。以前这里只是把值发出去,界面上
# 一动不动——存成功了才在别处闪一下,失败则悄无声息。
self._set_save_status("正在保存…", "WarningText")
self.saved.emit(self.values())
def mark_saved(self, ok: bool, message: str) -> None:
"""保存结果常驻显示,不再定时抹掉。
成功提示一闪而过的话,人根本来不及看见;失败提示被抹掉更糟——改动其实
没存上,界面却一片安静,下次启动才发现全白改了。留在那儿直到下一次保存
覆盖它,代价只是多一行字。
"""
stamp = time.strftime("%H:%M:%S")
self._set_save_status(
f"{'✓' if ok else '✕'} {message} {stamp}",
"SuccessText" if ok else "DangerText",
)
class SystemSettingsPage(QScrollArea):
"""系统级偏好、数据维护与诊断入口。"""
saved = Signal(dict)
def __init__(self, settings: dict):
super().__init__()
self.reference_preview = "--qt-smoke-test" in sys.argv
class _ReferenceSwitch(QCheckBox):
"""Compact painted switch used only by the system-settings board."""
def __init__(self):
super().__init__()
self.setFixedSize(48, 26)
self.setCursor(Qt.PointingHandCursor)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
track = QRect(1, 3, 46, 20)
painter.setPen(QPen(QColor("#AFC2FF" if self.isChecked() else "#D7DEED"), 1))
painter.setBrush(QColor("#316CFF" if self.isChecked() else "#C9D1E5"))
painter.drawRoundedRect(track, 10, 10)
thumb_x = 27 if self.isChecked() else 3
painter.setPen(QPen(QColor(255, 255, 255, 175), 1))
painter.setBrush(QColor("#FFFFFF"))
painter.drawEllipse(QRect(thumb_x, 5, 16, 16))
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
root = QWidget()
root.setObjectName("PageRoot")
root.setMinimumWidth(1040)
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(12, 18, 16, 8)
layout.setSpacing(13)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(12)
header.addLayout(_page_header("07", "系统设置", "管理软件启动、数据存储、通知与系统维护"), 1)
version_chip = _header_chip(f"当前版本 v{UI_VERSION} ⓘ", "blue")
version_chip.setFixedSize(200, 47)
header.addWidget(version_chip, 0, Qt.AlignVCenter)
check_update = _header_button("⌕ 检查更新")
check_update.setFixedSize(164, 47)
check_update.clicked.connect(self._check_updates)
header.addWidget(check_update, 0, Qt.AlignVCenter)
self.save_status = _label("", "SuccessText")
self.save_status.setMinimumWidth(170)
self.save_status.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
header.addWidget(self.save_status, 0, Qt.AlignVCenter)
self.save_button = _header_button("▣ 保存设置", "primary")
self.save_button.setFixedSize(184, 49)
self.save_button.clicked.connect(self._save)
header.addWidget(self.save_button, 0, Qt.AlignVCenter)
layout.addLayout(header)
self.auto_launch = _ReferenceSwitch()
self.auto_launch.setChecked(bool(settings.get("auto_launch", False)))
self.auto_monitor = _ReferenceSwitch()
self.auto_monitor.setChecked(bool(settings.get("auto_monitor", False)))
self.minimize_on_close = _ReferenceSwitch()
self.minimize_on_close.setChecked(bool(settings.get("minimize_on_close", True)))
self.keep_background = _ReferenceSwitch()
self.keep_background.setChecked(
bool(settings.get("keep_background", self.minimize_on_close.isChecked()))
)
if self.reference_preview:
for option in (
self.auto_launch,
self.auto_monitor,
self.minimize_on_close,
self.keep_background,
):
option.setChecked(True)
self.log_retention = QSpinBox()
self.log_retention.setRange(7, 365)
self.log_retention.setValue(int(settings.get("log_retention_days", 90)))
self.log_retention.setSuffix(" 天")
self.log_retention.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
self.log_retention.setAlignment(Qt.AlignCenter)
self.log_retention.setStyleSheet(
"QSpinBox{background:transparent;border:none;padding:0;color:#26365F;}"
)
self.notify_send_failed = _ReferenceSwitch()
self.notify_timeout = _ReferenceSwitch()
self.notify_manual = _ReferenceSwitch()
self.notify_disconnect = _ReferenceSwitch()
self.last_backup_at = ""
notice_defaults = ((self.notify_send_failed, "notify_send_failed"), (self.notify_timeout, "notify_timeout"), (self.notify_manual, "notify_manual"), (self.notify_disconnect, "notify_disconnect"))
for option, key in notice_defaults:
option.setChecked(bool(settings.get(key, True)))
grid = QGridLayout()
grid.setHorizontalSpacing(20)
grid.setVerticalSpacing(14)
def setting_badge(icon: str, tone: str = "blue", size: int = 30) -> LineIconBadge:
badge = LineIconBadge(icon, "StatusChip" if tone == "success" else "BlueChip")
badge.setFixedSize(size, size)
badge.setStyleSheet("background:transparent;border:none;padding:0;")
return badge
def toggle_row(icon: str, title: str, option: QCheckBox) -> QFrame:
row = QFrame()
row.setObjectName("CompactRow")
row.setFixedHeight(43)
line = QHBoxLayout(row)
line.setContentsMargins(10, 4, 10, 4)
line.setSpacing(10)
line.addWidget(setting_badge(icon), 0, Qt.AlignVCenter)
line.addWidget(_label(title, "CardSubtitle"), 1)
line.addWidget(option, 0, Qt.AlignVCenter)
return row
def select_row(icon: str, title: str, values: tuple[str, ...], current: str) -> tuple[QFrame, QComboBox]:
row = QFrame()
row.setObjectName("CompactRow")
row.setFixedHeight(43)
line = QHBoxLayout(row)
line.setContentsMargins(10, 4, 10, 4)
line.setSpacing(10)
line.addWidget(setting_badge(icon), 0, Qt.AlignVCenter)
line.addWidget(_label(f"{title}", "CardSubtitle"), 1)
selector = QComboBox()
selector.addItems(values)
if current in values:
selector.setCurrentText(current)
selector.setFixedWidth(150)
selector.setCursor(Qt.PointingHandCursor)
selector.setStyleSheet(
"QComboBox{background:transparent;border:none;padding:0 24px 0 4px;color:#26365F;}"
"QComboBox::drop-down{border:none;width:22px;}"
)
line.addWidget(selector)
return row, selector
def state_row(icon: str, title: str, value: str) -> QFrame:
row = QFrame()
row.setObjectName("CompactRow")
row.setFixedHeight(39)
line = QHBoxLayout(row)
line.setContentsMargins(10, 3, 10, 3)
line.setSpacing(9)
line.addWidget(setting_badge(icon, "success", 26), 0, Qt.AlignVCenter)
line.addWidget(_label(f"{title}", "CardSubtitle"), 1)
value_label = _label(value, "CardSubtitle")
value_label.setWordWrap(False)
line.addWidget(value_label)
check = setting_badge("✓", "success", 18)
line.addWidget(check, 0, Qt.AlignVCenter)
row.value_label = value_label
row.check_badge = check
return row
basics, basics_layout = _compact_card("基础设置")
basics.setFixedHeight(354)
basics_layout.setSpacing(4)
for icon, title, option in (
("◯", "开机自动启动", self.auto_launch),
("⌁", "启动后自动监听", self.auto_monitor),
("▣", "最小化到系统托盘", self.minimize_on_close),
("◇", "关闭窗口时保持后台运行", self.keep_background),
):
basics_layout.addWidget(toggle_row(icon, title, option))
language_row, self.interface_language = select_row(
"◎", "界面语言", ("简体中文",), str(settings.get("interface_language", "简体中文"))
)
scale_row, self.scale_ratio = select_row(
"▣", "缩放比例", ("自动", "100%", "125%", "150%", "200%"), str(settings.get("scale_ratio", "自动"))
)
basics_layout.addWidget(language_row)
basics_layout.addWidget(scale_row)
grid.addWidget(basics, 0, 0)
data, data_layout = _compact_card("数据与存储")
data.setFixedHeight(354)
data_layout.setSpacing(6)
storage = QHBoxLayout()
storage.setContentsMargins(0, 6, 0, 0)
storage.setSpacing(12)
ring_host = QWidget()
ring_host.setFixedSize(158, 158)
ring_stack = QGridLayout(ring_host)
ring_stack.setContentsMargins(0, 0, 0, 0)
ring_stack.addWidget(StatusRing("", "#4E78F7", 158), 0, 0, Qt.AlignCenter)
center = QWidget()
center.setFixedSize(104, 66)
center_box = QVBoxLayout(center)
center_box.setContentsMargins(0, 0, 0, 0)
center_box.setSpacing(0)
used = _label("已使用", "MetricMeta")
used.setAlignment(Qt.AlignCenter)
center_box.addWidget(used)
amount_row = QHBoxLayout()
amount_row.setContentsMargins(0, 0, 0, 0)
amount_row.setSpacing(4)
amount_row.addStretch(1)
amount = _label("286", "MetricValue")
amount.setStyleSheet("color:#101E49;font-size:27px;font-weight:600;")
amount_row.addWidget(amount)
amount_row.addWidget(_label("MB", "CardSubtitle"), 0, Qt.AlignBottom)
amount_row.addStretch(1)
center_box.addLayout(amount_row)
ring_stack.addWidget(center, 0, 0, Qt.AlignCenter)
storage.addWidget(ring_host, 0, Qt.AlignTop)
legend = QVBoxLayout()
legend.setSpacing(6)
for title, value, tone in (("● 会话归档", "128 MB", "blue"), ("● 运行日志", "86 MB", "blue"), ("● 缓存数据", "72 MB", "success")):
row = QHBoxLayout()
row.addWidget(_label(title, "CardSubtitle"), 1)
row.addWidget(_label(value, "MetricMeta"))
legend.addLayout(row)
legend.addStretch(1)
retention_row = QHBoxLayout()
retention_row.addWidget(_label("数据保留", "CardSubtitle"))
retention_control = QFrame()
retention_control.setObjectName("CompactRow")
retention_control.setFixedSize(100, 38)
retention_control_row = QHBoxLayout(retention_control)
retention_control_row.setContentsMargins(8, 2, 8, 2)
retention_control_row.addWidget(self.log_retention, 1)
retention_control_row.addWidget(_label("⌄", "CardSubtitle"))
retention_row.addWidget(retention_control)
legend.addLayout(retention_row)
storage.addLayout(legend, 1)
data_layout.addLayout(storage, 1)
data_actions = QHBoxLayout()
data_actions.setSpacing(8)
open_dir = _button("▱ 打开目录")
open_dir.clicked.connect(self._open_data_dir)
backup = _button("☁ 立即备份")
backup.clicked.connect(self._backup_now)
clear_cache = _button("♨ 清理缓存")
clear_cache.clicked.connect(self._clear_cache)
for action in (open_dir, backup, clear_cache):
action.setFixedHeight(43)
data_actions.addWidget(action)
data_layout.addLayout(data_actions)
grid.addWidget(data, 0, 1)
notices, notices_layout = _compact_card("通知提醒")
notices.setFixedHeight(354)
notices_layout.setSpacing(4)
for icon, title, option in (("✈", "发送失败提醒", self.notify_send_failed), ("⌛", "模型超时提醒", self.notify_timeout), ("♙", "需要人工跟进", self.notify_manual), ("↗", "企业微信连接中断", self.notify_disconnect)):
notices_layout.addWidget(toggle_row(icon, title, option))
preview_title = _label("通知预览", "MetricMeta")
preview_title.setStyleSheet("color:#4E5D85;font-size:12px;")
notices_layout.addWidget(preview_title)
preview = QFrame()
preview.setObjectName("MiniCard")
preview.setFixedHeight(58)
preview_box = QHBoxLayout(preview)
preview_box.setContentsMargins(10, 6, 10, 6)
preview_box.addWidget(setting_badge("bell", "blue", 36))
preview_copy = QVBoxLayout()
preview_copy.setSpacing(1)
preview_copy.addWidget(_label("发送失败提醒", "CardSubtitle"))
preview_copy.addWidget(_label("消息发送失败,已加入重试队列", "MetricMeta"))
preview_box.addLayout(preview_copy, 1)
preview_meta = QVBoxLayout()
preview_meta.addWidget(_label("16:33", "MetricMeta"), 0, Qt.AlignRight)
unread = _label("●", "BlueText")
unread.setAlignment(Qt.AlignRight)
preview_meta.addWidget(unread)
preview_box.addLayout(preview_meta)
notices_layout.addWidget(preview)
grid.addWidget(notices, 0, 2)
self.mask_sensitive = _ReferenceSwitch()
self.mask_sensitive.setChecked(bool(settings.get("mask_sensitive", True)))
self.encrypt_local = _ReferenceSwitch()
self.encrypt_local.setChecked(bool(settings.get("encrypt_local", False)))
self.hide_chat_in_logs = _ReferenceSwitch()
self.hide_chat_in_logs.setChecked(bool(settings.get("hide_chat_in_logs", False)))
self.clear_clipboard_on_exit = _ReferenceSwitch()
self.clear_clipboard_on_exit.setChecked(bool(settings.get("clear_clipboard_on_exit", True)))
security, security_layout = _compact_card("安全与隐私")
security.setFixedHeight(241)
security_layout.setSpacing(4)
for icon, title, option in (
("◇", "敏感信息脱敏", self.mask_sensitive),
("▣", "本地数据加密", self.encrypt_local),
("▣", "日志隐藏聊天正文", self.hide_chat_in_logs),
("▣", "退出时清理剪贴板", self.clear_clipboard_on_exit),
):
security_layout.addWidget(toggle_row(icon, title, option))
grid.addWidget(security, 1, 0)
network, network_layout = _compact_card("后台连接")
network.setFixedHeight(241)
network_layout.setSpacing(4)
# 后台地址是桌面端**唯一**需要人填的东西。
#
# 模型接口、密钥、网关地址、角色编排全部由后台下发——填对这一个,其余
# 自动到位。以前这个输入框只存在于一个从没被打开过的对话框里
# BackendLoginDialog),等于界面上根本没法改,只能去手工编辑
# backend_connection.json。
network_layout.addWidget(_label("后台地址", "CardSubtitle"))
self.server_url = QLineEdit()
self.server_url.setFixedHeight(38)
self.server_url.setPlaceholderText("https://你的域名 或 http://127.0.0.1:8765")
try:
import backend_client as _bc
_saved = _bc.load_settings()
self.server_url.setText(
str(_saved.get("server_url") or _bc.DEFAULT_SERVER_URL)
)
except Exception:
self.server_url.setText("")
network_layout.addWidget(self.server_url)
self.network_status_rows: dict[str, QFrame] = {}
for icon, title, value in (
("☁", "配置同步", "未检测"),
("⌁", "模型网关", "未同步"),
):
row = state_row(icon, title, value)
self.network_status_rows[title] = row
network_layout.addWidget(row)
test = _button("⌁ 保存并连接", "primary")
test.setFixedHeight(40)
test.clicked.connect(self._test_connection)
network_layout.addWidget(test, 0, Qt.AlignHCenter)
grid.addWidget(network, 1, 1)
maintenance, maintenance_layout = _compact_card("维护与诊断")
maintenance.setFixedHeight(241)
maintenance_layout.setSpacing(8)
maintenance_grid = QGridLayout()
maintenance_grid.setHorizontalSpacing(8)
maintenance_grid.setVerticalSpacing(8)
def maintenance_action(icon: str, title: str, detail: str, callback) -> QPushButton:
action = _button("")
action.setFixedHeight(72)
action_layout = QHBoxLayout(action)
action_layout.setContentsMargins(12, 7, 12, 7)
action_layout.setSpacing(10)
action_layout.addWidget(setting_badge(icon, "blue", 42), 0, Qt.AlignVCenter)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(title, "CardSubtitle"))
copy.addWidget(_label(detail, "MetricMeta"))
action_layout.addLayout(copy, 1)
action.clicked.connect(callback)
return action
check = maintenance_action("⟳", "检查更新", "已是最新版本", self._check_updates)
diagnose = maintenance_action("▣", "导出诊断", "导出系统诊断报告", self._export_diagnostics)
reset = maintenance_action("↻", "恢复默认设置", "重置所有设置项", self._restore_defaults)
def show_license() -> None:
QMessageBox.information(
self,
"许可信息",
f"甄养堂企业微信 AI 自动回复\n版本 {APP_VERSION}\n本机授权状态正常。\n\n"
"本软件使用 HarmonyOS Sans 字体,版权归 Huawei Device Co., Ltd. 所有。\n"
"数字指标使用 Inter 字体(SIL Open Font License 1.1)。\n"
"完整字体许可文本随应用保存在 assets/fonts 目录。",
)
license_button = maintenance_action("◇", "查看许可信息", "查看软件许可详情", show_license)
for index, action in enumerate((check, diagnose, reset, license_button)):
maintenance_grid.addWidget(action, index // 2, index % 2)
maintenance_layout.addLayout(maintenance_grid)
grid.addWidget(maintenance, 1, 2)
for column, stretch in enumerate((455, 437, 465)):
grid.setColumnStretch(column, stretch)
layout.addLayout(grid)
layout.addSpacing(6)
status, status_layout = _compact_card("系统状态")
status.setFixedHeight(146)
status_band = QFrame()
status_band.setObjectName("SystemStatusBand")
status_band.setFixedHeight(78)
status_band.setStyleSheet(
"QFrame#SystemStatusBand{background:rgba(250,252,255,225);"
"border:1px solid #E0E6F4;border-radius:16px;}"
)
status_grid = QHBoxLayout(status_band)
status_grid.setContentsMargins(10, 7, 10, 7)
status_grid.setSpacing(0)
for icon, title, detail, tone in (("♢", "配置已保存", "所有设置已生效", "success"), ("☁", "数据已备份", "最近备份:16:33:28", "blue"), ("♢", "安全策略正常", "所有安全策略已启用", "success"), ("▣", "后台服务运行中", "系统运行状态良好", "blue")):
tile = QWidget()
line = QHBoxLayout(tile)
line.setContentsMargins(10, 0, 10, 0)
line.setSpacing(10)
badge = LineIconBadge(icon, "StatusChip" if tone == "success" else "WarningChip" if tone == "warning" else "BlueChip")
badge.setProperty("roundBadge", True)
badge.setFixedSize(46, 46)
line.addWidget(badge)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(title, "CardSubtitle"))
copy.addWidget(_label(detail, "MetricMeta"))
line.addLayout(copy, 1)
status_grid.addWidget(tile, 1)
if title != "后台服务运行中":
divider = QFrame()
divider.setFixedWidth(1)
divider.setStyleSheet("background:#E1E7F3;border:none;")
status_grid.addWidget(divider)
status_layout.addWidget(status_band)
layout.addWidget(status)
layout.addStretch(1)
def _restore_defaults(self) -> None:
if QMessageBox.question(self, "恢复默认设置", "确定恢复系统设置默认值吗?会话、队列和日志不会删除。") != QMessageBox.Yes:
return
self.auto_launch.setChecked(False)
self.auto_monitor.setChecked(False)
self.minimize_on_close.setChecked(True)
self.keep_background.setChecked(True)
self.interface_language.setCurrentText("简体中文")
self.scale_ratio.setCurrentText("自动")
self.log_retention.setValue(90)
self.mask_sensitive.setChecked(True)
self.encrypt_local.setChecked(False)
self.hide_chat_in_logs.setChecked(False)
self.clear_clipboard_on_exit.setChecked(True)
for option in (self.notify_send_failed, self.notify_timeout, self.notify_manual, self.notify_disconnect):
option.setChecked(True)
self._save()
def values(self) -> dict:
return {
"auto_launch": self.auto_launch.isChecked(),
"auto_monitor": self.auto_monitor.isChecked(),
"minimize_on_close": self.minimize_on_close.isChecked(),
"keep_background": self.keep_background.isChecked(),
"interface_language": self.interface_language.currentText(),
"scale_ratio": self.scale_ratio.currentText(),
"log_retention_days": self.log_retention.value(),
"mask_sensitive": self.mask_sensitive.isChecked(),
"encrypt_local": self.encrypt_local.isChecked(),
"hide_chat_in_logs": self.hide_chat_in_logs.isChecked(),
"clear_clipboard_on_exit": self.clear_clipboard_on_exit.isChecked(),
"notify_send_failed": self.notify_send_failed.isChecked(),
"notify_timeout": self.notify_timeout.isChecked(),
"notify_manual": self.notify_manual.isChecked(),
"notify_disconnect": self.notify_disconnect.isChecked(),
}
def _save(self) -> None:
# 先只说"正在保存"。过去这里直接把按钮改成"已保存",可写盘还没发生——
# 写失败了按钮照样显示已保存,人以为存上了。真正的结果由
# `mark_saved()` 在写完之后填。
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_status.show()
self.saved.emit(self.values())
def mark_saved(self, ok: bool, message: str) -> None:
stamp = time.strftime("%H:%M:%S")
self.save_status.setText(f"{'✓' if ok else '✕'} {message} {stamp}")
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)
self.save_status.show()
def _open_data_dir(self) -> None:
if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(SCRIPT_DIR))):
QMessageBox.warning(self, "打开失败", f"无法打开数据目录:\n{SCRIPT_DIR}")
def _set_network_result(self, title: str, value: str, ok: bool) -> None:
row = self.network_status_rows.get(title)
if row is None:
return
row.value_label.setText(value)
row.check_badge.icon = "✓" if ok else "!"
row.check_badge.setObjectName("SuccessText" if ok else "WarningChip")
row.check_badge.style().unpolish(row.check_badge)
row.check_badge.style().polish(row.check_badge)
row.check_badge.update()
def _connect_backend(self, url: str) -> dict:
"""保存后台地址并立刻同步一次。Qt 页面和 HTML 控制台共用这一份。
以前这里只是 TCP 连一下端口就报"已连接"。端口通不代表后台在那儿——反代
配错时 80 端口照样通,你会看到一个绿色的"已连接",然后纳闷为什么配置一直
不更新。现在要真的同步成功、拿到配置版本号,才算连上。
"""
import backend_client
url = str(url or "").strip()
if not url:
return {"ok": False, "message": "请先填写后台地址。"}
settings = backend_client.load_settings()
settings["server_url"] = backend_client.normalize_server_url(url)
# 人手填的地址是明确选择,之后不许被"同机后台自动发现"改掉——否则你填了
# 公网域名,下次读配置就被换成本地的某个后台,表现是登录成功、随后每个
# 请求 401,而界面上完全看不出地址被换过。
settings["server_url_pinned"] = True
backend_client.save_settings(settings)
old_default = backend_client.DEFAULT_SERVER_URL
backend_client.DEFAULT_SERVER_URL = settings["server_url"]
try:
result = backend_client.sync_cloud_config(timeout=8.0)
except Exception as exc:
self._set_network_result("配置同步", "失败", False)
self._set_network_result("模型网关", "未同步", False)
return {
"ok": False,
"server_url": settings["server_url"],
"message": (
f"{exc}\n\n检查:\n"
f" · 地址是否正确:{settings['server_url']}\n"
" · 后台是否已启动(python run_backend.py\n"
" · 走反代时 Nginx 是否已配好"
),
}
finally:
backend_client.DEFAULT_SERVER_URL = old_default
gateway_url = str(
(backend_client.load_settings().get("gateway") or {}).get("url") or ""
)
version = result.get("version", 0)
self._set_network_result("配置同步", f"v{version}", True)
self._set_network_result(
"模型网关", "已就绪" if gateway_url else "后台未下发", bool(gateway_url)
)
return {
"ok": True,
"server_url": settings["server_url"],
"version": version,
"gateway_url": gateway_url,
"message": (
f"后台:{settings['server_url']}\n"
f"配置版本:v{version}\n"
f"模型网关:{gateway_url or '(后台没有下发,模型调用会失败)'}\n\n"
"模型接口、密钥和角色编排都由后台下发,本机不用再配。"
),
}
def _test_connection(self) -> None:
result = self._connect_backend(self.server_url.text())
if result.get("server_url"):
self.server_url.setText(result["server_url"])
if result.get("ok"):
QMessageBox.information(self, "连接成功", result["message"])
else:
QMessageBox.warning(self, "连接失败", result["message"])
def _backup_now(self) -> None:
target = SCRIPT_DIR / "backups" / time.strftime("%Y%m%d-%H%M%S")
try:
target.mkdir(parents=True, exist_ok=False)
copied = 0
for name in (
"app_settings.json",
"ai_settings.json",
"conversations.json",
"pending_replies.json",
"registration_leads.json",
"queue_events.json",
):
source = SCRIPT_DIR / name
if source.is_file():
shutil.copy2(source, target / name)
copied += 1
except OSError as exc:
QMessageBox.warning(self, "备份失败", str(exc))
return
QMessageBox.information(self, "备份完成", f"已备份 {copied} 个数据文件到:\n{target}")
self.last_backup_at = time.strftime("%Y-%m-%d %H:%M:%S")
def _clear_cache(self) -> None:
if QMessageBox.question(self, "确认清理", "清理视觉状态与误判缓存?会话和队列不会删除。") != QMessageBox.Yes:
return
removed = 0
for name in ("false_pos_cache.json", "vision_status.json"):
path = SCRIPT_DIR / name
try:
if path.is_file():
path.unlink()
removed += 1
except OSError as exc:
QMessageBox.warning(self, "清理失败", str(exc))
return
QMessageBox.information(self, "清理完成", f"已清理 {removed} 个缓存文件。")
def _check_updates(self) -> None:
try:
import backend_client
status = backend_client.cached_release_status()
except Exception as exc:
QMessageBox.warning(self, "检查更新失败", str(exc))
return
if status.get("update_available"):
QMessageBox.information(
self,
"发现新版本",
f"当前版本:{APP_VERSION}\n最新版本:{status.get('latest_version')}\n\n{status.get('release_notes') or '请联系管理员获取更新。'}",
)
else:
QMessageBox.information(self, "检查更新", f"当前已是最新版本 v{APP_VERSION}。")
def _export_diagnostics(self) -> None:
suggested = SCRIPT_DIR / f"diagnostics-{time.strftime('%Y%m%d-%H%M%S')}.json"
filename, _kind = QFileDialog.getSaveFileName(
self, "导出诊断报告", str(suggested), "JSON 文件 (*.json)"
)
if not filename:
return
payload = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"app_version": APP_VERSION,
"settings": self.values(),
"data_dir": str(SCRIPT_DIR),
"files": sorted(path.name for path in SCRIPT_DIR.iterdir() if path.is_file()),
}
try:
Path(filename).write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
except OSError as exc:
QMessageBox.warning(self, "导出失败", str(exc))
return
QMessageBox.information(self, "导出完成", f"诊断报告已保存:\n{filename}")
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__()
class _BusinessIcon(QLabel):
"""Small painted archive-page icon independent of symbol fonts."""
def __init__(self, kind: str, tone: str = "blue", size: int = 40):
super().__init__("")
self.kind = kind
self.tone = tone
self.setFixedSize(size, size)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
palette = {
"blue": ("#316CFF", "#EEF3FF"),
"success": ("#11AF7B", "#EAF9F4"),
"warning": ("#ED942B", "#FFF4E7"),
"purple": ("#7559E8", "#F1EEFF"),
"muted": ("#8793B3", "#F2F4F9"),
}
color, fill = palette.get(self.tone, palette["blue"])
painter.setPen(QPen(QColor(fill), 1))
painter.setBrush(QColor(fill))
painter.drawEllipse(self.rect().adjusted(1, 1, -1, -1))
painter.setPen(QPen(QColor(color), 1.7, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
painter.setBrush(Qt.NoBrush)
cx, cy = self.width() // 2, self.height() // 2
if self.kind == "registration":
painter.drawRoundedRect(QRect(cx - 8, cy - 10, 16, 21), 2, 2)
painter.drawRoundedRect(QRect(cx - 4, cy - 13, 8, 5), 2, 2)
painter.drawLine(cx - 4, cy - 3, cx + 4, cy - 3)
painter.drawLine(cx - 4, cy + 3, cx + 4, cy + 3)
elif self.kind == "followup":
painter.drawArc(QRect(cx - 11, cy - 11, 22, 22), 210 * 16, 120 * 16)
painter.drawEllipse(QRect(cx - 11, cy + 3, 5, 7))
painter.drawEllipse(QRect(cx + 6, cy - 10, 5, 7))
elif self.kind in {"manual", "user"}:
painter.drawEllipse(QRect(cx - 4, cy - 10, 8, 8))
painter.drawArc(QRect(cx - 10, cy - 1, 20, 16), 0, 180 * 16)
elif self.kind == "robot":
painter.drawRoundedRect(QRect(cx - 10, cy - 7, 20, 15), 4, 4)
painter.drawPoint(cx - 4, cy)
painter.drawPoint(cx + 4, cy)
painter.drawLine(cx, cy - 7, cx, cy - 11)
painter.drawEllipse(QRect(cx - 2, cy - 13, 4, 4))
else:
painter.drawEllipse(QRect(cx - 8, cy - 8, 16, 16))
painter.drawLine(cx - 4, cy, cx - 1, cy + 3)
painter.drawLine(cx - 1, cy + 3, cx + 5, cy - 4)
self._business_icon_class = _BusinessIcon
self._smoke_reference = "--qt-smoke-test" in sys.argv
self.archive_date = ""
self._pending_lookup = {}
self.active_archive_filter = "全部"
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
root = QWidget()
root.setObjectName("PageRoot")
root.setMinimumWidth(1040)
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(12, 18, 16, 8)
layout.setSpacing(13)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(12)
header.addLayout(_page_header("03", "会话归档", "保存客户上下文、自动回复与回访线索"), 1)
connected_chip = _header_chip("● 企业微信已连接", "success")
connected_chip.setFixedSize(183, 48)
header.addWidget(connected_chip, 0, Qt.AlignVCenter)
ai_chip = _header_chip("● AI 服务正常", "success")
ai_chip.setFixedSize(165, 48)
header.addWidget(ai_chip, 0, Qt.AlignVCenter)
export_records = _header_button("↓ 导出记录")
export_records.setFixedSize(154, 48)
export_records.clicked.connect(self._export_records)
header.addWidget(export_records, 0, Qt.AlignVCenter)
layout.addLayout(header)
filters = QFrame()
filters.setObjectName("FilterBar")
filters.setFixedHeight(64)
filter_row = QHBoxLayout(filters)
filter_row.setContentsMargins(12, 7, 12, 7)
filter_row.setSpacing(12)
self.archive_search = QLineEdit()
self.archive_search.setMaxLength(80)
self.archive_search.setPlaceholderText("搜索客户或会话 ID")
self.archive_search.addAction(
_painted_ui_icon("search", 18), QLineEdit.ActionPosition.LeadingPosition
)
self.archive_search.setFixedSize(298, 44)
self.archive_search.textChanged.connect(self._apply_archive_filter)
filter_row.addWidget(self.archive_search)
self.archive_filter_buttons: list[QPushButton] = []
filter_specs = (
("全部会话", True, 132),
("今日", False, 88),
("AI 已回复", False, 124),
("已转人工", False, 124),
("2026-08-18", False, 194),
)
for text, active, width in filter_specs:
button = _button(text)
button.setCheckable(True)
button.setAutoExclusive(True)
button.setChecked(active)
button.setFixedSize(width, 44)
if "2026-" in text:
button.setIcon(_painted_ui_icon("calendar", 18))
button.setIconSize(QSize(18, 18))
button.setLayoutDirection(Qt.RightToLeft)
elif text.startswith("全部"):
button.setIcon(_painted_ui_icon("chevron", 16, "#245DE7"))
button.setIconSize(QSize(16, 16))
button.setLayoutDirection(Qt.RightToLeft)
key = "全部" if text.startswith("全部") else "日期" if "2026-" in text else text
button.toggled.connect(lambda checked, name=key: checked and self._set_archive_filter(name))
self.archive_filter_buttons.append(button)
filter_row.addWidget(button)
filter_row.addStretch(1)
layout.addWidget(filters)
layout.addSpacing(11)
self.registration_metric = MetricCard("待处理登记", "0", "需要人工跟进")
self.session_metric = MetricCard("会话档案", "0", "长期上下文记录")
self.registration_metric.hide()
self.session_metric.hide()
recent, recent_box = _compact_card("最近会话")
recent.setFixedHeight(521)
self.recent_list = QListWidget()
self.recent_list.setObjectName("DeskSessionList")
self.recent_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.recent_list.setMinimumHeight(310)
self.recent_list.setSpacing(0)
self.recent_list.currentRowChanged.connect(self._select_visible_session)
recent_box.addWidget(self.recent_list)
recent_footer = QHBoxLayout()
self.recent_count = _label("共 0 个会话", "CardSubtitle")
recent_footer.addWidget(self.recent_count)
recent_footer.addStretch(1)
reload_button = HeaderActionButton("⟳")
reload_button.setObjectName("SecondaryButton")
reload_button.setCursor(Qt.PointingHandCursor)
reload_button.setFixedSize(50, 40)
reload_button.clicked.connect(self.refresh_data)
recent_footer.addWidget(reload_button)
recent_box.addLayout(recent_footer)
conversation, conversation_box = _compact_card("会话记录")
conversation.setFixedHeight(521)
conversation_meta_row = QHBoxLayout()
conversation_meta_row.setSpacing(8)
self.conversation_identity = _chip("● 选择左侧会话", "blue")
self.conversation_count = _chip("0 条消息", "blue")
self.conversation_status = _chip("等待选择", "blue")
self.conversation_updated = _chip("◷ --:-- 更新", "blue")
for widget, width in (
(self.conversation_identity, 132),
(self.conversation_count, 92),
(self.conversation_status, 98),
(self.conversation_updated, 126),
):
widget.setFixedSize(width, 32)
conversation_meta_row.addWidget(widget)
conversation_meta_row.addStretch(1)
conversation_box.addLayout(conversation_meta_row)
self.conversation_meta = self.conversation_identity
self.chat_scroll = QScrollArea()
self.chat_scroll.setWidgetResizable(True)
self.chat_scroll.setFrameShape(QFrame.NoFrame)
self.chat_scroll.setStyleSheet("QScrollArea{background:transparent;border:none;}QScrollArea>QWidget>QWidget{background:transparent;}")
self.chat_root = QWidget()
self.chat_box = QVBoxLayout(self.chat_root)
self.chat_box.setContentsMargins(2, 2, 2, 2)
self.chat_box.setSpacing(5)
self.chat_scroll.setWidget(self.chat_root)
conversation_box.addWidget(self.chat_scroll, 1)
memory, memory_box = _compact_card("客户记忆")
memory.setFixedHeight(283)
memory_box.setSpacing(8)
memory_tags = QHBoxLayout()
memory_tags.setSpacing(10)
for text, tone, width in (("饮食控制", "success", 96), ("体重管理", "blue", 96), ("长期回访", "blue", 96)):
tag = _chip(text, tone)
tag.setFixedSize(width, 36)
memory_tags.addWidget(tag)
memory_tags.addStretch(1)
memory_box.addLayout(memory_tags)
memory_note = QFrame()
memory_note.setObjectName("MiniCard")
memory_note.setFixedHeight(118)
memory_note_box = QVBoxLayout(memory_note)
memory_note_box.setContentsMargins(14, 12, 14, 12)
memory_text = _label(
"客户关注灵芝孢子粉的服用方法与注意事项,\n"
"希望优化服用时间与剂量,同时注重产品安全\n"
"性与体质适配。",
"CardSubtitle",
)
memory_note_box.addWidget(memory_text)
memory_box.addWidget(memory_note)
context_chip = _chip("◷ 上下文 8 轮", "blue")
context_chip.setFixedSize(132, 30)
memory_box.addWidget(context_chip, 0, Qt.AlignLeft)
sediment, sediment_box = _compact_card("业务沉淀")
sediment.setFixedHeight(224)
sediment_box.setSpacing(5)
for kind, title, detail, value, tone in (
("registration", "挂号登记", "已登记潜在客户", "1 次", "success"),
("followup", "回访线索", "创建回访计划", "1 条", "purple"),
("manual", "人工跟进", "未分配客服", "0 次", "warning"),
):
row = QFrame()
row.setObjectName("CompactRow")
row.setFixedHeight(50)
row_line = QHBoxLayout(row)
row_line.setContentsMargins(9, 4, 10, 4)
row_line.setSpacing(9)
row_line.addWidget(self._business_icon_class(kind, tone, 38))
copy = QVBoxLayout()
copy.setSpacing(1)
copy.addWidget(_label(title, "CardSubtitle"))
copy.addWidget(_label(detail, "MetricMeta"))
row_line.addLayout(copy, 1)
value_label = _label(value, "CardSubtitle")
value_label.setWordWrap(False)
row_line.addWidget(value_label)
state_tick = QLabel("✓")
state_tick.setAlignment(Qt.AlignCenter)
state_tick.setFixedSize(22, 22)
state_tick.setStyleSheet(
"color:white;background:"
+ ("#14B77F" if value != "0 次" else "#C8D0E2")
+ ";border:none;border-radius:11px;font-size:11px;font-weight:600;"
)
row_line.addWidget(state_tick)
sediment_box.addWidget(row)
center = QGridLayout()
center.setHorizontalSpacing(14)
center.setVerticalSpacing(14)
center.addWidget(recent, 0, 0, 2, 1)
center.addWidget(conversation, 0, 1, 2, 1)
center.addWidget(memory, 0, 2)
center.addWidget(sediment, 1, 2)
center.setColumnStretch(0, 334)
center.setColumnStretch(1, 605)
center.setColumnStretch(2, 429)
center.setRowMinimumHeight(0, 283)
center.setRowMinimumHeight(1, 224)
layout.addLayout(center)
updates, updates_box = _compact_card("最近更新")
updates.setFixedHeight(168)
update_row = QHBoxLayout()
update_row.setSpacing(8)
for args, width in zip(
(("16:33:12", "AI 已回复", "发送注意事项与服用建议", "success"), ("16:32:42", "AI 已回复", "回复服用时间与注意事项", "success"), ("16:32:26", "客户提问", "询问有什么注意事项", "blue"), ("16:32:18", "AI 已回复", "建议每日 12 次服用", "success")),
(230, 230, 230, 218),
):
tile = _activity_tile(*args)
tile.setFixedSize(width, 90)
update_row.addWidget(tile)
self.copy_button = _button("▣ 复制全部记录")
self.copy_button.setFixedSize(132, 48)
self.copy_button.clicked.connect(self._copy_selected_records)
self.mark_contact_button = _button("✓ 标记已联系")
self.mark_contact_button.setFixedSize(120, 48)
self.mark_contact_button.clicked.connect(self._mark_registration_done)
self.delete_sessions_button = _button("♲ 删除所选", "danger")
self.delete_sessions_button.setFixedSize(112, 48)
self.delete_sessions_button.clicked.connect(self._delete_sessions)
update_row.addWidget(self.copy_button)
update_row.addWidget(self.mark_contact_button)
update_row.addWidget(self.delete_sessions_button)
updates_box.addLayout(update_row)
layout.addWidget(updates)
# Original tables remain the authoritative data/selection layer used by
# dialogs and tests; the dashboard above is their design-faithful view.
self.registration_metric = MetricCard("待处理登记", "0", "需要人工跟进")
self.session_metric = MetricCard("会话档案", "0", "长期上下文记录")
self.registration_table = self._table(
["最近更新", "微信客户", "症状 / 诉求", "状态"]
)
self.session_table = self._table(["最近更新", "会话 ID", "消息数", "内容预览"])
self.session_table.cellDoubleClicked.connect(self._open_session_detail)
for hidden in (self.registration_metric, self.session_metric, self.registration_table, self.session_table):
hidden.hide()
layout.addStretch(1)
self.refresh_data()
def _set_archive_filter(self, name: str) -> None:
self.active_archive_filter = name
self._apply_archive_filter()
def _set_archive_date(self, value: str) -> None:
text = str(value or "").strip()
self.archive_date = text if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text) else ""
if self.archive_date:
self.active_archive_filter = "日期"
elif getattr(self, "active_archive_filter", "") == "日期":
self.active_archive_filter = "全部"
self._apply_archive_filter()
def _apply_archive_filter(self) -> None:
if not hasattr(self, "recent_list"):
return
mode = getattr(self, "active_archive_filter", "全部")
needle = self.archive_search.text().strip().casefold()
midnight = time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))
date_key = str(getattr(self, "archive_date", "") or "")
for row in range(self.recent_list.count()):
item = self.recent_list.item(row)
searchable = str(item.data(Qt.UserRole + 1) or "").casefold()
stamp = float(item.data(Qt.UserRole + 2) or 0.0)
status = str(item.data(Qt.UserRole + 3) or "")
day = time.strftime("%Y-%m-%d", time.localtime(stamp)) if stamp else ""
mode_match = (
mode == "全部"
or mode in {"今日"} and stamp >= midnight
or mode == "日期" and (not date_key or day == date_key)
or mode == "AI 已回复" and status == "AI 已回复"
or mode == "已转人工" and status == "已转人工"
)
item.setHidden(not mode_match or bool(needle and needle not in searchable))
def _export_records(self) -> None:
suggested = SCRIPT_DIR / f"conversation-export-{time.strftime('%Y%m%d-%H%M%S')}.json"
filename, _kind = QFileDialog.getSaveFileName(
self, "导出会话记录", str(suggested), "JSON 文件 (*.json)"
)
if not filename:
return
exported = []
for session in getattr(self, "archive_sessions", []):
record = dict(session)
session_id = str(session.get("session_id") or "")
try:
record["messages"] = list(self.conversation_store.history(session_id))
except Exception:
record["messages"] = []
exported.append(record)
payload = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"session_count": len(exported),
"sessions": exported,
}
try:
Path(filename).write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
except OSError as exc:
QMessageBox.warning(self, "导出失败", str(exc))
return
QMessageBox.information(self, "导出完成", f"已导出 {len(exported)} 个会话:\n{filename}")
def _clear_chat(self) -> None:
while self.chat_box.count():
item = self.chat_box.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
@staticmethod
def _chat_time(message: dict, fallback: str = "--:--:--") -> str:
raw = (
message.get("time")
or message.get("created_at")
or message.get("timestamp")
or message.get("updated")
)
if isinstance(raw, (int, float)):
try:
return time.strftime("%H:%M:%S", time.localtime(float(raw)))
except (ValueError, OSError):
return fallback
text = str(raw or "").strip()
if len(text) >= 8 and text[-8:-6].isdigit() and text[-5:-3].isdigit():
return text[-8:]
return fallback
def _append_chat_message(self, message: dict, index: int) -> None:
role = str(message.get("role") or "system").lower()
assistant = role == "assistant"
stamp = self._chat_time(message, f"16:32:{11 + index * 7:02d}")
content = str(message.get("content") or "")
holder = QWidget()
holder_box = QVBoxLayout(holder)
holder_box.setContentsMargins(0, 0, 0, 0)
holder_box.setSpacing(2)
time_row = QHBoxLayout()
time_row.setContentsMargins(0, 0, 0, 0)
time_label = _label(stamp, "MetricMeta")
time_label.setWordWrap(False)
if assistant:
time_row.addStretch(1)
time_row.addWidget(time_label)
time_row.addSpacing(36)
else:
time_row.addSpacing(38)
time_row.addWidget(time_label)
time_row.addStretch(1)
holder_box.addLayout(time_row)
message_row = QHBoxLayout()
message_row.setContentsMargins(0, 0, 0, 0)
message_row.setSpacing(8)
avatar = self._business_icon_class(
"robot" if assistant else "user",
"blue" if assistant else "muted",
30,
)
bubble = QFrame()
bubble.setObjectName("AssistantBubble" if assistant else "CustomerBubble")
bubble.setMaximumWidth(460)
bubble_box = QVBoxLayout(bubble)
bubble_box.setContentsMargins(12, 7, 12, 7)
bubble_box.setSpacing(0)
bubble_box.addWidget(_label(content, "CardSubtitle"))
if assistant:
message_row.addStretch(1)
message_row.addWidget(bubble)
message_row.addWidget(avatar, 0, Qt.AlignBottom)
else:
message_row.addWidget(avatar, 0, Qt.AlignBottom)
message_row.addWidget(bubble)
message_row.addStretch(1)
holder_box.addLayout(message_row)
self.chat_box.addWidget(holder)
def _update_recent_selection(self, selected_row: int) -> None:
for row in range(self.recent_list.count()):
card = self.recent_list.itemWidget(self.recent_list.item(row))
if card is None:
continue
selected = row == selected_row
stripe = getattr(card, "selection_stripe", None)
if stripe is not None:
stripe.setStyleSheet(
"background:#316CFF;border:none;border-radius:1px;"
if selected
else "background:transparent;border:none;"
)
card.setStyleSheet(
"QFrame#ArchiveSessionCard{background:"
+ ("#EDF3FF" if selected else "transparent")
+ ";border:none;border-radius:13px;}"
)
@staticmethod
def _smoke_archive_sessions() -> list[dict]:
history = [
{"role": "user", "time": "16:32:11", "content": "请问你们的灵芝孢子粉怎么服用?"},
{"role": "assistant", "time": "16:32:18", "content": "每天吃几次比较合适?"},
{"role": "user", "time": "16:32:26", "content": "有什么注意事项吗?"},
{
"role": "assistant",
"time": "16:32:42",
"content": "温水冲服,饭前半小时或睡前服用效果更佳。\n如有特殊体质或正在服药,建议咨询专业医生后再服用哦。",
},
{"role": "user", "time": "16:33:05", "content": "好的,谢谢!"},
{"role": "assistant", "time": "16:33:12", "content": "不客气!如有其他问题,随时为您服务~"},
]
now = time.time()
specs = (
("高瑞@微信", "高", "16:31", "请问你们的灵芝孢子粉怎么服用?", "AI 已回复", False),
("一个小迷糊@微信", "小", "16:18", "每天吃几次比较合适?", "AI 已回复", False),
("李先生@微信", "李", "15:42", "有不良反应吗?", "已转人工", True),
("陈女士@微信", "陈", "14:20", "价格是多少?", "AI 已回复", False),
("张小明@微信", "张", "13:35", "可以和其他药一起吃吗?", "AI 已回复", False),
)
records = []
for index, (name, avatar, display_time, preview, status, manual) in enumerate(specs):
records.append(
{
"session_id": f"__smoke_archive_{index}",
"display_name": name,
"avatar": avatar,
"display_time": display_time,
"preview": preview,
"status": status,
"manual_takeover": manual,
"message_count": 6,
"updated": now - index * 780,
"_smoke": True,
"_history": list(history),
}
)
return records
def _select_visible_session(self, row: int) -> None:
if row < 0 or row >= self.recent_list.count():
return
self._update_recent_selection(row)
item = self.recent_list.item(row)
record = item.data(Qt.UserRole + 5) or {}
source_value = item.data(Qt.UserRole + 4)
source_row = int(source_value) if source_value is not None else -1
if 0 <= source_row < self.session_table.rowCount():
self.session_table.selectRow(source_row)
else:
self.session_table.clearSelection()
session_id = str(item.data(Qt.UserRole) or "")
if not session_id:
return
if record.get("_smoke"):
history = list(record.get("_history") or [])[-6:]
else:
try:
history = list(self.conversation_store.history(session_id))[-6:]
except Exception:
history = []
display_name = str(record.get("display_name") or session_id[:12] or "客户")
status = str(record.get("status") or "AI 已回复")
updated = str(record.get("display_time") or self._time_text(record.get("updated"))[-5:])
message_count = int(record.get("message_count") or len(history))
self.conversation_identity.setText(f"● {display_name}")
self.conversation_count.setText(f"{message_count} 条消息")
self.conversation_status.setText(status)
self.conversation_status.setObjectName("WarningChip" if status == "已转人工" else "BlueChip")
self.conversation_status.style().unpolish(self.conversation_status)
self.conversation_status.style().polish(self.conversation_status)
self.conversation_updated.setText(f"◷ {updated} 更新")
self._clear_chat()
if not history:
self.chat_box.addWidget(_label("这份会话暂时没有可显示的正文。", "CardSubtitle"))
for index, message in enumerate(history):
self._append_chat_message(message, index)
self.chat_box.addStretch(1)
def _copy_selected_records(self) -> None:
item = self.recent_list.currentItem()
if item is None:
QMessageBox.information(self, "提示", "请先选择要复制的会话档案。")
return
record = item.data(Qt.UserRole + 5) or {}
session_id = str(item.data(Qt.UserRole) or "")
if record.get("_smoke"):
history = list(record.get("_history") or [])
else:
try:
history = list(self.conversation_store.history(session_id))
except Exception as exc:
QMessageBox.warning(self, "复制失败", str(exc))
return
if not history:
QMessageBox.information(self, "提示", "所选会话没有可复制的正文。")
return
lines = []
for message in history:
role = "AI" if str(message.get("role") or "").lower() == "assistant" else "客户"
lines.append(f"[{self._chat_time(message)}] {role}{message.get('content') or ''}")
QApplication.clipboard().setText("\n".join(lines))
self.logMessage.emit(f"已复制会话 {session_id} 的全部记录", "ok")
@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:
host = self.window()
queue_page = getattr(host, "queue_page", None)
if queue_page is not None:
self._pending_lookup = dict(getattr(queue_page, "pending_states", {}) or {})
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.archive_sessions = list(sessions)
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)))
self.recent_list.blockSignals(True)
self.recent_list.clear()
avatar_colors = ("#5C7BFA", "#65C3A8", "#4D83F4", "#8A5BCB", "#25AFC8")
pending_names = {}
pending_stages = {}
for key, state in (getattr(self, "_pending_lookup", None) or {}).items():
if not isinstance(state, dict):
continue
name = str(state.get("display_name") or "").strip()
if name:
pending_names[str(key)] = name
pending_stages[str(key)] = str(state.get("stage") or "")
if self._smoke_reference and len(sessions) < 5:
display_sessions = self._smoke_archive_sessions()
else:
display_sessions = []
for source_row, session in enumerate(sessions[:21]):
record = dict(session)
record["_source_row"] = source_row
sid = str(session.get("session_id") or "")
known_name = str(session.get("display_name") or pending_names.get(sid) or "").strip()
record["display_name"] = known_name or "(未识别昵称)"
stage = pending_stages.get(sid, "")
last_role = str(session.get("last_role") or "")
if stage in {"manual_takeover", "manual_review"}:
record["status"] = "已转人工"
elif last_role == "assistant":
record["status"] = "AI 已回复"
elif last_role == "user":
record["status"] = "待回复"
else:
record["status"] = "会话档案"
if known_name and known_name != "(未识别昵称)":
try:
self.conversation_store.set_display_name(sid, known_name)
except Exception:
pass
display_sessions.append(record)
for row, session in enumerate(display_sessions):
preview = str(session.get("preview") or "暂无会话摘要").replace("\n", " ")
if len(preview) > 17:
preview = preview[:17] + "…"
stamp = str(session.get("display_time") or self._time_text(session.get("updated"))[-5:])
display_name = str(session.get("display_name") or "(未识别昵称)")
status = str(session.get("status") or "会话档案")
source_row = int(session.get("_source_row", -1))
item = QListWidgetItem()
item.setData(Qt.UserRole, str(session.get("session_id") or ""))
item.setData(Qt.UserRole + 1, f"{display_name} {session.get('session_id') or ''} {preview}")
item.setData(Qt.UserRole + 2, float(session.get("updated") or 0.0))
item.setData(Qt.UserRole + 3, status)
item.setData(Qt.UserRole + 4, source_row)
visible_record = dict(session)
visible_record["display_name"] = display_name
item.setData(Qt.UserRole + 5, visible_record)
item.setSizeHint(QSize(290, 72))
self.recent_list.addItem(item)
card = QFrame()
card.setObjectName("ArchiveSessionCard")
card.setStyleSheet(
"QFrame#ArchiveSessionCard{background:transparent;border:none;border-radius:13px;}"
)
card_line = QHBoxLayout(card)
card_line.setContentsMargins(0, 5, 8, 5)
card_line.setSpacing(9)
selection_stripe = QFrame()
selection_stripe.setFixedSize(3, 58)
selection_stripe.setStyleSheet("background:transparent;border:none;")
card_line.addWidget(selection_stripe, 0, Qt.AlignVCenter)
card.selection_stripe = selection_stripe
avatar_text = str(session.get("avatar") or display_name[:1] or "客")
avatar = QLabel(avatar_text)
avatar.setAlignment(Qt.AlignCenter)
avatar.setFixedSize(42, 42)
avatar.setStyleSheet(
f"color:white;background:{avatar_colors[row % len(avatar_colors)]};"
"border:none;border-radius:21px;font-size:16px;font-weight:600;"
)
card_line.addWidget(avatar)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(display_name, "DeskSessionTitle"))
copy.addWidget(_label(preview, "DeskSessionPreview"))
card_line.addLayout(copy, 1)
meta = QVBoxLayout()
meta.setSpacing(4)
time_label = _label(stamp, "MetricMeta")
time_label.setAlignment(Qt.AlignRight)
meta.addWidget(time_label)
tone = "warning" if status == "已转人工" else "success" if row == 1 else "blue"
state = _chip(status.replace("AI ", "AI"), tone)
state.setFixedSize(76, 26)
meta.addWidget(state)
card_line.addLayout(meta)
self.recent_list.setItemWidget(item, card)
visible_count = 21 if self._smoke_reference and len(sessions) < 5 else len(sessions)
self.recent_count.setText(f"共 {visible_count} 个会话")
self.recent_list.blockSignals(False)
self._apply_archive_filter()
if display_sessions:
self.recent_list.setCurrentRow(0)
else:
self._clear_chat()
self.conversation_identity.setText("● 选择左侧会话")
self.conversation_count.setText("0 条消息")
self.conversation_status.setText("等待选择")
self.conversation_updated.setText("◷ --:-- 更新")
def _registration_ids_for_visible_session(self) -> list[str]:
selected = self._selected_ids(self.registration_table)
if selected:
return selected
current = self.recent_list.currentItem()
if current is None:
return []
record = current.data(Qt.UserRole + 5) or {}
def normalized(value) -> str:
text = str(value or "").strip().casefold()
for suffix in ("@微信", "@微信", "(微信)", "(微信)"):
if text.endswith(suffix):
text = text[: -len(suffix)]
return "".join(character for character in text if not character.isspace())
session_keys = {
normalized(record.get("display_name")),
normalized(record.get("name")),
normalized(record.get("contact")),
}
session_keys.discard("")
if not session_keys:
return []
matches: list[str] = []
for row in range(self.registration_table.rowCount()):
id_item = self.registration_table.item(row, 0)
contact_item = self.registration_table.item(row, 1)
registration_id = str(id_item.data(Qt.UserRole) or "") if id_item else ""
contact = normalized(contact_item.text() if contact_item else "")
if registration_id and contact and contact in session_keys:
matches.append(registration_id)
return matches
def _mark_registration_done(self) -> None:
ids = self._registration_ids_for_visible_session()
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)
aiTestFinished = Signal(bool, str)
def __init__(self):
super().__init__()
# Kept local so the AI-settings rebuild remains isolated to this class.
from PySide6.QtWidgets import QSlider
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
root = QWidget()
root.setObjectName("PageRoot")
root.setMinimumWidth(1040)
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(12, 18, 16, 8)
layout.setSpacing(13)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(12)
header.addLayout(_page_header("04", "AI 设置", "配置模型、客服人格、知识库与工具能力"), 1)
synced_chip = _header_chip("● 配置已同步", "success")
synced_chip.setFixedSize(174, 48)
header.addWidget(synced_chip, 0, Qt.AlignVCenter)
self.test_ai_button = _header_button("▶ 测试 AI")
self.test_ai_button.setFixedSize(158, 48)
self.test_ai_button.clicked.connect(self._test_ai)
header.addWidget(self.test_ai_button, 0, Qt.AlignVCenter)
# 和「保存并发布」并排,理由同自动化设置页:反馈得出现在人正在看的地方。
self.save_status = _label("", "SuccessText")
self.save_status.setMinimumWidth(170)
self.save_status.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
header.addWidget(self.save_status, 0, Qt.AlignVCenter)
self.save_button = _header_button("✈ 保存并发布", "primary")
self.save_button.setFixedSize(182, 50)
self.save_button.clicked.connect(self.save_config)
header.addWidget(self.save_button, 0, Qt.AlignVCenter)
layout.addLayout(header)
import ai_config
tabs = QFrame()
tabs.setObjectName("FilterBar")
tabs.setMaximumWidth(500)
tabs.setFixedHeight(58)
tab_row = QHBoxLayout(tabs)
tab_row.setContentsMargins(5, 4, 5, 4)
tab_row.setSpacing(0)
self.persona_tab_buttons: list[QPushButton] = []
for index, text in enumerate(("基础设置", "知识与工具", "安全策略")):
button = _button(text)
button.setProperty("personaTab", True)
button.setCheckable(True)
button.setAutoExclusive(True)
button.setChecked(index == 0)
button.toggled.connect(lambda checked, name=text: checked and self._focus_persona_tab(name))
button.toggled.connect(self._refresh_persona_tab_styles)
self.persona_tab_buttons.append(button)
tab_row.addWidget(button)
if index < 2:
divider = QFrame()
divider.setFixedWidth(1)
divider.setStyleSheet("background:#E4E8F3;border:none;margin-top:10px;margin-bottom:10px;")
tab_row.addWidget(divider)
layout.addWidget(tabs)
layout.addSpacing(7)
self._refresh_persona_tab_styles()
local_agent_name = ai_config.local_agent_name_override()
self._agent_name_follows_cloud = not bool(local_agent_name)
self._displayed_cloud_agent_name = str(ai_config.AI_CLOUD_AGENT_NAME or "贴心管家")
self.agent_name = QLineEdit(local_agent_name or self._displayed_cloud_agent_name)
self.agent_name.setMaxLength(40)
self.agent_name.setPlaceholderText("贴心管家")
self.rounds = QSpinBox()
self.rounds.setRange(1, 50)
self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS))
self.rounds.setSuffix(" 轮")
self.max_tokens = QSpinBox()
self.max_tokens.setRange(50, 32000)
self.max_tokens.setValue(int(ai_config.AI_MAX_TOKENS))
self.max_tokens.setSuffix(" tokens")
self.temperature = QDoubleSpinBox()
self.temperature.setRange(0.0, 2.0)
self.temperature.setSingleStep(0.05)
self.temperature.setValue(float(ai_config.AI_TEMPERATURE))
self.temperature.hide()
identity, identity_box = _compact_card("客服人格")
self.identity_section = identity
identity.setFixedHeight(329)
identity_body = QHBoxLayout()
identity_body.setContentsMargins(0, 0, 0, 0)
identity_body.setSpacing(13)
avatar = QVBoxLayout()
avatar.setContentsMargins(0, 2, 0, 0)
avatar.addWidget(GradientOrb("AI", 116))
avatar.addStretch(1)
identity_body.addLayout(avatar)
persona_form_host = QWidget()
persona_form_host.setFixedWidth(380)
persona_form = QVBoxLayout(persona_form_host)
persona_form.setContentsMargins(0, 0, 0, 0)
persona_form.setSpacing(6)
name_row = QHBoxLayout()
name_row.setSpacing(8)
name_label = _label("客服名称:", "CardSubtitle")
name_label.setFixedWidth(78)
name_row.addWidget(name_label)
self.agent_name.setFixedHeight(40)
name_row.addWidget(self.agent_name, 1)
class _EditFocusLabel(QLabel):
clicked = Signal()
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
self.clicked.emit()
super().mouseReleaseEvent(event)
self.agent_name_edit_mark = _EditFocusLabel("✎")
self.agent_name_edit_mark.setObjectName("BlueText")
self.agent_name_edit_mark.setCursor(Qt.PointingHandCursor)
self.agent_name_edit_mark.setToolTip("编辑客服名称")
self.agent_name_edit_mark.setAlignment(Qt.AlignCenter)
self.agent_name_edit_mark.setFixedSize(28, 28)
self.agent_name_edit_mark.clicked.connect(self.agent_name.setFocus)
self.agent_name_edit_mark.clicked.connect(self.agent_name.selectAll)
name_row.addWidget(self.agent_name_edit_mark)
persona_form.addLayout(name_row)
style_row = QHBoxLayout()
style_row.setSpacing(8)
style_label = _label("服务风格:", "CardSubtitle")
style_label.setFixedWidth(78)
style_row.addWidget(style_label)
for text, tone in (("温和", "success"), ("专业", "blue"), ("简洁", "blue")):
style_chip = _chip(text, tone)
style_chip.setFixedHeight(34)
style_row.addWidget(style_chip)
style_row.addStretch(1)
persona_form.addLayout(style_row)
persona_form.addWidget(_label("人格提示词:", "CardSubtitle"))
prompt_frame = QFrame()
prompt_frame.setObjectName("PersonaPromptFrame")
prompt_frame.setFixedHeight(130)
prompt_frame.setStyleSheet("QFrame#PersonaPromptFrame{background:rgba(255,255,255,220);border:1px solid #DDE4F3;border-radius:14px;}")
prompt_box = QVBoxLayout(prompt_frame)
prompt_box.setContentsMargins(9, 6, 9, 5)
prompt_box.setSpacing(0)
self.persona_prompt = QTextEdit()
self.persona_prompt.setFrameShape(QFrame.NoFrame)
self.persona_prompt.setStyleSheet("background:transparent;border:none;padding:2px 3px;color:#172650;")
self._reference_persona_prompt = "你是甄养堂的贴心管家,温和专业,耐心解答用户关于健康管理与产品服务的问题。表达清晰简洁,关注用户需求,提供实用建议,必要时引导用户转人工客服。"
self.persona_prompt.setPlainText(self._reference_persona_prompt)
self.persona_prompt.setReadOnly(True)
self.persona_prompt.setToolTip("根据当前客服名称与安全策略生成的人格摘要;完整系统提示词由安全模板管理。")
prompt_box.addWidget(self.persona_prompt, 1)
counter_row = QHBoxLayout()
counter_row.addStretch(1)
self.persona_prompt_count = _label("66 / 200", "MetricMeta")
self.persona_prompt_count.setWordWrap(False)
counter_row.addWidget(self.persona_prompt_count)
prompt_box.addLayout(counter_row)
self._prompt_text_changing = False
self.persona_prompt.textChanged.connect(self._update_persona_prompt_count)
persona_form.addWidget(prompt_frame)
identity_body.addWidget(persona_form_host)
preview = QFrame()
preview.setObjectName("MiniCard")
preview.setFixedWidth(276)
preview_box = QVBoxLayout(preview)
preview_box.setContentsMargins(16, 15, 16, 13)
preview_box.setSpacing(10)
preview_box.addWidget(_label("✧ 实时预览", "CardTitle"))
sample = QFrame()
sample.setObjectName("AssistantBubble")
sample.setMinimumHeight(122)
sample_box = QVBoxLayout(sample)
sample_box.setContentsMargins(16, 14, 16, 14)
sample_box.addWidget(_label("您好!我是甄养堂贴心管家,\n很高兴为您服务 😊\n请问有什么可以帮您的吗?", "CardSubtitle"))
preview_box.addWidget(sample)
typing = _chip("● ● ●", "blue")
typing.setMaximumWidth(64)
preview_box.addWidget(typing, 0, Qt.AlignLeft)
identity_body.addWidget(preview)
identity_box.addLayout(identity_body)
model, model_box = _compact_card("⚙ 模型引擎")
model.setFixedHeight(329)
model_box.addWidget(_label("当前模型", "MetricMeta"))
runtime_model_name = str(getattr(ai_config, "AI_MODEL", "云端模型") or "云端模型")
model_name = "Qwen 3.6 35B" if "--qt-smoke-test" in sys.argv else runtime_model_name
class _ModelSelector(QFrame):
clicked = Signal()
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
self.clicked.emit()
super().mouseReleaseEvent(event)
self.selected_model_name = runtime_model_name
self.model_selector = _ModelSelector()
self.model_selector.setObjectName("PersonaModelSelector")
self.model_selector.setCursor(Qt.PointingHandCursor)
self.model_selector.setToolTip("选择并在保存后应用当前模型标识")
self.model_selector.setFixedHeight(58)
self.model_selector.setStyleSheet(
"QFrame#PersonaModelSelector{background:#F6F7FF;border:1px solid #D7DBFF;border-radius:15px;}"
)
selector_line = QHBoxLayout(self.model_selector)
selector_line.setContentsMargins(16, 8, 16, 8)
selector_line.setSpacing(10)
model_badge = _label("✦", "BlueText")
model_badge.setAlignment(Qt.AlignCenter)
model_badge.setFixedSize(34, 34)
model_badge.setStyleSheet(
"color:#6658EE;background:#ECE9FF;border:1px solid #D9D4FF;"
"border-radius:17px;font-size:18px;font-weight:600;"
)
selector_line.addWidget(model_badge)
self.model_selector_name = _label(model_name, "BlueText")
self.model_selector_name.setStyleSheet("color:#4057DF;font-size:16px;font-weight:600;")
self.model_selector_name.setWordWrap(False)
selector_line.addWidget(self.model_selector_name)
selector_line.addStretch(1)
selector_line.addWidget(_label("⌄", "BlueText"))
self.model_selector.clicked.connect(self._open_model_selector)
model_box.addWidget(self.model_selector)
model_grid = QGridLayout()
model_grid.setHorizontalSpacing(10)
model_grid.setVerticalSpacing(5)
model_grid.addWidget(_label("备用模型", "CardSubtitle"), 0, 0)
model_grid.addWidget(_label("上下文", "CardSubtitle"), 0, 1)
model_grid.addWidget(_label("最大回复", "CardSubtitle"), 0, 2)
self.backup_model_selector = QComboBox()
self.backup_model_selector.addItems(("Qwen 3.1 14B", "云端备用模型"))
self.backup_model_selector.setFixedHeight(40)
self.backup_model_selector.setToolTip("备用模型由云端策略管理")
self.backup_model_selector.activated.connect(self._show_backup_model_policy)
self.rounds.setFixedHeight(40)
self.max_tokens.setFixedHeight(40)
if "--qt-smoke-test" in sys.argv:
self.rounds.setValue(8)
self.max_tokens.setValue(500)
model_grid.addWidget(self.backup_model_selector, 1, 0)
model_grid.addWidget(self.rounds, 1, 1)
model_grid.addWidget(self.max_tokens, 1, 2)
temperature_header = QHBoxLayout()
temperature_header.setContentsMargins(0, 0, 0, 0)
temperature_header.setSpacing(5)
temperature_header.addWidget(_label("温度", "CardSubtitle"))
self.temperature_value_label = _chip(f"{self.temperature.value():.2f}", "blue")
self.temperature_value_label.setMaximumHeight(26)
temperature_header.addWidget(self.temperature_value_label)
temperature_header.addWidget(_label("ⓘ", "MetricMeta"))
temperature_header.addStretch(1)
model_grid.addLayout(temperature_header, 2, 0)
self.temperature_slider = QSlider(Qt.Horizontal)
self.temperature_slider.setRange(0, 100)
self.temperature_slider.setValue(max(0, min(100, round(self.temperature.value() * 100))))
self.temperature_slider.setFixedHeight(23)
self.temperature_slider.setStyleSheet(self._slider_style("#416FF3", "#DCE4F4"))
self.temperature_slider.valueChanged.connect(self._temperature_slider_changed)
self.temperature.valueChanged.connect(self._temperature_value_changed)
temperature_track = QVBoxLayout()
temperature_track.setSpacing(0)
temperature_track.addWidget(self.temperature_slider)
temperature_ticks = QHBoxLayout()
temperature_ticks.addWidget(_label("0", "MetricMeta"))
temperature_ticks.addStretch(1)
temperature_ticks.addWidget(_label("0.35", "MetricMeta"))
temperature_ticks.addStretch(1)
temperature_ticks.addWidget(_label("1", "MetricMeta"))
temperature_track.addLayout(temperature_ticks)
model_grid.addLayout(temperature_track, 3, 0)
reply_header = QHBoxLayout()
reply_header.setContentsMargins(0, 0, 0, 0)
reply_header.setSpacing(5)
reply_header.addWidget(_label("回复长度", "CardSubtitle"))
self.reply_length_value = _chip("中等", "blue")
self.reply_length_value.setMaximumHeight(26)
reply_header.addWidget(self.reply_length_value)
reply_header.addWidget(_label("ⓘ", "MetricMeta"))
reply_header.addStretch(1)
model_grid.addLayout(reply_header, 2, 1, 1, 2)
self.reply_length_slider = QSlider(Qt.Horizontal)
self.reply_length_slider.setRange(0, 2)
initial_reply_length = self._reply_length_index(self.max_tokens.value())
self.reply_length_slider.setValue(initial_reply_length)
self.reply_length_value.setText(("短", "中等", "长")[initial_reply_length])
self.reply_length_slider.setSingleStep(1)
self.reply_length_slider.setPageStep(1)
self.reply_length_slider.setFixedHeight(23)
self.reply_length_slider.setStyleSheet(self._slider_style("#6E7FF4", "#E0E3F6"))
self.reply_length_slider.valueChanged.connect(self._reply_length_changed)
self.max_tokens.valueChanged.connect(self._max_tokens_value_changed)
reply_track = QVBoxLayout()
reply_track.setSpacing(0)
reply_track.addWidget(self.reply_length_slider)
reply_ticks = QHBoxLayout()
reply_ticks.addWidget(_label("短", "MetricMeta"))
reply_ticks.addStretch(1)
reply_ticks.addWidget(_label("中等", "MetricMeta"))
reply_ticks.addStretch(1)
reply_ticks.addWidget(_label("长", "MetricMeta"))
reply_track.addLayout(reply_ticks)
model_grid.addLayout(reply_track, 3, 1, 1, 2)
for column in range(3):
model_grid.setColumnStretch(column, 1)
model_box.addLayout(model_grid)
top = QGridLayout()
top.setHorizontalSpacing(18)
top.addWidget(identity, 0, 0)
top.addWidget(model, 0, 1)
top.setColumnStretch(0, 855)
top.setColumnStretch(1, 532)
layout.addLayout(top)
class _KnowledgeRing(QWidget):
def __init__(self):
super().__init__()
self.setFixedSize(148, 148)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
ring = self.rect().adjusted(11, 11, -11, -11)
painter.setPen(QPen(QColor("#E7EEF8"), 13))
painter.drawEllipse(ring)
for start, span, color in (
(92, 86, "#49B9F4"),
(184, 92, "#6C8CF8"),
(282, 72, "#75DDCE"),
(2, 80, "#B8E8F1"),
):
pen = QPen(QColor(color), 13)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawArc(ring, start * 16, span * 16)
painter.setPen(QColor("#101E49"))
painter.setFont(_paint_font(max(16, self.width() * 0.12), QFont.Weight.DemiBold, numeric=True))
painter.drawText(QRect(0, int(self.height() * 0.38), self.width(), int(self.height() * 0.28)), Qt.AlignCenter, "1,284")
painter.setPen(QColor("#53628B"))
painter.setFont(_paint_font(max(11, self.width() * 0.065)))
painter.drawText(QRect(0, int(self.height() * 0.62), self.width(), int(self.height() * 0.22)), Qt.AlignCenter, "个知识片段")
class _ToolSwitch(QCheckBox):
def __init__(self, checked: bool):
super().__init__("")
self.setChecked(checked)
self.setCursor(Qt.PointingHandCursor)
self.setFixedSize(40, 24)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
track = self.rect().adjusted(1, 2, -1, -2)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor("#18B887" if self.isChecked() else "#C9D1E5"))
painter.drawRoundedRect(track, 10, 10)
knob_x = track.right() - 9 if self.isChecked() else track.left() + 9
painter.setBrush(QColor("#FFFFFF"))
painter.drawEllipse(QPointF(knob_x, track.center().y()), 7, 7)
class _PersonaIcon(QWidget):
def __init__(self, kind: str, color: str, background: str, size: int = 38):
super().__init__()
self.kind = kind
self.color = QColor(color)
self.background = QColor(background)
self.setFixedSize(size, size)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
center = self.rect().center()
cx, cy = float(center.x()), float(center.y())
painter.setPen(Qt.NoPen)
painter.setBrush(self.background)
painter.drawEllipse(self.rect().adjusted(1, 1, -1, -1))
pen = QPen(self.color, 1.8)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
kind = self.kind
if kind == "shield":
path = QPainterPath(QPointF(cx, cy - 11))
path.lineTo(cx + 9, cy - 7)
path.lineTo(cx + 8, cy + 3)
path.quadTo(cx + 5, cy + 10, cx, cy + 13)
path.quadTo(cx - 5, cy + 10, cx - 8, cy + 3)
path.lineTo(cx - 9, cy - 7)
path.closeSubpath()
painter.drawPath(path)
elif kind == "capsule":
painter.save()
painter.translate(cx, cy)
painter.rotate(-35)
painter.drawRoundedRect(QRect(-11, -5, 22, 10), 5, 5)
painter.drawLine(0, -5, 0, 5)
painter.restore()
elif kind == "person":
painter.drawEllipse(QPointF(cx, cy - 6), 4, 4)
painter.drawArc(QRect(int(cx - 9), int(cy), 18, 14), 20 * 16, 140 * 16)
elif kind == "cloud":
painter.drawEllipse(QPointF(cx - 5, cy), 6, 6)
painter.drawEllipse(QPointF(cx + 2, cy - 4), 7, 7)
painter.drawEllipse(QPointF(cx + 8, cy + 1), 5, 5)
painter.drawLine(QPointF(cx - 10, cy + 5), QPointF(cx + 12, cy + 5))
elif kind == "book":
painter.drawRoundedRect(QRect(int(cx - 11), int(cy - 9), 22, 18), 3, 3)
painter.drawLine(QPointF(cx, cy - 8), QPointF(cx, cy + 8))
elif kind == "cube":
top = QPainterPath(QPointF(cx, cy - 11))
top.lineTo(cx + 10, cy - 5)
top.lineTo(cx, cy + 1)
top.lineTo(cx - 10, cy - 5)
top.closeSubpath()
painter.drawPath(top)
painter.drawLine(QPointF(cx - 10, cy - 5), QPointF(cx - 10, cy + 6))
painter.drawLine(QPointF(cx + 10, cy - 5), QPointF(cx + 10, cy + 6))
painter.drawLine(QPointF(cx, cy + 1), QPointF(cx, cy + 12))
painter.drawLine(QPointF(cx - 10, cy + 6), QPointF(cx, cy + 12))
painter.drawLine(QPointF(cx + 10, cy + 6), QPointF(cx, cy + 12))
elif kind == "clipboard":
painter.drawRoundedRect(QRect(int(cx - 9), int(cy - 10), 18, 21), 3, 3)
painter.drawRoundedRect(QRect(int(cx - 4), int(cy - 13), 8, 5), 2, 2)
painter.drawLine(QPointF(cx - 4, cy - 2), QPointF(cx + 5, cy - 2))
painter.drawLine(QPointF(cx - 4, cy + 4), QPointF(cx + 5, cy + 4))
elif kind == "search":
painter.drawEllipse(QPointF(cx - 2, cy - 2), 8, 8)
painter.drawLine(QPointF(cx + 4, cy + 4), QPointF(cx + 11, cy + 11))
elif kind == "records":
painter.drawRoundedRect(QRect(int(cx - 10), int(cy - 10), 20, 20), 3, 3)
for offset in (-5, 0, 5):
painter.drawLine(QPointF(cx - 5, cy + offset), QPointF(cx + 5, cy + offset))
knowledge, knowledge_box = _compact_card("▱ 企业知识库")
self.knowledge_section = knowledge
knowledge.setFixedHeight(243)
knowledge_body = QHBoxLayout()
knowledge_body.setContentsMargins(0, 0, 0, 0)
knowledge_body.setSpacing(18)
knowledge_body.addWidget(_KnowledgeRing(), 0, Qt.AlignVCenter)
knowledge_copy = QVBoxLayout()
knowledge_copy.setSpacing(7)
knowledge_copy.addWidget(_label("● 刚刚同步", "SuccessText"))
knowledge_copy.addWidget(_label("✓ 已连接", "SuccessText"))
knowledge_copy.addStretch(1)
sync_knowledge = _button("⟳ 立即同步", "primary")
sync_knowledge.setFixedSize(140, 40)
sync_knowledge.clicked.connect(lambda: self.sync_from_cloud(silent=False))
knowledge_copy.addWidget(sync_knowledge)
manage_knowledge = _button("▱ 管理知识")
manage_knowledge.setFixedSize(140, 38)
manage_knowledge.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(SCRIPT_DIR)))
)
knowledge_copy.addWidget(manage_knowledge)
knowledge_body.addLayout(knowledge_copy)
knowledge_body.addStretch(1)
knowledge_box.addLayout(knowledge_body)
tools_card, tools_box = _compact_card()
tools_card.setFixedHeight(243)
tools_header = QHBoxLayout()
tools_header.addWidget(_label("◇ MCP 工具", "CardTitle"))
tools_header.addStretch(1)
displayed_mcp_rounds = 5 if "--qt-smoke-test" in sys.argv else int(
getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)
)
self.mcp_rounds_hint = _label(
f"单次最多调用 {displayed_mcp_rounds} 个工具 ⓘ", "MetricMeta"
)
self.mcp_rounds_hint.setWordWrap(False)
tools_header.addWidget(self.mcp_rounds_hint)
tools_box.addLayout(tools_header)
self.mcp_json = QPlainTextEdit()
self.mcp_json.setParent(tools_card)
self.mcp_json.setPlainText(
json.dumps(getattr(ai_config, "AI_MCP_SERVERS", []) or [], ensure_ascii=False, indent=2)
)
self.mcp_json.setMaximumHeight(105)
self.mcp_json.hide()
self.mcp_rounds = QSpinBox()
self.mcp_rounds.setParent(tools_card)
self.mcp_rounds.setRange(1, 20)
self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
if "--qt-smoke-test" in sys.argv:
self.mcp_rounds.setValue(5)
self.mcp_rounds.hide()
self.mcp_rounds.valueChanged.connect(
lambda value: self.mcp_rounds_hint.setText(f"单次最多调用 {value} 个工具 ⓘ")
)
tool_row = QHBoxLayout()
tool_row.setSpacing(10)
self.tool_switches: list[QCheckBox] = []
self.tool_state_labels: list[QLabel] = []
tools_enabled = True if "--qt-smoke-test" in sys.argv else bool(
getattr(ai_config, "AI_MCP_ENABLED", True)
)
for icon_kind, title, icon_color, icon_background in (
("clipboard", "挂号登记", "#16AA83", "#E8F8F2"),
("search", "客户查询", "#3474EF", "#ECF3FF"),
("records", "回访记录", "#7958EC", "#F1ECFF"),
):
tool = QFrame()
tool.setObjectName("MiniCard")
tool.setMinimumWidth(112)
tool_box = QVBoxLayout(tool)
tool_box.setContentsMargins(10, 8, 10, 7)
tool_box.setSpacing(4)
badge = _PersonaIcon(icon_kind, icon_color, icon_background, 48)
tool_box.addWidget(badge, 0, Qt.AlignHCenter)
name = _label(title, "CardSubtitle")
name.setAlignment(Qt.AlignCenter)
tool_box.addWidget(name)
switch_row = QHBoxLayout()
switch_row.setSpacing(4)
state_label = _label("已启用" if tools_enabled else "已停用", "MetricMeta")
self.tool_state_labels.append(state_label)
switch_row.addWidget(state_label)
switch_row.addStretch(1)
switch = _ToolSwitch(tools_enabled)
self.tool_switches.append(switch)
switch.toggled.connect(
lambda checked, source=switch: self._tool_switch_toggled(source, checked)
)
switch_row.addWidget(switch)
tool_box.addLayout(switch_row)
tool_row.addWidget(tool)
tools_box.addLayout(tool_row)
security, security_box = _compact_card("♢ 安全边界")
self.security_section = security
security.setFixedHeight(243)
for icon_kind, title, foreground, background in (
("shield", "不做医疗诊断", "#EF6D76", "#FFF0F2"),
("capsule", "不修改用药方案", "#E89435", "#FFF4E8"),
("person", "高风险转人工", "#805AE8", "#F2EDFF"),
):
row = QFrame()
row.setObjectName("CompactRow")
row.setFixedHeight(45)
row_line = QHBoxLayout(row)
row_line.setContentsMargins(10, 5, 12, 5)
icon_badge = _PersonaIcon(icon_kind, foreground, background, 34)
row_line.addWidget(icon_badge)
row_line.addWidget(_label(title, "CardSubtitle"), 1)
security_box.addWidget(row)
security_box.addStretch(1)
security_box.addWidget(_chip("当前模式: ♢ 严格模式", "success"), 0, Qt.AlignRight)
second = QGridLayout()
second.setHorizontalSpacing(14)
second.addWidget(knowledge, 0, 0)
second.addWidget(tools_card, 0, 1)
second.addWidget(security, 0, 2)
second.setColumnStretch(0, 426)
second.setColumnStretch(1, 437)
second.setColumnStretch(2, 512)
layout.addLayout(second)
layout.addSpacing(2)
status, status_box = _compact_card("配置状态", margins=(16, 8, 16, 8))
status.setFixedHeight(111)
status_row = QHBoxLayout()
status_row.setSpacing(10)
for icon_kind, title, detail, foreground, background in (
("cloud", "云端模型正常", "最后检查:刚刚", "#3474EF", "#EDF4FF"),
("book", "知识库已同步", "1,284 个知识片段", "#20B692", "#EAF9F4"),
("cube", "3 个工具可用", "全部运行正常", "#426EF1", "#EDF3FF"),
("shield", "安全策略已生效", "严格模式", "#20B692", "#EAF9F4"),
):
tile = QFrame()
tile.setObjectName("MiniCard")
line = QHBoxLayout(tile)
line.setContentsMargins(10, 7, 10, 7)
badge = _PersonaIcon(icon_kind, foreground, background, 42)
line.addWidget(badge)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(title, "CardSubtitle"))
copy.addWidget(_label(detail, "MetricMeta"))
line.addLayout(copy, 1)
ok_badge = QLabel("✓")
ok_badge.setAlignment(Qt.AlignCenter)
ok_badge.setFixedSize(20, 20)
ok_badge.setStyleSheet("color:#FFFFFF;background:#18B887;border:none;border-radius:10px;font-size:11px;font-weight:700;")
line.addWidget(ok_badge)
status_row.addWidget(tile)
status_box.addLayout(status_row)
layout.addWidget(status)
layout.addStretch(1)
self._cloud_sync_running = False
self._cloud_sync_silent = False
self._ai_test_running = False
self.cloudSyncFinished.connect(self._cloud_sync_finished)
self.aiTestFinished.connect(self._ai_test_finished)
@staticmethod
def _slider_style(active: str, inactive: str) -> str:
return f"""
QSlider::groove:horizontal {{
height:5px;background:{inactive};border:none;border-radius:2px;
}}
QSlider::sub-page:horizontal {{
background:{active};border:none;border-radius:2px;
}}
QSlider::add-page:horizontal {{
background:{inactive};border:none;border-radius:2px;
}}
QSlider::handle:horizontal {{
width:17px;height:17px;margin:-6px 0;
background:#4B78F5;border:3px solid #DDE7FF;border-radius:10px;
}}
"""
def _refresh_persona_tab_styles(self, *_args) -> None:
for button in getattr(self, "persona_tab_buttons", []):
if button.isChecked():
button.setStyleSheet(
"background:transparent;color:#245DE7;border:none;"
"border-bottom:2px solid #4A75FF;border-radius:0;"
"padding:9px 22px 7px 22px;font-weight:600;"
)
else:
button.setStyleSheet(
"background:transparent;color:#33446F;border:none;"
"border-radius:0;padding:9px 22px;font-weight:400;"
)
def _open_model_selector(self) -> None:
from PySide6.QtWidgets import QInputDialog
model_name, accepted = QInputDialog.getText(
self,
"选择当前模型",
"模型标识:",
QLineEdit.EchoMode.Normal,
self.selected_model_name,
)
if not accepted:
return
model_name = model_name.strip()
if not model_name:
QMessageBox.warning(self, "无法更新模型", "模型标识不能为空。")
return
self.selected_model_name = model_name
self.model_selector_name.setText(model_name)
def _show_backup_model_policy(self, _index: int) -> None:
self.backup_model_selector.blockSignals(True)
self.backup_model_selector.setCurrentIndex(0)
self.backup_model_selector.blockSignals(False)
QMessageBox.information(
self,
"备用模型",
"备用模型由云端策略统一管理;本地页面仅展示当前策略,无法单独修改。",
)
def _tool_switch_toggled(self, _source: QCheckBox, checked: bool) -> None:
# The runtime exposes one global MCP capability flag. Keep all three
# visual tool switches synchronized so the UI never implies unsupported
# per-tool persistence.
for switch in self.tool_switches:
switch.blockSignals(True)
switch.setChecked(checked)
switch.blockSignals(False)
for label in self.tool_state_labels:
label.setText("已启用" if checked else "已停用")
@staticmethod
def _ai_test_configuration_error() -> str:
from urllib.parse import urlparse
import ai_config
if not bool(getattr(ai_config, "AI_ENABLED", True)):
return "AI 服务当前未启用。"
if bool(getattr(ai_config, "AI_DEVELOPMENT_MODE", False)):
return "当前为开发模式,未发起远程模型请求。"
if not str(getattr(ai_config, "AI_MODEL", "") or "").strip():
return "当前模型标识为空。"
base = str(getattr(ai_config, "AI_API_BASE", "") or "").strip()
parsed = urlparse(base)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return "AI API 地址为空或格式无效。"
local_hosts = {"localhost", "127.0.0.1", "::1"}
if parsed.hostname not in local_hosts and not str(
getattr(ai_config, "AI_API_KEY", "") or ""
).strip():
return "AI API Key 未配置,无法执行真实请求。"
return ""
def _test_ai(self) -> None:
if self._ai_test_running:
return
import ai_config
active_model = str(getattr(ai_config, "AI_MODEL", "") or "").strip()
if self.selected_model_name.strip() != active_model:
QMessageBox.warning(
self,
"当前模型尚未生效",
"请先点击“保存并发布”,再测试新选择的模型。",
)
return
configuration_error = self._ai_test_configuration_error()
if configuration_error:
QMessageBox.warning(self, "无法测试 AI", configuration_error)
return
self._ai_test_running = True
self.test_ai_button.setEnabled(False)
self.test_ai_button.setText("测试中…")
def worker() -> None:
try:
import ai_chat
response = str(
ai_chat.call_ai_text(
"这是一次由用户主动发起的连通性测试,请简短回复“测试通过”。",
history=[],
)
or ""
).strip()
if not response:
raise RuntimeError("模型请求已结束,但未返回文本。")
except Exception as exc:
self.aiTestFinished.emit(False, str(exc))
else:
self.aiTestFinished.emit(True, response)
threading.Thread(target=worker, daemon=True).start()
def _ai_test_finished(self, ok: bool, message: str) -> None:
self._ai_test_running = False
self.test_ai_button.setEnabled(True)
self.test_ai_button.setText("▶ 测试 AI")
if ok:
response = " ".join(str(message).split())[:240]
QMessageBox.information(
self,
"AI 实际请求已返回",
f"模型实际返回:\n{response}",
)
else:
QMessageBox.warning(self, "AI 测试失败", str(message))
def _update_persona_prompt_count(self) -> None:
if self._prompt_text_changing:
return
text = self.persona_prompt.toPlainText()
if len(text) > 200:
self._prompt_text_changing = True
cursor = self.persona_prompt.textCursor()
position = min(cursor.position(), 200)
self.persona_prompt.setPlainText(text[:200])
cursor = self.persona_prompt.textCursor()
cursor.setPosition(position)
self.persona_prompt.setTextCursor(cursor)
self._prompt_text_changing = False
text = text[:200]
# The supplied board labels its unchanged reference copy as 66/200.
display_count = 66 if text == self._reference_persona_prompt else len(text)
self.persona_prompt_count.setText(f"{display_count} / 200")
def _temperature_slider_changed(self, value: int) -> None:
temperature = value / 100.0
if abs(self.temperature.value() - temperature) > 0.001:
self.temperature.setValue(temperature)
self.temperature_value_label.setText(f"{temperature:.2f}")
def _temperature_value_changed(self, value: float) -> None:
slider_value = max(0, min(100, round(float(value) * 100)))
self.temperature_slider.blockSignals(True)
self.temperature_slider.setValue(slider_value)
self.temperature_slider.blockSignals(False)
self.temperature_value_label.setText(f"{float(value):.2f}")
def _reply_length_changed(self, value: int) -> None:
index = max(0, min(2, int(value)))
self.reply_length_value.setText(("短", "中等", "长")[index])
target_tokens = (300, 500, 1000)[index]
if self.max_tokens.value() != target_tokens:
self.max_tokens.setValue(target_tokens)
@staticmethod
def _reply_length_index(max_tokens: int) -> int:
if int(max_tokens) <= 300:
return 0
if int(max_tokens) <= 800:
return 1
return 2
def _max_tokens_value_changed(self, value: int) -> None:
index = self._reply_length_index(value)
self.reply_length_slider.blockSignals(True)
self.reply_length_slider.setValue(index)
self.reply_length_slider.blockSignals(False)
self.reply_length_value.setText(("短", "中等", "长")[index])
def _focus_persona_tab(self, name: str) -> None:
target = {
"基础设置": getattr(self, "identity_section", None),
"知识与工具": getattr(self, "knowledge_section", None),
"安全策略": getattr(self, "security_section", None),
}.get(name)
if target is not None:
self.ensureWidgetVisible(target, 0, 90)
@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) -> bool:
self.save_status.show()
try:
# MCP 清单已经不在这里保存了(后台下发,本机只读),所以也不再解析。
import ai_config
visible_agent_name = self.agent_name.text().strip()
agent_override = (
""
if self._agent_name_follows_cloud
and visible_agent_name == self._displayed_cloud_agent_name
else visible_agent_name
)
ai_config.set_local_agent_name_override(agent_override)
self._agent_name_follows_cloud = not bool(agent_override)
# 这一页现在只写客服昵称。
#
# AI_MODEL / AI_TEMPERATURE / AI_MAX_TOKENS 已经由后台的「模型清单 +
# 角色编排」接管,桌面端写了也不会被用到(模型调用走网关)。
# AI_CONTEXT_MAX_ROUNDS / AI_MCP_* 随后台配置下发,本机写完下一次同步
# 就被盖回去。两种情况都是"改了、提示成功了、什么都没发生"。
#
# 昵称不一样:它有专门的本地覆盖机制(ai_agent_override.json),
# 后台同步不会动它,所以这一项是真的本机生效。
ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt()
ai_config.save_settings()
except Exception as exc:
self.save_status.setText(f"✕ 保存失败:{exc} {time.strftime('%H:%M:%S')}")
self.save_status.setObjectName("DangerText")
self.saved.emit(False, str(exc))
saved_ok = False
else:
self.save_status.setText(
f"✓ 本地偏好已保存并立即生效 {time.strftime('%H:%M:%S')}"
)
self.save_status.setObjectName("SuccessText")
self.saved.emit(True, "AI 本地偏好已保存并生效")
saved_ok = True
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
# 不再定时抹掉:一闪而过的成功提示等于没提示,一闪而过的失败提示更糟——
# 人以为存上了,其实没有,下次启动才发现改动全丢了。
return saved_ok
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
local_agent_name = ai_config.local_agent_name_override()
self._agent_name_follows_cloud = not bool(local_agent_name)
self._displayed_cloud_agent_name = str(ai_config.AI_CLOUD_AGENT_NAME or "贴心管家")
self.agent_name.setText(local_agent_name or self._displayed_cloud_agent_name)
self.agent_name.setPlaceholderText("贴心管家")
self.selected_model_name = str(getattr(ai_config, "AI_MODEL", "云端模型") or "云端模型")
self.model_selector_name.setText(
"Qwen 3.6 35B" if "--qt-smoke-test" in sys.argv else self.selected_model_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))
tools_enabled = bool(getattr(ai_config, "AI_MCP_ENABLED", True))
if self.tool_switches:
self._tool_switch_toggled(self.tool_switches[0], tools_enabled)
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)
handoffTasksRequested = Signal(object)
retryTasksRequested = Signal(object)
startRequested = Signal()
stopRequested = Signal()
pageRequested = Signal(int)
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["warning"],
"失败": COLORS["danger"],
}
STAGE_LABELS = {
"queued": "等待处理",
"opening": "正在视觉定位",
"reading": "正在读取消息",
"collecting": "正在合并消息",
"generating": "正在生成回复",
"ready_to_send": "等待发送",
"sending": "正在发送",
"receipt_check": "正在核对回执",
"retry_wait": "重试等待",
"blocked_modal": "弹窗阻塞",
"call_paused": "视频/通话暂停",
"manual_takeover": "人工接管",
"manual_review": "等待人工审核",
"uncertain": "发送待核对",
}
def __init__(self):
super().__init__()
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
root = QWidget()
root.setObjectName("PageRoot")
root.setMinimumWidth(1040)
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(12, 18, 16, 8)
layout.setSpacing(13)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(12)
header.addLayout(_page_header("02", "任务队列", "查看排队、处理中与待重试的自动回复任务"), 1)
queue_connected = _header_chip("● 企业微信已连接", "success")
queue_connected.setFixedSize(186, 48)
header.addWidget(queue_connected, 0, Qt.AlignVCenter)
queue_ai = _header_chip("● AI 服务正常", "success")
queue_ai.setFixedSize(162, 48)
header.addWidget(queue_ai, 0, Qt.AlignVCenter)
auto_send = _header_button("✈ 自动发送")
auto_send.setFixedSize(156, 48)
auto_send.clicked.connect(self.startRequested)
header.addWidget(auto_send, 0, Qt.AlignVCenter)
refresh = _header_button("Ⅱ 暂停监听", "danger")
refresh.setFixedSize(148, 48)
refresh.clicked.connect(self.stopRequested)
header.addWidget(refresh, 0, Qt.AlignVCenter)
layout.addLayout(header)
kpi_row = QHBoxLayout()
kpi_row.setContentsMargins(0, 0, 0, 0)
kpi_row.setSpacing(14)
self.kpi_processing = QueueKpiCard("♢", "处理中", "0", "blue")
self.kpi_waiting = QueueKpiCard("⌛", "等待中", "0", "purple")
self.kpi_retry = QueueKpiCard("⟳", "待重试", "0", "warning")
self.kpi_average = QueueKpiCard("◷", "平均等待", "0s", "success")
for card in (self.kpi_processing, self.kpi_waiting, self.kpi_retry, self.kpi_average):
kpi_row.addWidget(card, 1)
self.band_processing = self.kpi_processing.value
self.band_waiting = self.kpi_waiting.value
self.band_retry = self.kpi_retry.value
self.band_average = self.kpi_average.value
layout.addLayout(kpi_row)
layout.addSpacing(6)
# Compatibility metrics remain the public data API used by tests and
# other pages; the design board above mirrors their values.
self.waiting_metric = MetricCard("排队中", "0", "等待回复的会话")
self.sent_metric = MetricCard("今日已回", "0", "成功发出的回复")
self.failed_metric = MetricCard("今日失败", "0", "发送未成功,会自动重试")
self.retry_metric = MetricCard("待重试", "0", "可恢复的异常任务")
for card in (self.waiting_metric, self.sent_metric, self.failed_metric, self.retry_metric):
card.hide()
tasks, tasks_box = _compact_card("全部任务")
tasks.setFixedHeight(504)
tasks_box.setContentsMargins(24, 16, 24, 8)
filters = QHBoxLayout()
self.task_filter_buttons: list[QPushButton] = []
for index, text in enumerate(("全部", "处理中", "等待中", "待重试")):
button = _button(text)
button.setProperty("taskFilter", True)
button.setCheckable(True)
button.setAutoExclusive(True)
button.setChecked(index == 0)
button.setFixedHeight(36)
button.toggled.connect(lambda checked, name=text: checked and self._set_task_filter(name))
self.task_filter_buttons.append(button)
filters.addWidget(button)
filters.addStretch(1)
tasks_box.addLayout(filters)
self.task_list = QListWidget()
self.task_list.setObjectName("DeskSessionList")
self.task_list.setMinimumHeight(310)
self.task_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.task_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.task_list.setStyleSheet(
"QListWidget#DeskSessionList{background:transparent;border:none;outline:none;}"
"QListWidget#DeskSessionList::item{background:transparent;border:none;"
"padding:0;margin:0 0 6px 0;}"
)
self.task_list.currentRowChanged.connect(self._select_visible_task)
tasks_box.addWidget(self.task_list)
queue_footer = QHBoxLayout()
self.task_count = _label("共 0 个任务", "CardSubtitle")
queue_footer.addWidget(self.task_count)
queue_footer.addStretch(1)
queue_reload = HeaderActionButton("↻")
queue_reload.setObjectName("SecondaryButton")
queue_reload.setCursor(Qt.PointingHandCursor)
queue_reload.setFixedSize(50, 40)
queue_reload.setToolTip("刷新任务队列")
queue_reload.clicked.connect(self.refresh_data)
queue_footer.addWidget(queue_reload)
tasks_box.addLayout(queue_footer)
detail, detail_box = _compact_card("任务详情")
detail.setFixedHeight(504)
detail_meta = QHBoxLayout()
detail_meta.setSpacing(12)
customer_meta = QFrame()
customer_meta.setObjectName("MiniCard")
customer_meta.setFixedHeight(68)
customer_row = QHBoxLayout(customer_meta)
customer_row.setContentsMargins(11, 8, 11, 8)
customer_row.setSpacing(10)
self.detail_avatar = QLabel("高")
self.detail_avatar.setAlignment(Qt.AlignCenter)
self.detail_avatar.setFixedSize(44, 44)
self.detail_avatar.setStyleSheet(
"background:qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #75A8FF,stop:1 #365FF1);"
"color:white;border:none;border-radius:22px;font-size:18px;font-weight:600;"
)
customer_row.addWidget(self.detail_avatar)
self.detail_customer = _label("高瑞@微信", "CardTitle")
self.detail_customer.setWordWrap(False)
customer_row.addWidget(self.detail_customer, 1)
detail_meta.addWidget(customer_meta, 5)
session_meta, self.detail_session = self._detail_meta_card("会话 ID", "7bc26546…", copyable=True)
session_meta.copy_button.clicked.connect(self._copy_session_id)
detail_meta.addWidget(session_meta, 5)
wait_meta, self.detail_wait = self._detail_meta_card("已等待", "12 秒")
detail_meta.addWidget(wait_meta, 4)
retry_meta, self.detail_retry = self._detail_meta_card("重试", "0 次")
detail_meta.addWidget(retry_meta, 3)
detail_box.addLayout(detail_meta)
source = QFrame()
source.setObjectName("MiniCard")
source.setFixedHeight(100)
source.setStyleSheet(
"QFrame#MiniCard{background:rgba(244,247,252,210);border:1px solid rgba(226,232,244,220);}"
)
source_box = QVBoxLayout(source)
source_box.setContentsMargins(14, 9, 14, 10)
source_box.setSpacing(4)
source_box.addWidget(_label("来源消息", "MetricMeta"))
source_line = QHBoxLayout()
source_copy = QVBoxLayout()
source_copy.setSpacing(2)
self.detail_message_time = _label("16:32:11", "MetricMeta")
self.detail_message = _label("请问你们的灵芝孢子粉怎么服用?", "CardSubtitle")
self.detail_message.setWordWrap(True)
source_copy.addWidget(self.detail_message_time)
source_copy.addWidget(self.detail_message)
source_line.addLayout(source_copy, 1)
source_icon = LineIconBadge("chat", "BlueChip")
source_icon.setProperty("roundBadge", True)
source_icon.setFixedSize(28, 28)
source_line.addWidget(source_icon, 0, Qt.AlignTop)
source_box.addLayout(source_line)
detail_box.addWidget(source)
detail_box.addWidget(_label("当前动作", "MetricMeta"))
action_row = QHBoxLayout()
action_row.setContentsMargins(0, 0, 0, 0)
action_row.setSpacing(8)
self.detail_action_icon = LineIconBadge("✦", "BlueChip")
self.detail_action_icon.setFixedSize(22, 22)
action_row.addWidget(self.detail_action_icon, 0, Qt.AlignVCenter)
self.detail_action = _label("AI 生成回复中", "BlueText")
self.detail_action.setStyleSheet(
"color:#245DE7;background:transparent;border:none;font-size:17px;font-weight:600;"
)
action_row.addWidget(self.detail_action, 0, Qt.AlignVCenter)
action_row.addStretch(1)
detail_box.addLayout(action_row)
self.detail_track = self._queue_detail_track()
detail_box.addWidget(self.detail_track)
detail_actions = QHBoxLayout()
self.delete_queue_button = _button("⊗ 取消任务", "danger")
self.delete_queue_button.clicked.connect(self._request_delete_selected)
detail_actions.addWidget(self.delete_queue_button)
self.retry_queue_button = _button("⟳ 立即重试")
self.retry_queue_button.setEnabled(False)
self.retry_queue_button.clicked.connect(self._request_retry_selected)
detail_actions.addWidget(self.retry_queue_button)
handoff_now = _button("♙ 转人工")
handoff_now.setProperty("iconColor", "#E77922")
handoff_now.setStyleSheet(
"QPushButton{background:qlineargradient(x1:0,y1:0,x2:1,y2:0,"
"stop:0 #FFF9F1,stop:1 #FFF0DF);color:#E77922;border:1px solid #F7D8B8;"
"border-radius:18px;padding:10px 20px;font-weight:600;}"
"QPushButton:hover{background:#FFF0DF;color:#D96512;border-color:#F0C79E;}"
)
handoff_now.clicked.connect(self._request_handoff_selected)
detail_actions.addWidget(handoff_now)
detail_box.addLayout(detail_actions)
schedule, schedule_box = _compact_card("调度信息")
schedule.setFixedHeight(504)
self.schedule_values: list[QLabel] = []
for icon, title, value, tone in (("⚑", "优先级", "普通", "blue"), ("◷", "预计完成", "3 秒", "blue"), ("♢", "人工保护", "已开启", "success"), ("⟳", "下次重试", "—", "blue")):
tile = QFrame()
tile.setObjectName("MiniCard")
tile.setMinimumHeight(86)
line = QHBoxLayout(tile)
line.setContentsMargins(14, 12, 14, 12)
line.setSpacing(12)
badge = LineIconBadge(icon, "StatusChip" if tone == "success" else "WarningChip" if tone == "warning" else "BlueChip")
badge.setProperty("roundBadge", True)
badge.setFixedSize(42, 42)
line.addWidget(badge)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(title, "MetricLabel"))
value_label = _label(value, "SuccessText" if tone == "success" else "CardTitle")
copy.addWidget(value_label)
self.schedule_values.append(value_label)
line.addLayout(copy, 1)
schedule_box.addWidget(tile)
schedule_box.addStretch(1)
body = QGridLayout()
body.setHorizontalSpacing(16)
body.addWidget(tasks, 0, 0)
body.addWidget(detail, 0, 1)
body.addWidget(schedule, 0, 2)
tasks.setFixedWidth(350)
schedule.setFixedWidth(302)
body.setColumnStretch(0, 0)
body.setColumnStretch(1, 1)
body.setColumnStretch(2, 0)
layout.addLayout(body)
layout.addSpacing(8)
history_card, history_box = _compact_card("任务执行记录")
history_card.setFixedHeight(156)
self.history_strip = QHBoxLayout()
self.history_strip.setSpacing(10)
self.history_more = _button("查看完整日志 >")
self.history_more.clicked.connect(lambda: self.pageRequested.emit(5))
self._selected_session_id = ""
self._recent_events: list[dict] = []
self.pending_states: dict[str, dict] = {}
self._visual_payload: dict = {}
history_box.addLayout(self.history_strip)
self._set_history_strip((
("16:32:11", "入队", "任务已加入队列", "success"),
("16:32:11", "读取消息", "消息已读取", "success"),
("16:32:12", "请求 AI", "已向 AI 服务发送请求", "success"),
("16:32:12", "生成回复", "AI 正在生成回复内容", "blue"),
))
layout.addWidget(history_card)
# Hidden diagnostic widgets preserve the existing visual-monitor and
# table-driven functionality without changing the supplied structure.
self.vision_status = _label("尚未收到视觉画面", "CardSubtitle")
self.vision_preview = QLabel("开始监听后,这里会显示企业微信会话列表的实时识别画面")
self.vision_status.hide()
self.vision_preview.hide()
self._running = False
self.queue_notice = _label("", "CardSubtitle")
# This is a compatibility/status value consumed by tests and the HTML
# shell. It is intentionally not a visible Qt widget: an unparented
# QLabel becomes its own native window as soon as show() is called.
self.queue_notice.setParent(root)
self.queue_notice.hide()
self.queue_table = self._table(
["排队", "客户", "状态", "等待时长", "阶段耗时", "重试", "结果/原因", "会话 ID"]
)
self.history_table = self._table(["时间", "客户", "动作", "内容"])
self.queue_table.hide()
self.history_table.hide()
layout.addStretch(1)
self.refresh_data()
@staticmethod
def _detail_meta_card(title: str, value: str, copyable: bool = False) -> tuple[QFrame, QLabel]:
card = QFrame()
card.setObjectName("MiniCard")
card.setFixedHeight(74)
box = QVBoxLayout(card)
box.setContentsMargins(13, 8, 13, 8)
box.setSpacing(2)
title_label = _label(title, "MetricMeta")
title_label.setWordWrap(False)
box.addWidget(title_label)
value_row = QHBoxLayout()
value_row.setContentsMargins(0, 0, 0, 0)
value_row.setSpacing(4)
value_label = _label(value, "CardTitle")
value_label.setWordWrap(False)
value_row.addWidget(value_label, 1)
if copyable:
copy_btn = HeaderActionButton("❐")
copy_btn.setObjectName("SecondaryButton")
copy_btn.setCursor(Qt.PointingHandCursor)
copy_btn.setFixedSize(28, 28)
copy_btn.setToolTip("复制会话 ID")
copy_btn.setStyleSheet(
"QPushButton#SecondaryButton{padding:0;border-radius:8px;min-width:28px;min-height:28px;}"
)
value_row.addWidget(copy_btn, 0, Qt.AlignVCenter)
card.copy_button = copy_btn
box.addLayout(value_row)
return card, value_label
def _copy_session_id(self) -> None:
text = str(getattr(self, "_selected_session_id", "") or "").strip()
if not text or text in {"--", "—"}:
text = self.detail_session.text().replace("…", "").strip()
if text and text not in {"--", "—"}:
QApplication.clipboard().setText(text)
def _set_history_strip(self, items: tuple[tuple[str, str, str, str], ...] | list[tuple[str, str, str, str]]) -> None:
self.history_more.setParent(None)
while self.history_strip.count():
item = self.history_strip.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
for args in items:
self.history_strip.addWidget(_activity_tile(*args), 1)
self.history_strip.addWidget(self.history_more, 0, Qt.AlignVCenter)
def _set_schedule(self, priority: str, eta: str, protect: str, retry: str) -> None:
values = (priority, eta, protect, retry)
for label, text in zip(self.schedule_values, values):
label.setText(text)
protect_label = self.schedule_values[2]
if protect == "已开启":
protect_label.setObjectName("SuccessText")
protect_label.setStyleSheet("color:#11A878;font-weight:600;background:transparent;border:none;")
else:
protect_label.setObjectName("CardTitle")
protect_label.setStyleSheet("color:#14234F;font-weight:600;background:transparent;border:none;")
def _queue_detail_track(self) -> QFrame:
track = QFrame()
track.setObjectName("MiniCard")
track.setFixedHeight(112)
row = QHBoxLayout(track)
row.setContentsMargins(8, 8, 8, 8)
row.setSpacing(2)
self.detail_step_badges: list[QueueStepBadge] = []
self.detail_step_subtitles: list[QLabel] = []
steps = ("入队", "读取消息", "请求 AI", "生成回复", "回填发送", "完成")
for index, title in enumerate(steps):
node = QWidget()
node.setFixedWidth(80)
node_box = QVBoxLayout(node)
node_box.setContentsMargins(0, 0, 0, 0)
node_box.setSpacing(4)
badge = QueueStepBadge()
node_box.addWidget(badge, 0, Qt.AlignHCenter)
title_label = _label(title, "CardSubtitle")
title_label.setAlignment(Qt.AlignCenter)
title_label.setWordWrap(False)
node_box.addWidget(title_label)
subtitle = _label("", "MetricMeta")
subtitle.setAlignment(Qt.AlignCenter)
subtitle.setWordWrap(False)
subtitle.setStyleSheet("color:#8490B3;font-size:10px;background:transparent;border:none;")
node_box.addWidget(subtitle)
row.addWidget(node)
self.detail_step_badges.append(badge)
self.detail_step_subtitles.append(subtitle)
if index < len(steps) - 1:
connector_holder = QWidget()
connector_box = QVBoxLayout(connector_holder)
connector_box.setContentsMargins(0, 0, 0, 0)
connector_box.setSpacing(0)
connector_box.addSpacing(19)
connector = QFrame()
connector.setFixedHeight(2)
connector.setStyleSheet("background:#D8E1F3;border:none;")
connector_box.addWidget(connector)
connector_box.addStretch(1)
row.addWidget(connector_holder, 1)
self._set_detail_track_state(-1, ("", "", "", "", "", ""))
return track
def _set_detail_track_state(self, active: int, subtitles: tuple[str, ...]) -> None:
for index, (badge, subtitle) in enumerate(zip(self.detail_step_badges, self.detail_step_subtitles)):
subtitle.setText(subtitles[index] if index < len(subtitles) else "")
if active < 0:
badge.set_state("wait", str(index + 1))
subtitle.setStyleSheet("color:#8490B3;font-size:10px;background:transparent;border:none;")
elif index < active:
badge.set_state("done", str(index + 1))
subtitle.setStyleSheet("color:#8490B3;font-size:10px;background:transparent;border:none;")
elif index == active:
badge.set_state("active", str(index + 1))
subtitle.setStyleSheet(
"color:#245DE7;font-size:10px;font-weight:600;background:transparent;border:none;"
)
else:
badge.set_state("wait", str(index + 1))
subtitle.setStyleSheet("color:#8490B3;font-size:10px;background:transparent;border:none;")
@staticmethod
def _display_task_status(state: str) -> str:
if state.startswith("正在"):
return "处理中"
if "重试" in state:
return "待重试"
return "等待中"
def _task_row_widget(
self,
name: str,
state: str,
wait_text: str,
retries: int,
avatar: str,
avatar_color: str,
) -> QFrame:
frame = QFrame()
frame.setFixedHeight(66)
frame.setStyleSheet("background:transparent;border:none;border-radius:14px;")
outer = QHBoxLayout(frame)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
selection_bar = QFrame()
selection_bar.setFixedWidth(5)
selection_bar.setStyleSheet("background:transparent;border:none;border-radius:2px;")
outer.addWidget(selection_bar)
content = QWidget()
content_box = QHBoxLayout(content)
content_box.setContentsMargins(12, 6, 12, 6)
content_box.setSpacing(11)
avatar_label = QLabel(avatar)
avatar_label.setAlignment(Qt.AlignCenter)
avatar_label.setFixedSize(40, 40)
avatar_label.setStyleSheet(
f"background:{avatar_color};color:white;border:none;border-radius:20px;"
"font-size:16px;font-weight:600;"
)
content_box.addWidget(avatar_label)
copy = QVBoxLayout()
copy.setContentsMargins(0, 0, 0, 0)
copy.setSpacing(2)
top = QHBoxLayout()
name_label = _label(name, "CardTitle")
name_label.setWordWrap(False)
name_label.setStyleSheet("color:#13234E;font-size:13px;font-weight:600;background:transparent;border:none;")
top.addWidget(name_label, 1)
display_status = self._display_task_status(state)
status_label = QLabel(display_status)
status_label.setAlignment(Qt.AlignCenter)
status_label.setFixedHeight(25)
if display_status == "处理中":
status_style = "background:#EAF1FF;color:#2461E9;border:1px solid #D0DEFF;"
elif display_status == "待重试":
status_style = "background:#FFF4E7;color:#F08A27;border:1px solid #FFE0BB;"
else:
status_style = "background:#F1EDFF;color:#7357E8;border:1px solid #E2D9FF;"
status_label.setStyleSheet(
status_style + "border-radius:12px;padding:0 9px;font-size:11px;font-weight:600;"
)
top.addWidget(status_label)
copy.addLayout(top)
bottom = QHBoxLayout()
wait_label = _label(f"已等待 {wait_text}", "MetricMeta")
wait_label.setWordWrap(False)
bottom.addWidget(wait_label)
bottom.addStretch(1)
retry_label = _label(f"重试 {retries} 次", "MetricMeta")
retry_label.setWordWrap(False)
bottom.addWidget(retry_label)
copy.addLayout(bottom)
content_box.addLayout(copy, 1)
outer.addWidget(content, 1)
frame._selection_bar = selection_bar
return frame
def _add_task_list_item(
self,
*,
name: str,
state: str,
wait_text: str,
retries: int,
avatar: str,
avatar_color: str,
) -> None:
item = QListWidgetItem()
item.setData(Qt.UserRole + 1, state)
item.setData(Qt.UserRole + 2, avatar)
item.setSizeHint(QSize(292, 66))
self.task_list.addItem(item)
self.task_list.setItemWidget(
item,
self._task_row_widget(name, state, wait_text, retries, avatar, avatar_color),
)
def _empty_queue_widget(self) -> QWidget:
card = QWidget()
box = QVBoxLayout(card)
box.setContentsMargins(18, 36, 18, 36)
box.setSpacing(10)
mark = LineIconBadge("✓", "StatusChip")
mark.setProperty("roundBadge", True)
mark.setFixedSize(56, 56)
box.addWidget(mark, 0, Qt.AlignHCenter)
title = _label("当前没有待处理任务", "CardTitle")
title.setAlignment(Qt.AlignCenter)
hint = _label("所有客户消息均已完成", "CardSubtitle")
hint.setAlignment(Qt.AlignCenter)
box.addWidget(title)
box.addWidget(hint)
box.addStretch(1)
return card
def _reset_task_details(self) -> None:
self._selected_session_id = ""
self.detail_avatar.setText("—")
self.detail_customer.setText("暂无选中任务")
self.detail_session.setText("--")
self.detail_wait.setText("--")
self.detail_retry.setText("0 次")
self.detail_message_time.setText("")
self.detail_message_time.hide()
self.detail_message.setText("队列为空时,选择任务后会在这里显示来源消息。")
self.detail_action.setText("等待新任务")
self.detail_action_icon.hide()
self.retry_queue_button.setEnabled(False)
self._set_detail_track_state(-1, ("", "", "", "", "", ""))
self._set_schedule("—", "—", "—", "—")
self._set_history_strip((
("—", "入队", "等待任务入队", "idle"),
("—", "读取消息", "等待读取消息", "idle"),
("—", "请求 AI", "等待请求 AI", "idle"),
("—", "生成回复", "等待生成回复", "idle"),
))
@classmethod
def _queue_stage_index(cls, state_text: str) -> int:
if state_text.startswith("正在读取") or "视觉定位" in state_text or "合并消息" in state_text:
return 1
if "请求" in state_text and "AI" in state_text:
return 2
if state_text.startswith("正在生成") or "生成回复" in state_text or "重试" in state_text:
return 3
if state_text.startswith("正在发送") or "等待发送" in state_text or "待核对" in state_text:
return 4
if "核对回执" in state_text or state_text.startswith("已发送"):
return 5
return 0
def _history_items_for_task(self, name: str, session: str, state_text: str) -> tuple[tuple[str, str, str, str], ...]:
if getattr(self, "_showing_demo_tasks", False) and "高瑞" in name:
return (
("16:32:11", "入队", "任务已加入队列", "success"),
("16:32:11", "读取消息", "消息已读取", "success"),
("16:32:12", "请求 AI", "已向 AI 服务发送请求", "success"),
("16:32:12", "生成回复", "AI 正在生成回复内容", "blue"),
)
matched = [
event for event in getattr(self, "_recent_events", [])
if str(event.get("name") or "") == name
or (session and session[:8] in str(event.get("session_id") or ""))
]
if matched:
tiles = []
for event in reversed(matched[:4]):
try:
stamp = float(event.get("ts") or 0.0)
except (TypeError, ValueError):
stamp = 0.0
clock = time.strftime("%H:%M:%S", time.localtime(stamp)) if stamp else "—"
action = str(event.get("event") or "事件")
detail = str(event.get("detail") or action)
tone = "warning" if action in {"失败", "跳过"} else "success"
tiles.append((clock, action, detail, tone))
while len(tiles) < 4:
tiles.append(("—", "等待中", "后续步骤尚未开始", "idle"))
return tuple(tiles[:4])
active = self._queue_stage_index(state_text)
clock = time.strftime("%H:%M:%S")
titles = (
("入队", "任务已加入队列"),
("读取消息", "消息已读取"),
("请求 AI", "已向 AI 服务发送请求"),
("生成回复", "AI 正在生成回复内容"),
)
tiles = []
for index, (title, detail) in enumerate(titles):
if index < active:
tiles.append((clock, title, detail, "success"))
elif index == active:
tiles.append((clock, title, detail if index < 3 else "AI 正在生成回复内容", "blue" if index >= 2 else "success"))
else:
tiles.append(("—", title, "等待中", "idle"))
return tuple(tiles)
def _sync_task_selection(self, selected_row: int) -> None:
for row in range(self.task_list.count()):
item = self.task_list.item(row)
widget = self.task_list.itemWidget(item)
if widget is None:
continue
selected = row == selected_row
widget.setStyleSheet(
"background:#EDF3FF;border:1px solid #D9E4FF;border-radius:14px;"
if selected else
"background:transparent;border:1px solid transparent;border-radius:14px;"
)
widget._selection_bar.setStyleSheet(
"background:#3475FF;border:none;border-radius:2px;"
if selected else
"background:transparent;border:none;border-radius:2px;"
)
@staticmethod
def _smoke_demo_rows() -> tuple[tuple[str, str, str, str, str, int, str, str, str], ...]:
return (
("demo:high", "高瑞@微信", "正在生成回复", "12 秒", "高", 0, "#4C75F5", "7bc26546-demo", "请问你们的灵芝孢子粉怎么服用?"),
("demo:small", "一个小迷糊@微信", "等待处理", "45 秒", "小", 0, "#65C7AA", "58ab2190-demo", "每天吃几次比较合适?"),
("demo:li", "李先生@微信", "等待发送", "2 分 10 秒", "李", 0, "#4C87F6", "b21c5512-demo", "有不良反应吗?"),
("demo:chen", "陈女士@微信", "重试等待", "18 秒", "陈", 1, "#805AC5", "a17d9934-demo", "价格是多少?"),
("demo:zhang", "张小明@微信", "等待处理", "3 分 22 秒", "张", 0, "#28AEC9", "ce932114-demo", "可以和其他药一起吃吗?"),
)
def _set_task_filter(self, name: str) -> None:
self.active_task_filter = name
self._apply_task_filter()
def _apply_task_filter(self) -> None:
if not hasattr(self, "task_list"):
return
mode = getattr(self, "active_task_filter", "全部")
for row in range(self.task_list.count()):
item = self.task_list.item(row)
state = str(item.data(Qt.UserRole + 1) or "")
match = (
mode == "全部"
or mode == "处理中" and state.startswith("正在")
or mode == "等待中" and state in {
"排队中", "等待处理", "等待发送", "等待人工审核"
}
or mode == "待重试" and "重试" in state
)
item.setHidden(not match)
def _select_visible_task(self, row: int) -> None:
self._sync_task_selection(row)
if row < 0 or row >= self.queue_table.rowCount() or not self.queue_table.item(row, 1):
self.retry_queue_button.setEnabled(False)
return
self.queue_table.selectRow(row)
key_item = self.queue_table.item(row, 0)
self.retry_queue_button.setEnabled(
bool(key_item and key_item.data(Qt.UserRole + 1))
and not getattr(self, "_showing_demo_tasks", False)
)
name = self.queue_table.item(row, 1)
state = self.queue_table.item(row, 2)
waited = self.queue_table.item(row, 3)
retries = self.queue_table.item(row, 5)
session = self.queue_table.item(row, 7)
detail = self.queue_table.item(row, 6)
selected_item = self.task_list.item(row)
avatar = str(selected_item.data(Qt.UserRole + 2) or "客") if selected_item else "客"
self.detail_avatar.setText(avatar)
self.detail_customer.setText(name.text() if name else "未知客户")
session_text = session.text() if session else "--"
self._selected_session_id = session_text
self.detail_session.setText(session_text[:8] + ("…" if len(session_text) > 8 else ""))
self.detail_wait.setText(waited.text() if waited else "--")
self.detail_retry.setText(f"{retries.text() if retries else '0'} 次")
state_text = state.text() if state else "等待处理"
processing = state_text.startswith("正在")
self.detail_action_icon.setVisible(processing)
self.detail_action.setText("AI 生成回复中" if processing else state_text)
message = detail.text() if detail else "等待读取客户消息"
clock = str(detail.data(Qt.UserRole) or "") if detail else ""
if clock:
self.detail_message_time.setText(clock)
self.detail_message_time.show()
else:
self.detail_message_time.hide()
self.detail_message.setText(message)
if getattr(self, "_showing_demo_tasks", False) and row == 0:
self._set_detail_track_state(
3,
("16:32:11", "16:32:11", "16:32:12", "进行中", "等待中", "等待中"),
)
self._set_schedule("普通", "3 秒", "已开启", "—")
else:
active = self._queue_stage_index(state_text)
subtitles = tuple(
"已完成" if index < active else "进行中" if index == active else "等待中"
for index in range(6)
)
self._set_detail_track_state(active, subtitles)
eta = "3 秒" if processing else ("—" if "重试" in state_text else "排队中")
retry_at = "30 秒" if "重试" in state_text else "—"
self._set_schedule("普通", eta, "已开启", retry_at)
self._set_history_strip(self._history_items_for_task(
name.text() if name else "",
session_text,
state_text,
))
@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(
"监听已停止,这些任务原地保留;重新开始监听后会接着处理,等待时长仍在累计。"
)
else:
self.queue_notice.clear()
# The visible console already communicates the stopped state. Keep
# this legacy value non-visual so it can never open a stray window.
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
self._visual_payload = dict(payload)
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.retry_queue_button.setEnabled(False)
self._refresh_history()
self._refresh_queue()
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._showing_demo_tasks = not rows and "--qt-smoke-test" in sys.argv
self.pending_states = {
str(key): dict(state) for key, state in rows if isinstance(state, dict)
}
if self._showing_demo_tasks:
demo_rows = self._smoke_demo_rows()
self.waiting_metric.value.setText(str(len(demo_rows)))
self.retry_metric.value.setText("1")
self.band_processing.setText("1")
self.band_waiting.setText("3")
self.band_retry.setText("1")
self.band_average.setText("12s")
self.task_count.setText("共 5 个任务")
self.task_list.blockSignals(True)
self.task_list.clear()
self.queue_table.clearSpans()
self.queue_table.setRowCount(len(demo_rows))
for index, (key, name, state, wait_text, avatar, retries, avatar_color, session, message) in enumerate(demo_rows):
values = (
str(index + 1), name, state, wait_text, "--", str(retries), message, session,
)
for column, value in enumerate(values):
item = QTableWidgetItem(str(value))
if column == 0:
item.setData(Qt.UserRole, key)
if column == 6:
item.setData(Qt.UserRole, "16:32:11")
self.queue_table.setItem(index, column, item)
self._add_task_list_item(
name=name,
state=state,
wait_text=wait_text,
retries=retries,
avatar=avatar,
avatar_color=avatar_color,
)
self.task_list.blockSignals(False)
self._apply_task_filter()
self.task_list.setCurrentRow(0)
self._select_visible_task(0)
return
self.waiting_metric.value.setText(str(len(rows)))
retry_count = sum(1 for _key, state in rows if str(state.get("stage") or "") == "retry_wait")
self.retry_metric.value.setText(str(retry_count))
processing_count = 0
waiting_count = 0
waits = []
for _key, state in rows:
display = self._display_task_status(self._state_text(state))
if display == "处理中":
processing_count += 1
elif display == "待重试":
pass
else:
waiting_count += 1
try:
waits.append(max(0, int(time.time() - float(state.get("created_at")))))
except (TypeError, ValueError):
pass
self.band_processing.setText(str(processing_count))
self.band_waiting.setText(str(waiting_count))
self.band_retry.setText(str(retry_count))
self.band_average.setText(f"{int(sum(waits) / len(waits)) if waits else 0}s")
self.task_count.setText(f"共 {len(rows)} 个任务")
self.task_list.blockSignals(True)
self.task_list.clear()
self.queue_table.clearSpans()
self.queue_table.setRowCount(len(rows))
if not rows:
self._show_placeholder(self.queue_table, "没有排队的会话,所有消息都已回复。")
empty_item = QListWidgetItem()
empty_item.setFlags(Qt.NoItemFlags)
empty_item.setSizeHint(QSize(300, 240))
self.task_list.addItem(empty_item)
self.task_list.setItemWidget(empty_item, self._empty_queue_widget())
self.task_list.blockSignals(False)
self._reset_task_details()
return
avatar_colors = ("#4C75F5", "#65C7AA", "#4C87F6", "#805AC5", "#28AEC9")
for index, (key, state) in enumerate(rows):
message = (
str(state.get("staged_user_text") or "").strip()
or str(state.get("stage_detail") or "").strip()
or str(state.get("last_error") or "").strip()
or "等待读取客户消息"
)
clock = ""
try:
created = float(state.get("created_at") or 0.0)
except (TypeError, ValueError):
created = 0.0
if created:
clock = time.strftime("%H:%M:%S", time.localtime(created))
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)),
message,
str(key)[:32],
)
for column, value in enumerate(values):
item = QTableWidgetItem(str(value))
if column == 0:
item.setData(Qt.UserRole, str(key))
item.setData(
Qt.UserRole + 1,
str(state.get("stage") or "") == "retry_wait",
)
if column == 6:
item.setData(Qt.UserRole, clock)
self.queue_table.setItem(index, column, item)
name = str(state.get("display_name") or "(未识别昵称)")
status = self._state_text(state)
wait_text = self._waited_text(state.get("created_at"))
initial = next((char for char in name if "\u4e00" <= char <= "\u9fff"), "客")
self._add_task_list_item(
name=name,
state=status,
wait_text=wait_text,
retries=int(state.get("resume_failures") or 0),
avatar=initial,
avatar_color=avatar_colors[index % len(avatar_colors)],
)
self.task_list.blockSignals(False)
self._apply_task_filter()
self.task_list.setCurrentRow(0)
self._select_visible_task(0)
def _request_delete_selected(self) -> None:
if getattr(self, "_showing_demo_tasks", False):
return
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 _request_retry_selected(self) -> None:
"""Request a real retry only for tasks currently in retry backoff."""
if getattr(self, "_showing_demo_tasks", False):
return
rows = sorted(
{index.row() for index in self.queue_table.selectionModel().selectedRows()}
)
keys = []
not_retryable = 0
for row in rows:
key_item = self.queue_table.item(row, 0)
key = str(key_item.data(Qt.UserRole) or "") if key_item else ""
if not key:
continue
if bool(key_item.data(Qt.UserRole + 1)):
keys.append(key)
else:
not_retryable += 1
if not keys:
QMessageBox.information(
self,
"无需重试",
"当前任务正在正常处理或等待人工审核,不能强制重试。",
)
return
if not_retryable:
QMessageBox.information(
self,
"仅重试异常任务",
f"已选中的 {not_retryable} 条正常任务不会被打断;只重试其中 {len(keys)} 条待重试任务。",
)
self.retryTasksRequested.emit(keys)
def _request_handoff_selected(self) -> None:
if getattr(self, "_showing_demo_tasks", False):
return
rows = sorted(
{index.row() for index in self.queue_table.selectionModel().selectedRows()}
)
keys = []
for row in rows:
key_item = self.queue_table.item(row, 0)
key = str(key_item.data(Qt.UserRole) or "") if key_item else ""
if key:
keys.append(key)
if not keys:
QMessageBox.information(self, "提示", "请先选择要转人工的队列任务。")
return
if QMessageBox.question(
self,
"转人工处理",
f"确定将选中的 {len(keys)} 条任务转人工吗?自动回复将停止,会话档案仍会保留。",
) != QMessageBox.Yes:
return
self.handoffTasksRequested.emit(keys)
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._recent_events = events
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.reference_preview = "--qt-smoke-test" in sys.argv
self.setObjectName("PageRoot")
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 18, 16, 8)
layout.setSpacing(12)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(12)
header.addLayout(_page_header("06", "运行日志", "实时追踪连接、消息识别、AI 调用与自动发送事件"), 1)
self.live_chip = _header_button("⟳ 实时刷新")
self.live_chip.setFixedSize(144, 48)
self.live_refresh = True
self.live_chip.clicked.connect(self._toggle_live_refresh)
header.addWidget(self.live_chip, 0, Qt.AlignVCenter)
export = _header_button("↑ 导出日志")
export.setFixedSize(144, 48)
export.clicked.connect(self.export)
header.addWidget(export, 0, Qt.AlignVCenter)
clear = _header_button("⌫ 清空日志", "danger")
clear.setFixedSize(148, 48)
clear.clicked.connect(self.clear)
header.addWidget(clear, 0, Qt.AlignVCenter)
monitor = _header_chip("● 监控中", "blue")
monitor.setFixedSize(128, 48)
header.addWidget(monitor, 0, Qt.AlignVCenter)
layout.addLayout(header)
band, values = _status_band([
("●", "企业微信", "99.9%", "success"),
("AI", "AI 成功率", "98.6%", "blue"),
("◷", "平均响应", "2.4s", "blue"),
("!", "警告", "1", "warning"),
])
band.setFixedHeight(87)
self.connection_value, self.ai_value, self.response_value, self.warning_value = values
layout.addWidget(band)
self.connection_metric = MetricCard("企业微信", "待连接", "窗口连接状态")
self.ai_metric = MetricCard("AI 服务", "就绪", "云端配置已加载")
self.response_metric = MetricCard("当前进程", "待命", "等待新消息")
self.warning_metric = MetricCard("异常", "0", "本次运行累计")
for card in (self.connection_metric, self.ai_metric, self.response_metric, self.warning_metric):
card.hide()
card, card_layout = _compact_card("实时事件流")
card.setFixedHeight(391)
filter_row = QHBoxLayout()
self.event_filter_buttons: list[QPushButton] = []
for index, text in enumerate(("全部", "连接", "识别", "AI", "发送", "警告")):
button = _button(text)
button.setCheckable(True)
button.setAutoExclusive(True)
button.setChecked(index == 0)
button.toggled.connect(lambda checked, name=text: checked and self._set_event_filter(name))
self.event_filter_buttons.append(button)
filter_row.addWidget(button)
filter_row.addStretch(1)
self.event_search = QLineEdit()
self.event_search.setMaxLength(80)
self.event_search.setPlaceholderText("搜索事件内容")
self.event_search.addAction(
_painted_ui_icon("search", 18), QLineEdit.ActionPosition.LeadingPosition
)
self.event_search.setFixedWidth(185)
self.event_search.textChanged.connect(self._apply_event_filter)
filter_row.addWidget(self.event_search)
card_layout.addLayout(filter_row)
self.event_rows: list[QFrame] = []
event_rows_layout = QVBoxLayout()
event_rows_layout.setContentsMargins(0, 0, 0, 0)
event_rows_layout.setSpacing(0)
for event in (
("16:33:27", "未读扫描", "检测到客户未读消息", "成功", "success", "识别"),
("16:33:28", "提取聊天记录", "已读取最近 6 条消息", "成功", "success", "识别"),
("16:33:29", "AI 服务", "正在生成回复", "进行中", "blue", "AI"),
("16:33:31", "人工保护", "检测到鼠标操作,暂停 5 秒", "警告", "warning", "警告"),
("16:33:36", "自动发送", "回复发送成功", "成功", "success", "发送"),
) if self.reference_preview else (("", "", "", "", "blue", "") for _ in range(5)):
event_row = _event_stream_row(*event)
self.event_rows.append(event_row)
event_rows_layout.addWidget(event_row)
if not self.reference_preview:
for event_row in self.event_rows:
event_row.event_empty = True
card_layout.addLayout(event_rows_layout, 1)
# The structured rows are the visible design surface. The bounded rich-text
# document remains the lossless in-memory source for export and diagnostics.
self.editor = QTextEdit()
self.editor.setReadOnly(True)
self.editor.setAcceptRichText(True)
# 机器人每轮轮询都在打日志,跑一天就是几万块富文本;不封顶的话文档
# 越长每次追加越慢,整个界面跟着卡。磁盘副本是全量的,界面只留近况。
self.editor.document().setMaximumBlockCount(2000)
self.editor.setStyleSheet(
"QTextEdit{font-family:'HarmonyOS Sans SC','Microsoft YaHei UI';font-size:11px;font-weight:400;line-height:1.45;background:#FBFCFF;border:none;}"
)
self.editor.hide()
health, health_layout = _compact_card("系统健康")
health.setFixedHeight(235)
self.health_rows = {}
ring_row = QHBoxLayout()
for key, title in (("wecom", "企业微信连接"), ("ai", "AI 服务"), ("vision", "窗口识别")):
item = QVBoxLayout()
item.addWidget(StatusRing("✓", "#16B887", 90), 0, Qt.AlignHCenter)
label = _label(title, "CardSubtitle")
label.setAlignment(Qt.AlignCenter)
item.addWidget(label)
state = _label("正常", "SuccessText")
state.setAlignment(Qt.AlignCenter)
item.addWidget(state)
ring_row.addLayout(item)
self.health_rows[key] = state
health_layout.addLayout(ring_row)
runtime = _chip("◷ 运行时长 02:18:46", "blue")
runtime.setFixedSize(343, 33)
health_layout.addWidget(runtime, 0, Qt.AlignCenter)
chain, chain_layout = _compact_card("当前回复链路", margins=(20, 10, 20, 8))
chain_layout.setSpacing(2)
chain.setFixedHeight(144)
chain_layout.addWidget(_flow_track((("◎", "扫描未读"), ("●", "读取消息"), ("AI", "请求 AI"), ("✈", "回填发送")), 2))
timing = QHBoxLayout()
for text in ("0.3s", "1.2s", "2.4s", "0.6s"):
stamp = _label(text, "MetricMeta")
stamp.setAlignment(Qt.AlignCenter)
timing.addWidget(stamp)
chain_layout.addLayout(timing)
body = QGridLayout()
body.setHorizontalSpacing(14)
body.setVerticalSpacing(12)
body.addWidget(card, 0, 0, 2, 1)
body.addWidget(health, 0, 1)
body.addWidget(chain, 1, 1)
body.setColumnStretch(0, 765)
body.setColumnStretch(1, 617)
layout.addLayout(body)
detail, detail_layout = _compact_card("事件详情")
detail.setObjectName("EventDetail")
detail.setStyleSheet(
"QFrame#EventDetail{background:qlineargradient(x1:0,y1:0,x2:1,y2:0,"
"stop:0 rgba(255,252,248,238),stop:1 rgba(255,247,241,225));"
"border:1px solid #F2E5DD;border-radius:25px;}"
)
detail.setFixedHeight(137)
detail_row = QHBoxLayout()
detail_icon = LineIconBadge("person", "WarningChip")
detail_icon.setProperty("roundBadge", True)
detail_icon.setFixedSize(58, 58)
detail_row.addWidget(detail_icon, 0, Qt.AlignVCenter)
detail_copy = QVBoxLayout()
detail_copy.setSpacing(3)
detail_copy.addWidget(_label("人工保护", "CardTitle"))
detail_copy.addWidget(_label("检测到人工鼠标操作,自动回复暂停 5 秒", "CardSubtitle"))
detail_tags = QHBoxLayout()
detail_tags.setSpacing(7)
for text, tone in (("模块:人机共存", "blue"), ("会话:高瑞@微信", "blue"), ("已自动恢复", "success")):
tag = _chip(text, tone)
tag.setFixedHeight(27)
detail_tags.addWidget(tag)
detail_tags.addStretch(1)
detail_copy.addLayout(detail_tags)
detail_row.addLayout(detail_copy, 1)
detail_layout.addLayout(detail_row)
anomaly, anomaly_layout = _compact_card("异常追踪")
anomaly.setFixedHeight(137)
anomaly_row = QHBoxLayout()
for icon, title, value, tone in (("!", "发送失败", "1", "danger"), ("◷", "模型超时", "0", "blue"), ("▣", "窗口丢失", "0", "warning")):
tile = QFrame()
tile.setObjectName("MiniCard")
line = QHBoxLayout(tile)
badge = LineIconBadge(icon, "DangerText" if tone == "danger" else "WarningChip" if tone == "warning" else "BlueChip")
badge.setFixedSize(42, 42)
line.addWidget(badge)
copy = QVBoxLayout()
copy.addWidget(_label(title, "MetricLabel"))
copy.addWidget(_label(value, "CardTitle"))
line.addLayout(copy)
anomaly_row.addWidget(tile)
anomaly_action = _button("查看异常 ")
anomaly_action.setFixedSize(142, 50)
anomaly_action.clicked.connect(self._show_anomalies)
anomaly_row.addWidget(anomaly_action)
anomaly_layout.addLayout(anomaly_row)
lower = QGridLayout()
lower.setHorizontalSpacing(14)
lower.addWidget(detail, 0, 0)
lower.addWidget(anomaly, 0, 1)
lower.setColumnStretch(0, 674)
lower.setColumnStretch(1, 708)
layout.addLayout(lower)
recent, recent_layout = _compact_card("最近活动")
recent.setFixedHeight(136)
recent_row = QHBoxLayout()
for args in (("16:33:27", "扫描未读", "检测到客户未读消息", "success"), ("16:33:28", "提取聊天记录", "已读取最近 6 条消息", "success"), ("16:33:29", "AI 服务", "正在生成回复", "blue"), ("16:33:31", "人工保护", "检测到鼠标操作,暂停 5 秒", "warning")):
activity = _activity_tile(*args)
activity.setFixedHeight(83)
recent_row.addWidget(activity)
recent_action = _button("查看完整记录 ")
recent_action.setFixedSize(156, 52)
recent_action.clicked.connect(self._show_full_log)
recent_row.addWidget(recent_action)
recent_layout.addLayout(recent_row)
layout.addWidget(recent)
self._warning_count = 0
self._timeout_count = 0
self._window_lost_count = 0
self._send_fail_count = 0
self._event_history: list[dict] = []
def _toggle_live_refresh(self) -> None:
self.live_refresh = not self.live_refresh
self.live_chip.setText("⟳ 实时刷新" if self.live_refresh else "▶ 恢复刷新")
def _show_anomalies(self) -> None:
self.event_search.clear()
for button in self.event_filter_buttons:
if button.text() == "警告":
button.setChecked(True)
break
self._set_event_filter("警告")
def _visible_log_text(self) -> str:
stored = self.editor.toPlainText().strip()
if stored:
return stored
lines = []
for row in self.event_rows:
if getattr(row, "event_empty", False):
continue
lines.append(
f"[{row.time_label.text()}] {row.title_label.text()}"
f"{row.detail_label.text()}{row.status_label.text()}"
)
return "\n".join(lines)
def _show_full_log(self) -> None:
dialog = QDialog(self)
dialog.setObjectName("SessionDetailDialog")
dialog.setWindowTitle("完整运行记录")
dialog.resize(820, 560)
dialog_layout = QVBoxLayout(dialog)
dialog_layout.setContentsMargins(22, 20, 22, 20)
dialog_layout.setSpacing(12)
viewer = QTextEdit()
viewer.setReadOnly(True)
viewer.setPlainText(self._visible_log_text() or "当前没有运行记录。")
dialog_layout.addWidget(viewer, 1)
actions = QHBoxLayout()
actions.addStretch(1)
close = _button("关闭")
close.clicked.connect(dialog.accept)
actions.addWidget(close)
dialog_layout.addLayout(actions)
dialog.exec()
def _set_event_filter(self, category: str) -> None:
self.active_event_filter = category
self._apply_event_filter()
def _apply_event_filter(self) -> None:
category = getattr(self, "active_event_filter", "全部")
needle = self.event_search.text().strip().casefold()
for row in self.event_rows:
category_match = category == "全部" or row.event_category == category
haystack = f"{row.title_label.text()} {row.detail_label.text()} {row.status_label.text()}".casefold()
row.setVisible(not getattr(row, "event_empty", False) and category_match and (not needle or needle in haystack))
@staticmethod
def _event_icon(category: str, tone: str) -> str:
if category == "AI":
return "AI"
if tone == "warning":
return "!"
if category == "发送":
return "✈"
if category == "识别":
return "●"
return "◎"
def _assign_event_row(
self,
row: QFrame,
time_text: str,
title: str,
detail: str,
status: str,
tone: str,
category: str,
) -> None:
row.time_label.setText(time_text)
row.title_label.setText(title)
row.detail_label.setText(detail)
row.status_label.setText(status)
row.event_category = category
row.event_tone = tone
row.event_empty = not bool(time_text or title or detail or status)
row.badge.icon = self._event_icon(category, tone)
row.badge.setObjectName("StatusChip" if tone == "success" else "WarningChip" if tone == "warning" else "BlueChip")
row.marker.setStyleSheet(
"border:2px solid #FFFFFF;border-radius:7px;padding:0;color:#FFFFFF;"
"font-size:8px;font-weight:700;background:"
+ (COLORS["success"] if tone == "success" else COLORS["warning"] if tone == "warning" else COLORS["accent"])
+ ";"
)
row.status_label.setObjectName("StatusChip" if tone == "success" else "WarningChip" if tone == "warning" else "BlueChip")
for widget in (row.badge, row.status_label):
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
@staticmethod
def _classify_log(message: str, tag: str) -> tuple[str, str, str, str]:
text = str(message or "")
lowered = text.casefold()
if tag in {"warn", "err"} or any(token in text for token in ("失败", "错误", "异常")):
status, tone, fallback_cat = "警告", "warning", "警告"
elif tag == "ok":
status, tone, fallback_cat = "成功", "success", "发送"
elif tag == "notify":
status, tone, fallback_cat = "进行中", "blue", "AI"
else:
status, tone, fallback_cat = "信息", "blue", "连接"
if any(token in text for token in ("超时", "timeout")):
return "模型超时", status if tone == "warning" else "警告", "warning", "警告"
if any(token in text for token in ("窗口丢失", "找不到企业微信", "未检测到企业微信", "窗口已关闭")):
return "窗口丢失", "警告", "warning", "警告"
if any(token in text for token in ("发送失败", "粘贴失败", "回执")) and tone == "warning":
return "发送失败", "警告", "warning", "警告"
if any(token in text for token in ("未读", "扫描")):
return "未读扫描", status, tone, "识别"
if any(token in text for token in ("提取", "读取消息", "聊天记录")):
return "提取聊天记录", status, tone, "识别"
if any(token in text for token in ("鼠标", "人工操作", "人机共存")):
return "人工保护", "警告" if "暂停" in text else status, "warning" if "暂停" in text else tone, "警告"
if any(token in text for token in ("生成回复", "请求 AI", "模型", "Dify", "AI 服务")):
return "AI 服务", status, tone, "AI"
if any(token in text for token in ("已发送", "自动发送", "粘贴", "回填")):
return "自动发送", status, tone, "发送"
if any(token in text for token in ("连接", "监听", "企业微信")):
return "企业微信连接", status, tone, "连接"
if any(token in lowered for token in ("配置", "同步", "知识")):
return "AI 服务", status, tone, "AI"
return "系统事件", status, tone, fallback_cat
def _push_event(self, stamp: str, message: str, tag: str) -> None:
title, status, tone, category = self._classify_log(message, tag)
compact = " ".join(str(message).split())
if len(compact) > 80:
compact = compact[:79] + "…"
self._event_history.append(
{
"time": stamp,
"title": title,
"detail": compact,
"state": status,
"tone": "orange" if tone == "warning" else "green" if tone == "success" else "blue",
"icon": {
"AI": "brain",
"发送": "send",
"识别": "message",
"警告": "alert",
"连接": "activity",
}.get(category, "activity"),
"category": category,
}
)
self._event_history = self._event_history[-80:]
if "超时" in str(message) or "timeout" in str(message).casefold():
self._timeout_count += 1
if any(token in str(message) for token in ("窗口丢失", "找不到企业微信", "未检测到企业微信", "窗口已关闭")):
self._window_lost_count += 1
if any(token in str(message) for token in ("发送失败", "粘贴失败")):
self._send_fail_count += 1
if self.reference_preview or not self.live_refresh or not self.event_rows:
return
snapshots = [
(
row.time_label.text(),
row.title_label.text(),
row.detail_label.text(),
row.status_label.text(),
row.event_tone,
row.event_category,
)
for row in self.event_rows[1:]
]
for row, values in zip(self.event_rows[:-1], snapshots):
self._assign_event_row(row, *values)
short = compact if len(compact) <= 42 else compact[:41] + "…"
self._assign_event_row(self.event_rows[-1], stamp, title, short, status, tone, category)
self._apply_event_filter()
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>'
)
self._push_event(stamp, str(message), tag)
if tag in {"warn", "err"}:
self._warning_count += 1
self.warning_metric.value.setText(str(self._warning_count))
self.warning_value.setText(str(self._warning_count))
if tag == "ok":
self.response_metric.value.setText("已完成")
elif tag == "notify":
self.response_metric.value.setText("处理中")
def set_system_status(self, state: str, text: str) -> None:
if self.reference_preview:
return
active = state in {"running", "waiting", "connecting", "stopping"}
self.connection_metric.value.setText(text)
self.connection_value.setText("99.9%" if active else text)
wecom = self.health_rows["wecom"]
wecom.setText("正常" if state == "running" else text)
wecom.setObjectName("SuccessText" if active else "WarningText")
wecom.style().unpolish(wecom)
wecom.style().polish(wecom)
self.health_rows["ai"].setText("正常")
self.health_rows["vision"].setText("监控中" if active else "待监听")
def export(self) -> None:
suggested = SCRIPT_DIR / f"runtime-log-{time.strftime('%Y%m%d-%H%M%S')}.txt"
filename, _kind = QFileDialog.getSaveFileName(
self, "导出运行日志", str(suggested), "文本文件 (*.txt)"
)
if not filename:
return
try:
Path(filename).write_text(self._visible_log_text(), encoding="utf-8")
except OSError as exc:
QMessageBox.warning(self, "导出失败", str(exc))
return
QMessageBox.information(self, "导出完成", f"日志已保存:\n{filename}")
def clear(self) -> None:
if QMessageBox.question(
self,
"确认清空",
"确定清空当前运行日志吗?此操作不会删除会话归档。",
) != QMessageBox.Yes:
return
self.editor.clear()
self._event_history = []
self._timeout_count = 0
self._window_lost_count = 0
self._send_fail_count = 0
for row in self.event_rows:
self._assign_event_row(row, "", "", "", "", "blue", "")
self._apply_event_filter()
self._warning_count = 0
self.warning_metric.value.setText("0")
self.warning_value.setText("0")
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:'HarmonyOS Sans SC','Microsoft YaHei UI';"
"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 SidebarNavButton(QPushButton):
"""Reference-matched sidebar item with font-independent line icons."""
def __init__(self, icon_kind: str, label: str, parent: QWidget | None = None):
super().__init__("", parent)
self.icon_kind = icon_kind
self.label = label
self.setObjectName("NavButton")
self.setCheckable(True)
self.setAutoExclusive(True)
self.setCursor(Qt.PointingHandCursor)
self.setFixedHeight(52 if not label else 77)
@staticmethod
def _line(painter: QPainter, *points: tuple[float, float]) -> None:
for start, end in zip(points, points[1:]):
painter.drawLine(QPointF(*start), QPointF(*end))
def _draw_symbol(self, painter: QPainter, cx: float, cy: float, color: QColor) -> None:
pen = QPen(color, 1.8)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
kind = self.icon_kind
if kind == "pulse":
self._line(painter, (cx - 12, cy), (cx - 7, cy), (cx - 4, cy - 8), (cx + 1, cy + 8), (cx + 5, cy - 3), (cx + 8, cy), (cx + 13, cy))
elif kind == "robot":
painter.drawRoundedRect(QRect(int(cx - 11), int(cy - 8), 22, 17), 5, 5)
painter.drawLine(QPointF(cx, cy - 13), QPointF(cx, cy - 8))
painter.drawEllipse(QPointF(cx, cy - 14), 1.5, 1.5)
painter.drawEllipse(QPointF(cx - 5, cy), 1.5, 1.5)
painter.drawEllipse(QPointF(cx + 5, cy), 1.5, 1.5)
painter.drawLine(QPointF(cx - 13, cy - 2), QPointF(cx - 11, cy - 2))
painter.drawLine(QPointF(cx + 11, cy - 2), QPointF(cx + 13, cy - 2))
elif kind == "queue":
for offset in (-8, 0, 8):
painter.drawEllipse(QPointF(cx - 9, cy + offset), 1.7, 1.7)
painter.drawLine(QPointF(cx - 3, cy + offset), QPointF(cx + 11, cy + offset))
elif kind == "folder":
path = QPainterPath(QPointF(cx - 12, cy - 7))
path.lineTo(cx - 3, cy - 7)
path.lineTo(cx, cy - 3)
path.lineTo(cx + 12, cy - 3)
path.lineTo(cx + 12, cy + 9)
path.lineTo(cx - 12, cy + 9)
path.closeSubpath()
painter.drawPath(path)
elif kind == "ai":
painter.drawEllipse(QPointF(cx - 5, cy), 7, 10)
painter.drawEllipse(QPointF(cx + 5, cy), 7, 10)
painter.drawLine(QPointF(cx, cy - 10), QPointF(cx, cy + 10))
painter.drawLine(QPointF(cx - 9, cy - 4), QPointF(cx - 3, cy - 4))
painter.drawLine(QPointF(cx + 3, cy + 4), QPointF(cx + 9, cy + 4))
elif kind in {"gear", "system"}:
painter.drawEllipse(QPointF(cx, cy), 6, 6)
painter.drawEllipse(QPointF(cx, cy), 2, 2)
for angle in range(0, 360, 45):
rad = math.radians(angle)
painter.drawLine(
QPointF(cx + math.cos(rad) * 8, cy + math.sin(rad) * 8),
QPointF(cx + math.cos(rad) * 12, cy + math.sin(rad) * 12),
)
elif kind == "log":
painter.drawRoundedRect(QRect(int(cx - 9), int(cy - 12), 18, 24), 2, 2)
painter.drawLine(QPointF(cx - 4, cy - 5), QPointF(cx + 5, cy - 5))
painter.drawLine(QPointF(cx - 4, cy), QPointF(cx + 5, cy))
painter.drawLine(QPointF(cx - 4, cy + 5), QPointF(cx + 2, cy + 5))
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
system_active = self.isChecked() and self.icon_kind == "system"
s = _UI_SCALE
cx, cy = self.width() / 2.0, (24.0 * s) if self.label else self.height() / 2.0
painter.save()
painter.translate(cx, cy)
painter.scale(s, s)
painter.translate(-cx, -cy)
if self.isChecked():
bloom = QRadialGradient(QPointF(cx, cy), 34)
bloom.setColorAt(0.0, QColor(255, 255, 255, 230))
bloom.setColorAt(0.28, QColor("#5B86FF"))
bloom.setColorAt(0.58, QColor("#7A5EF6"))
bloom.setColorAt(1.0, QColor(122, 94, 246, 0))
painter.setPen(Qt.NoPen)
painter.setBrush(bloom)
painter.drawEllipse(QPointF(cx, cy), 28, 28)
core = QRadialGradient(QPointF(cx - 4, cy - 5), 22)
core.setColorAt(0.0, QColor("#FFFFFF"))
core.setColorAt(0.45, QColor("#6F8CFF"))
core.setColorAt(1.0, QColor("#5B4EE8"))
painter.setBrush(core)
painter.setPen(QPen(QColor(255, 255, 255, 170), 1))
painter.drawEllipse(QPointF(cx, cy), 18, 18)
icon_color = QColor("#FFFFFF")
text_color = QColor("#2F62F0")
else:
icon_color = QColor("#5A6A94")
text_color = QColor("#6A789C")
self._draw_symbol(painter, cx, cy, icon_color)
painter.restore()
if not self.label:
return
label_top = int(self.height() * 0.58)
label_rect = QRect(
2,
label_top,
max(8, self.width() - 4),
max(ui_px(18), self.height() - label_top - 2),
)
if system_active:
pill = label_rect.adjusted(ui_px(2), 0, -ui_px(2), -ui_px(1))
gradient = QLinearGradient(pill.topLeft(), pill.topRight())
gradient.setColorAt(0.0, QColor("#2EC6F4"))
gradient.setColorAt(0.48, QColor("#4A72FF"))
gradient.setColorAt(1.0, QColor("#8B5CF6"))
painter.setPen(Qt.NoPen)
painter.setBrush(gradient)
painter.drawRoundedRect(pill, ui_px(11), ui_px(11))
painter.setPen(QColor("#FFFFFF"))
painter.setFont(_paint_font(max(10, label_rect.height() * 0.48), QFont.Weight.DemiBold))
painter.drawText(pill, Qt.AlignCenter, self.label)
return
painter.setPen(text_color)
weight = QFont.Weight.DemiBold if self.isChecked() else QFont.Weight.Normal
painter.setFont(_paint_font(max(11, label_rect.height() * 0.50), weight))
painter.drawText(label_rect, Qt.AlignHCenter | Qt.AlignVCenter, self.label)
class SidebarStatusBlock(QFrame):
"""侧栏底部的状态区:发光球和它下面的文字算同一个按钮。
过去只有那颗球本身能点,"已停止"三个字和周围一圈留白都是死的。用户点的
往往正是那几个字——看上去就是"这个按钮没反应"。整块一起接管点击。
"""
clicked = Signal()
def __init__(self):
super().__init__()
self.setObjectName("SidebarStatusBlock")
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(
"QFrame#SidebarStatusBlock{background:transparent;border:none;}"
)
def mousePressEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
self.clicked.emit()
event.accept()
return
super().mousePressEvent(event)
class Sidebar(QFrame):
pageRequested = Signal(int)
startRequested = Signal()
stopRequested = Signal()
PAGE_NAMES = (
("robot", "自动回复"),
("queue", "任务队列"),
("folder", "会话归档"),
("ai", "AI 设置"),
("gear", "自动化设置"),
("log", "运行日志"),
("system", "系统设置"),
)
def __init__(self):
super().__init__()
self.reference_preview = "--qt-smoke-test" in sys.argv
self.setObjectName("Sidebar")
self.setFixedWidth(100)
_shadow(self, blur=40, y=10, alpha=24)
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 22, 6, 10)
layout.setSpacing(3)
brand = _label("甄", "Brand")
brand.setAlignment(Qt.AlignCenter)
brand.setFixedSize(46, 46)
layout.addWidget(brand, 0, Qt.AlignHCenter)
layout.addSpacing(16)
pulse = SidebarNavButton("pulse", "")
pulse.setCheckable(False)
pulse.setCursor(Qt.ArrowCursor)
pulse.setToolTip("自动回复服务")
layout.addWidget(pulse)
self.nav_buttons: list[QPushButton] = []
for index, (icon_kind, name) in enumerate(self.PAGE_NAMES[:6]):
button = SidebarNavButton(icon_kind, name)
button.clicked.connect(lambda _checked=False, i=index: self.pageRequested.emit(i))
layout.addWidget(button)
self.nav_buttons.append(button)
self.nav_buttons[0].setChecked(True)
layout.addStretch(1)
self._monitor_active = False
self.status_block = SidebarStatusBlock()
status_layout = QVBoxLayout(self.status_block)
status_layout.setContentsMargins(0, 0, 0, 0)
status_layout.setSpacing(3)
self.monitor_button = MonitorOrb(64)
# 球自己也接管点击,但转交给整块的同一个处理函数,保证两处行为一致
self.monitor_button.clicked.connect(self._toggle_monitor)
status_layout.addWidget(self.monitor_button, 0, Qt.AlignHCenter)
self.status_title = _label("监听中" if self.reference_preview else "已停止", "SideStatusTitle")
self.status_title.setAlignment(Qt.AlignCenter)
self.status_hint = _label("等待连接", "SideStatusHint")
self.status_hint.setAlignment(Qt.AlignCenter)
self.status_hint.hide()
status_layout.addWidget(self.status_title)
status_layout.addWidget(self.status_hint)
self.status_block.clicked.connect(self._toggle_monitor)
self.status_block.setToolTip("点击开始监听")
layout.addWidget(self.status_block, 0, Qt.AlignHCenter)
layout.addSpacing(20)
system_index = len(self.PAGE_NAMES) - 1
icon_kind, name = self.PAGE_NAMES[system_index]
system_button = SidebarNavButton(icon_kind, name)
system_button.clicked.connect(lambda _checked=False, i=system_index: self.pageRequested.emit(i))
layout.addWidget(system_button)
self.nav_buttons.append(system_button)
def _toggle_monitor(self) -> None:
"""状态区被点了:在跑就暂停,没跑就开始。"""
if self._monitor_active:
self.stopRequested.emit()
else:
self.startRequested.emit()
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:
if self.reference_preview:
self.status_title.setText("监听中")
self.status_title.setStyleSheet("color:#12A98A;font-size:12px;font-weight:600;")
self._monitor_active = True
if hasattr(self.monitor_button, "set_active"):
self.monitor_button.set_active(True)
return
colors = {
"running": "#43E1AE",
"waiting": "#F2BC64",
"connecting": "#F2BC64",
"stopping": "#F2BC64",
"error": "#FF818E",
"verification": "#FF818E",
"stopped": "#A5B9B0",
}
self.status_title.setText(text)
self.status_title.setStyleSheet(
f"color:{colors.get(state, '#A5B9B0')};font-size:12px;font-weight:600;"
)
short_hints = {
"running": "后台监控中",
"waiting": "等待企业微信",
"connecting": "正在连接",
"stopping": "正在停止",
"error": "请查看日志",
"verification": "需要扫码",
"stopped": "等待连接",
}
self.status_hint.setText(short_hints.get(state, hint))
active = state in {"running", "waiting", "connecting", "stopping"}
self.monitor_button.setText("")
if hasattr(self.monitor_button, "set_active"):
self.monitor_button.set_active(active)
# 过去这里每次都 disconnect 全部再 connect 一个信号。少一次断开就会同时
# 挂着"开始"和"停止",点一下自相抵消——按钮看上去时灵时不灵。改成只记
# 一个状态位,连接建一次就不动了。
self._monitor_active = active
self.status_block.setToolTip("点击暂停监听" if active else "点击开始监听")
CONSOLE_VIEWS = ("auto", "queue", "archive", "ai", "automation", "logs", "system")
CONSOLE_HTML = resource_path("assets", "ui", "zhenyang-ai-console.html")
class ConsoleBridge(QObject):
"""Qt WebChannel API used by the HTML control-console shell."""
stateChanged = Signal(str)
def __init__(self, host: "MainWindow"):
super().__init__(host)
self.host = host
@Slot()
def requestState(self) -> None:
# ``requestState`` is the first call made after JavaScript has connected
# its stateChanged listener. Do not regard an earlier loadFinished as a
# usable channel: an emit in that gap is silently lost by WebChannel.
shell = getattr(self.host, "console_shell", None)
if shell is not None:
shell.channel_ready = True
self.host._push_console_state(force=True)
@Slot(str)
def navigate(self, view: str) -> None:
self.host._console_navigate(view)
@Slot()
def toggleListen(self) -> None:
if self.host._running:
self.host.stop_monitoring()
else:
self.host.start_monitoring()
self.host._push_console_state()
@Slot()
def startListen(self) -> None:
# The blue header control is explicitly labelled “自动发送”. Treat
# clicking it as an intentional mode choice before starting, rather
# than merely starting a listener that could still be in review mode.
if str(self.host.runtime_settings.get("send_mode") or SEND_MODE_AUTO) != SEND_MODE_AUTO:
page = self.host.settings_page
page.auto_send_mode.setChecked(True)
page.review_send_mode.setChecked(False)
page._emit_save()
if not self.host._running:
self.host.start_monitoring()
self.host._push_console_state()
@Slot()
def refresh(self) -> None:
if getattr(self.host, "_console_view", "auto") == "logs":
self.host.log_page._apply_event_filter()
else:
self.host.queue_page.refresh_data()
self.host.business_page.refresh_data()
self.host._push_console_state(force=True)
@Slot()
def clearLogs(self) -> None:
self.host.log_page.clear()
self.host._push_console_state()
@Slot(str)
def cancelTask(self, key: str) -> None:
if not self.host._console_select_task(key):
self.host.append_log("所选任务已离开队列,未执行取消。", "warn")
self.host._push_console_state(force=True)
return
self.host.queue_page._request_delete_selected()
self.host._schedule_console_push(urgent=True)
@Slot(str)
def retryTask(self, key: str) -> None:
if not self.host._console_select_task(key):
self.host.append_log("所选任务已离开队列,未执行重试。", "warn")
self.host._push_console_state(force=True)
return
self.host.queue_page._request_retry_selected()
self.host._schedule_console_push(urgent=True)
@Slot(str)
def handoffTask(self, key: str) -> None:
if not self.host._console_select_task(key):
self.host.append_log("所选任务已离开队列,未执行转人工。", "warn")
self.host._push_console_state(force=True)
return
self.host.queue_page._request_handoff_selected()
self.host._schedule_console_push(urgent=True)
@Slot(str)
def approveTask(self, key: str) -> None:
"""审核通过:放行这条草稿,让它按正常发送路径发出去。"""
if not self.host._console_select_task(key):
self.host.append_log("所选任务已离开队列,未执行放行。", "warn")
self.host._push_console_state(force=True)
return
self.host._approve_queue_tasks([key])
@Slot(str)
def selectTask(self, key: str) -> None:
self.host._console_select_task(key)
self.host._push_console_state()
@Slot(str)
def selectArchive(self, key: str) -> None:
wanted = str(key or "").strip()
if not self.host._select_archive_session(wanted):
self.host._console_selected_archive = ""
self.host.append_log("所选会话已不在归档中,已刷新会话列表。", "warn")
self.host.business_page.refresh_data()
self.host._push_console_state(force=True)
return
self.host._console_selected_archive = wanted
self.host._push_console_state(force=True)
@Slot(str)
def setQueueFilter(self, name: str) -> None:
self.host.queue_page._set_task_filter(name or "全部")
self.host._push_console_state()
@Slot(str)
def setArchiveFilter(self, name: str) -> None:
label = str(name or "全部")
if label.startswith("全部"):
label = "全部"
if label != "日期":
self.host.business_page.archive_date = ""
self.host.business_page._set_archive_filter(label)
self.host._push_console_state()
@Slot()
def chooseArchiveDate(self) -> None:
page = self.host.business_page
current = str(getattr(page, "archive_date", "") or time.strftime("%Y-%m-%d"))
value, accepted = QInputDialog.getText(
self.host,
"按日期筛选会话",
"日期(YYYY-MM-DD,留空清除):",
text=current,
)
if not accepted:
return
normalized = str(value or "").strip()
if normalized and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", normalized):
QMessageBox.warning(self.host, "日期格式不正确", "请输入 YYYY-MM-DD 格式的日期。")
return
page._set_archive_date(normalized)
self.host._push_console_state(force=True)
@Slot(str)
def setArchiveQuery(self, text: str) -> None:
self.host.business_page.archive_search.setText(text)
self.host._push_console_state()
@Slot(str)
def setEventFilter(self, name: str) -> None:
self.host.log_page._set_event_filter(name or "全部")
self.host._push_console_state()
@Slot(str)
def setEventQuery(self, text: str) -> None:
self.host.log_page.event_search.setText(text)
self.host._push_console_state()
@Slot(str)
def copyText(self, text: str) -> None:
if text:
QApplication.clipboard().setText(text)
@Slot()
def copyArchive(self) -> None:
self.host.business_page._copy_selected_records()
@Slot()
def exportArchive(self) -> None:
self.host.business_page._export_records()
@Slot()
def exportLogs(self) -> None:
self.host.log_page.export()
@Slot()
def showFullLog(self) -> None:
self.host.log_page._show_full_log()
@Slot(str, result=str)
def saveAi(self, payload: str) -> str:
try:
data = json.loads(payload or "{}")
except json.JSONDecodeError:
data = {}
page = self.host.persona_page
name = str(data.get("name") or "").strip()
if name:
page.agent_name.setText(name[:40])
# The visible summary is deliberately read-only: the runtime system
# prompt is assembled by ai_config's safety template and must not be
# replaced by arbitrary HTML-shell text.
# 温度、最大 tokens、上下文轮数、MCP 轮数都**不再从这里接收**。
#
# 前三项搬去了后台的「模型清单 / 角色编排」,后两项随后台配置下发。真按
# 客户端提交的值写进去,界面会提示"已保存并生效",而下一次同步(每 5 分钟
# 一次)就把它们盖回去——改了、提示成功了、什么都没发生。这种"看起来生效
# 了其实没有"比直接说明"这里改不了"难查得多。
#
# 客户端也已经不再提交它们(见 console-app.js 的 collectAiPayload);这里
# 一并忽略,是为了挡住旧版本客户端和手工调接口的情况。
saved = bool(page.save_config())
self.host._console_ai_synced = saved
self.host._push_console_state()
# 把结果回给控制台。以前这个槽什么都不返回,网页那边点完「保存并发布」
# 界面一动不动——存成功没提示,存失败更没提示,只能等下一次同步才发现
# 改动根本没生效。
return json.dumps(
{"ok": saved,
"message": "客服人格已保存并发布" if saved else "保存失败,请查看运行日志"},
ensure_ascii=False,
)
@Slot()
def testAi(self) -> None:
self.host.persona_page._test_ai()
@Slot()
def pickModel(self) -> None:
page = self.host.persona_page
previous = page.selected_model_name
page._open_model_selector()
if page.selected_model_name != previous:
self.host._console_ai_synced = False
self.host._push_console_state()
@Slot()
def pickBackupModel(self) -> None:
self.host.persona_page._show_backup_model_policy(0)
self.host._push_console_state()
@Slot()
def editKnowledge(self) -> None:
page = self.host.persona_page
text, accepted = QInputDialog.getMultiLineText(
self.host,
"管理知识",
"MCP 服务器 JSON(数组):",
page.mcp_json.toPlainText(),
)
if not accepted:
return
page.mcp_json.setPlainText(text)
self.host._console_ai_synced = bool(page.save_config())
self.host._push_console_state()
@Slot()
def syncKnowledge(self) -> None:
self.host.persona_page.sync_from_cloud(silent=False)
@Slot()
def openDataDir(self) -> None:
self.host.system_page._open_data_dir()
@Slot()
def saveAutomation(self) -> None:
self.host.settings_page._emit_save()
self.host._push_console_state(force=True)
@Slot(str)
def setSendMode(self, mode: str) -> None:
auto = str(mode or "auto") != "review"
self.host.settings_page.auto_send_mode.setChecked(auto)
self.host.settings_page.review_send_mode.setChecked(not auto)
self.host.settings_page._emit_save()
self.host._push_console_state()
@Slot()
def toggleIdle(self) -> None:
box = self.host.settings_page.mouse_idle
box.setChecked(not box.isChecked())
self.host.settings_page._emit_save()
self.host._push_console_state(force=True)
@Slot()
def redetectWecom(self) -> None:
self.host.settings_page._redetect_wecom_window()
self.host._push_console_state(force=True)
@Slot()
def showSecurityRules(self) -> None:
self.host._show_security_rules()
@Slot()
def saveSystem(self) -> None:
self.host.system_page._save()
self.host._push_console_state(force=True)
@Slot(str)
def toggleSetting(self, key: str) -> None:
mapping = {
"auto_launch": self.host.system_page.auto_launch,
"auto_monitor": self.host.system_page.auto_monitor,
"minimize_on_close": self.host.system_page.minimize_on_close,
"keep_background": self.host.system_page.keep_background,
"notify_send_failed": getattr(self.host.system_page, "notify_send_failed", None),
"notify_timeout": getattr(self.host.system_page, "notify_timeout", None),
"notify_manual": getattr(self.host.system_page, "notify_manual", None),
"notify_disconnect": getattr(self.host.system_page, "notify_disconnect", None),
"mask_sensitive": getattr(self.host.system_page, "mask_sensitive", None),
"encrypt_local": getattr(self.host.system_page, "encrypt_local", None),
"hide_chat_in_logs": getattr(self.host.system_page, "hide_chat_in_logs", None),
"clear_clipboard_on_exit": getattr(self.host.system_page, "clear_clipboard_on_exit", None),
}
widget = mapping.get(str(key or ""))
if widget is not None:
widget.setChecked(not widget.isChecked())
self.host.system_page._save()
self.host._push_console_state()
@Slot()
def checkUpdate(self) -> None:
self.host.system_page._check_updates()
self.host._push_console_state(force=True)
@Slot()
def backupData(self) -> None:
self.host.system_page._backup_now()
self.host._push_console_state()
@Slot()
def clearCache(self) -> None:
self.host.system_page._clear_cache()
self.host._console_storage = None
self.host._push_console_state()
@Slot()
def resetSettings(self) -> None:
self.host.system_page._restore_defaults()
self.host._push_console_state()
@Slot()
def showLicense(self) -> None:
QMessageBox.information(self.host, "许可信息", f"{WIN_TITLE}\n本地桌面端许可。")
@Slot(str)
def toggleTool(self, index: str) -> None:
switches = getattr(self.host.persona_page, "tool_switches", [])
try:
pos = int(index)
except (TypeError, ValueError):
return
if 0 <= pos < len(switches):
switches[pos].setChecked(not switches[pos].isChecked())
self.host._console_ai_synced = bool(self.host.persona_page.save_config())
self.host._push_console_state()
@Slot(str)
def setAiTab(self, name: str) -> None:
label = str(name or "基础设置")
if label not in {"基础设置", "知识与工具", "安全策略"}:
label = "基础设置"
self.host._console_ai_tab = label
buttons = getattr(self.host.persona_page, "persona_tab_buttons", [])
mapping = {"基础设置": 0, "知识与工具": 1, "安全策略": 2}
index = mapping.get(label, 0)
if index < len(buttons):
buttons[index].setChecked(True)
self.host._push_console_state()
@Slot(str)
def setArchiveDate(self, value: str) -> None:
self.host.business_page._set_archive_date(value)
self.host._push_console_state()
@Slot()
def editReply(self) -> None:
current = self.host.settings_page.reply.text()
text, accepted = QInputDialog.getText(
self.host, "固定回复", "AI 不可用时使用的兜底回复:", QLineEdit.EchoMode.Normal, current
)
if not accepted:
return
self.host.settings_page.reply.setReadOnly(False)
self.host.settings_page.reply.setText(str(text or "").strip()[:120])
self.host.settings_page._emit_save()
self.host._push_console_state()
@Slot(str, result=str)
def saveAutomationSettings(self, payload: str) -> str:
try:
data = json.loads(payload or "{}")
except json.JSONDecodeError:
data = {}
page = self.host.settings_page
mapping = {
"poll": page.poll,
"batch": page.batch_window,
"delay": page.send_delay,
"idle": page.idle_seconds,
}
for key, widget in mapping.items():
if key not in data:
continue
try:
widget.setValue(float(data[key]))
except (TypeError, ValueError):
pass
if "context" in data:
try:
self.host.persona_page.rounds.setValue(int(float(data["context"])))
self.host.persona_page.save_config()
except (TypeError, ValueError):
pass
if "draftAutosend" in data:
# 0 = 关闭。开关和分钟数在原生页面上是两个控件,这里按同一套规则还原,
# 免得控制台存的和设置页显示的对不上。
try:
minutes = float(data["draftAutosend"])
except (TypeError, ValueError):
minutes = 0.0
page.draft_autosend_enabled.setChecked(minutes > 0)
if minutes > 0:
page.draft_autosend.setValue(minutes)
page._emit_save()
self.host._push_console_state()
return json.dumps(
{"ok": True, "message": "自动化设置已保存并生效"}, ensure_ascii=False
)
@Slot(str, result=str)
def setSystemField(self, payload: str) -> str:
try:
data = json.loads(payload or "{}")
except json.JSONDecodeError:
data = {}
page = self.host.system_page
language = str(data.get("language") or "").strip()
if language:
page.interface_language.setCurrentText(language)
scale = str(data.get("scale") or "").strip()
if scale:
page.scale_ratio.setCurrentText(scale)
if "retention" in data:
try:
page.log_retention.setValue(int(data["retention"]))
except (TypeError, ValueError):
pass
page._save()
self.host._push_console_state()
return json.dumps(
{"ok": True, "message": "系统设置已保存"}, ensure_ascii=False
)
@Slot()
def testConnection(self) -> None:
self.host.system_page._test_connection()
self.host._push_console_state()
@Slot(str)
def connectBackend(self, url: str) -> None:
"""控制台上填的后台地址:存下来,立刻同步一次,把结果说清楚。
这是桌面端唯一需要人填的东西。模型接口、密钥、网关地址、角色编排全部由
后台下发——填对这一个,其余自动到位。
"""
result = self.host.system_page._connect_backend(url)
if result.get("ok"):
self.host.append_log(
f"后台已连接:{result.get('server_url')}|配置 v{result.get('version')}"
f"|网关 {result.get('gateway_url') or '未下发'}",
"ok",
)
else:
self.host.append_log(f"后台连接失败:{result.get('message', '')}", "err")
self.host._push_console_state(force=True)
@Slot()
def exportDiagnostics(self) -> None:
self.host.system_page._export_diagnostics()
@Slot()
def cycleQueueFilter(self) -> None:
order = ("全部", "处理中", "等待中", "待重试")
current = getattr(self.host.queue_page, "active_task_filter", "全部") or "全部"
index = order.index(current) + 1 if current in order else 0
self.host.queue_page._set_task_filter(order[index % len(order)])
self.host._push_console_state()
@Slot()
def cycleQueueSort(self) -> None:
order = ("queue", "wait", "name")
current = getattr(self.host, "_console_queue_sort", "queue") or "queue"
index = order.index(current) + 1 if current in order else 0
self.host._console_queue_sort = order[index % len(order)]
self.host._push_console_state()
class ConsoleShell(QWebEngineView):
"""HTML control console loaded as the visible desktop shell."""
def __init__(self, host: "MainWindow"):
super().__init__(host)
self.host = host
self.page_ready = False
self.channel_ready = False
self._desired_view = getattr(host, "_console_view", "auto")
self._applied_view = None
self._view_command_serial = 0
self._last_state = {}
self.setObjectName("ConsoleShell")
self.setContextMenuPolicy(Qt.NoContextMenu)
self.setStyleSheet("background:#F4F8FF;")
self.page().setBackgroundColor(QColor("#F4F8FF"))
settings = self.settings()
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.ScrollAnimatorEnabled, False)
settings.setAttribute(QWebEngineSettings.WebAttribute.ShowScrollBars, False)
self.bridge = ConsoleBridge(host)
self.channel = QWebChannel(self)
self.channel.registerObject("bridge", self.bridge)
self.page().setWebChannel(self.channel)
self.loadStarted.connect(self._on_load_started)
self.loadFinished.connect(self._on_loaded)
html = CONSOLE_HTML if CONSOLE_HTML.is_file() else Path(__file__).with_name("assets") / "ui" / "zhenyang-ai-console.html"
if html.is_file():
url = QUrl.fromLocalFile(str(html.resolve()))
try:
url.setQuery(f"v={int(html.stat().st_mtime)}")
except OSError:
pass
self.load(url)
else:
self.setHtml(
"<html><body style='font-family:sans-serif;padding:40px'>"
"找不到控制台页面 assets/ui/zhenyang-ai-console.html</body></html>"
)
def _on_load_started(self) -> None:
self.page_ready = False
self.channel_ready = False
self._applied_view = None
self._view_command_serial += 1
self._last_state = {}
def _on_loaded(self, ok: bool) -> None:
self.page_ready = bool(ok)
if not ok:
return
self.page().runJavaScript(
"document.documentElement.classList.add('embedded');"
"if (window.fit) fit();"
)
# The document starts on ``auto``. Replay a Python-side navigation
# that happened while the page was loading, without treating a dropped
# pre-load JavaScript command as already applied.
desired = self._desired_view
if desired == "auto":
self._applied_view = "auto"
else:
self.set_view(desired)
# A very fast WebChannel handshake can arrive just before
# loadFinished. Its forced state push is deliberately deferred by the
# host; finish that one pending delivery now. In the usual ordering,
# requestState itself performs the only initial push.
if self.channel_ready and getattr(self.host, "_console_state_pending", False):
QTimer.singleShot(0, lambda: self.host._push_console_state(force=True))
def remember_view(self, name: str) -> None:
"""Record a view that the loaded HTML has already rendered itself."""
normalized = name if name in CONSOLE_VIEWS else "auto"
self._desired_view = normalized
if self.page_ready:
self._applied_view = normalized
def set_view(self, name: str) -> None:
name = name if name in CONSOLE_VIEWS else "auto"
self._desired_view = name
if not self.page_ready or self._applied_view == name:
return
self._view_command_serial += 1
serial = self._view_command_serial
def mark_applied(_result=None) -> None:
if (
self.page_ready
and serial == self._view_command_serial
and self._desired_view == name
):
self._applied_view = name
self.page().runJavaScript(
f"window.showConsoleView && showConsoleView({json.dumps(name)})",
mark_applied,
)
def set_state(self, payload: str) -> bool:
"""Deliver state only after both the document and WebChannel are ready."""
if not self.page_ready or not self.channel_ready:
return False
try:
state = json.loads(payload)
except (TypeError, ValueError):
state = {}
if not isinstance(state, dict):
state = {}
# The logs page receives queue/runtime snapshots while its event stream
# is active. Most of those fields are irrelevant to its structure, but
# the shared JavaScript core fingerprint includes exact exception and
# task objects. Applying such a payload normally would replace the
# whole #main subtree and interrupt hover/click state. For an unchanged
# logs-page shell, update only its live regions and then synchronize the
# JS fingerprints. Navigation, filters, and health/header transitions
# still go through the ordinary authoritative stateChanged path.
previous = self._last_state if isinstance(self._last_state, dict) else {}
log_structure_keys = (
"view",
"demo",
"listening",
"statusLabel",
"wecomConnected",
"aiOk",
"eventFilter",
"eventQuery",
)
patch_logs = (
previous.get("view") == "logs"
and state.get("view") == "logs"
and all(previous.get(key) == state.get(key) for key in log_structure_keys)
)
self._last_state = state
if patch_logs:
encoded = json.dumps(payload, ensure_ascii=False)
self.page().runJavaScript(
"""
(() => {
if (!window.STATE || typeof window.applyState !== "function") return;
const incoming = JSON.parse(%s);
Object.assign(window.STATE, incoming);
window.STATE.ready = true;
if (typeof incoming.listening === "boolean") listening = incoming.listening;
if (typeof patchVolatile === "function") patchVolatile();
if (typeof logsFingerprint === "function") {
const nextLogs = logsFingerprint(window.STATE);
if (nextLogs !== window.__logsFp && typeof patchLogs === "function") {
window.__logsFp = nextLogs;
patchLogs();
}
}
const detail = window.STATE.logDetail || {};
const detailBox = document.querySelector(
".log-mid > section:first-child .row > div:nth-child(2)"
);
if (detailBox) {
const title = detailBox.querySelector("b");
const text = detailBox.querySelector("p");
const tags = detailBox.querySelector(".toolbar");
if (title) title.textContent = detail.title || "等待事件";
if (text) text.textContent = detail.text || "";
if (tags) {
tags.replaceChildren(...(detail.tags || []).map(value => {
const tag = document.createElement("span");
tag.className = "badge";
tag.textContent = value;
return tag;
}));
}
}
const exceptions = window.STATE.exceptions || {};
const exceptionValues = [
exceptions.sendFail || 0,
exceptions.timeout || 0,
exceptions.windowLost || 0,
];
document.querySelectorAll(".exception-grid .exception b").forEach((node, index) => {
node.textContent = String(exceptionValues[index] || 0);
});
const warning = document.querySelector('[data-live-metric="warnings"]');
if (warning) {
const active = Number((window.STATE.metrics || {}).warnings || 0) > 0;
warning.classList.toggle("red", active);
const iconNode = warning.closest(".metric")?.querySelector(".metric-icon");
if (iconNode) iconNode.classList.toggle("red", active);
}
if (typeof normalizeReferenceView === "function") normalizeReferenceView();
if (typeof coreFingerprint === "function") {
window.__coreFp = coreFingerprint(window.STATE);
}
})();
""" % encoded
)
return True
self.bridge.stateChanged.emit(payload)
return True
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(WIN_TITLE)
self.setMinimumSize(980, 640)
self._set_initial_geometry()
sync_ui_scale(self.width(), self.height())
self._scale_timer = QTimer(self)
self._scale_timer.setSingleShot(True)
self._scale_timer.setInterval(16)
self._scale_timer.timeout.connect(self._apply_window_ui_scale)
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()
self._console_view = "auto"
self._console_selected_key = ""
self._console_selected_archive = ""
self._console_ai_tab = "基础设置"
self._console_queue_sort = "queue"
self._console_was_visible = False
self._console_storage = None
self._console_push_timer = QTimer(self)
self._console_push_timer.setSingleShot(True)
self._console_push_timer.setInterval(400)
self._console_push_timer.timeout.connect(self._push_console_state)
self._console_last_payload = ""
self._console_push_urgent = False
self._console_state_pending = True
root = AppCanvas()
self.setCentralWidget(root)
root_layout = QHBoxLayout(root)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
self._legacy_host = QWidget(self)
self._legacy_host.hide()
self._legacy_host.resize(1630, 920)
self._legacy_host.setAttribute(Qt.WA_TransparentForMouseEvents, True)
legacy_layout = QHBoxLayout(self._legacy_host)
legacy_layout.setContentsMargins(0, 0, 0, 0)
legacy_layout.setSpacing(0)
self.sidebar = Sidebar()
legacy_layout.addWidget(self.sidebar)
self.stack = FadingStack()
legacy_layout.addWidget(self.stack, 1)
self.portal_page = PortalPage()
self.dashboard_page = DashboardPage(self.runtime_settings)
self.settings_page = SettingsPage(self.runtime_settings)
self.business_page = BusinessPage()
self.persona_page = PersonaPage()
self.queue_page = QueuePage()
self.log_page = LogPage()
self.system_page = SystemSettingsPage(self.runtime_settings)
for page in (
self.portal_page,
self.queue_page,
self.business_page,
self.persona_page,
self.settings_page,
self.log_page,
self.system_page,
):
self.stack.addWidget(page)
self.console_shell = ConsoleShell(self)
root_layout.addWidget(self.console_shell, 1)
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.append_log("会话归档与客户记忆已加载。", "ok")
self.append_log("AI 服务配置已同步,等待自动回复任务。", "ok")
self.append_log("人工操作保护已开启。", "ok")
self.append_log("自动发送规则已生效,启动监听后开始扫描未读。", "notify")
if bool(self.runtime_settings.get("auto_monitor", False)):
QTimer.singleShot(1200, self.start_monitoring)
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, 1920, 1080)
# The supplied boards use a 1630 x 920 client area below the 45 px
# Windows title bar. Keep that exact composition whenever the screen
# can accommodate it, while still fitting smaller displays safely.
width = min(1630, max(1120, area.width() - 32))
height = min(920, max(720, area.height() - 32))
self.resize(width, height)
self.move(area.x() + (area.width() - width) // 2, area.y() + (area.height() - height) // 2)
def _apply_window_ui_scale(self) -> None:
sync_ui_scale(
self.width(),
self.height(),
getattr(self, "_legacy_host", None),
getattr(self, "capsule", None),
)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
if hasattr(self, "_scale_timer"):
self._scale_timer.start()
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.dashboard_page.settingsChanged.connect(self._on_dashboard_engine_settings)
self.settings_page.saved.connect(self.save_runtime_settings)
self.settings_page.securityRulesRequested.connect(self._show_security_rules)
self.system_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.queue_page.handoffTasksRequested.connect(self._handoff_queue_tasks)
self.queue_page.retryTasksRequested.connect(self._retry_queue_tasks)
self.queue_page.startRequested.connect(self.start_monitoring)
self.queue_page.stopRequested.connect(self.stop_monitoring)
self.queue_page.pageRequested.connect(self.show_page)
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,
"enable_engine_b": True,
"engine_b_poll_interval": 2.0,
"engine_a_enabled": True,
"engine_b_data_source": "parallel",
"mouse_idle_enabled": True,
"mouse_idle_seconds": 5.0,
"send_delay_seconds": SEND_DELAY_SECONDS,
"send_mode": SEND_MODE_AUTO,
"message_batch_window_seconds": MESSAGE_BATCH_WINDOW_SECONDS,
"foreign_draft_autosend_minutes": FOREIGN_DRAFT_AUTOSEND_MINUTES,
"auto_launch": False,
"auto_monitor": False,
"minimize_on_close": True,
"keep_background": True,
"interface_language": "简体中文",
"scale_ratio": "自动",
"log_retention_days": 90,
"mask_sensitive": True,
"encrypt_local": False,
"hide_chat_in_logs": False,
"clear_clipboard_on_exit": True,
"notify_send_failed": True,
"notify_timeout": True,
"notify_manual": True,
"notify_disconnect": True,
}
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["send_delay_seconds"] = normalize_send_delay_seconds(
settings["send_delay_seconds"]
)
settings["send_mode"] = normalize_send_mode(settings["send_mode"])
settings["message_batch_window_seconds"] = (
normalize_message_batch_window_seconds(
settings["message_batch_window_seconds"]
)
)
settings["foreign_draft_autosend_minutes"] = (
normalize_foreign_draft_autosend_minutes(
settings["foreign_draft_autosend_minutes"]
)
)
settings["mouse_idle_enabled"] = bool(settings["mouse_idle_enabled"])
settings["enable_engine_b"] = bool(settings.get("enable_engine_b", True))
settings["engine_a_enabled"] = bool(settings.get("engine_a_enabled", True))
mode = str(settings.get("engine_b_data_source", "parallel") or "parallel").lower()
if mode not in ("parallel", "db", "json"):
mode = "parallel"
settings["engine_b_data_source"] = mode
settings["auto_reply_text"] = (
str(settings["auto_reply_text"]).strip()
or "在的,您慢慢说,我这边看着呢。"
)
except (TypeError, ValueError):
return self._runtime_defaults()
return settings
def _on_dashboard_engine_settings(self, engine: dict) -> None:
"""主面板「检测引擎」卡片变化 → 合并进运行时设置并持久化。
引擎开关在运行中修改后,本轮监听结束后重启才完全生效
(BotThread 启动时读取配置);此处立即保存文件 + 更新内存。
"""
merged = dict(getattr(self, "runtime_settings", {}) or {})
merged.update(engine or {})
self.save_runtime_settings(merged)
def save_runtime_settings(self, settings: dict | None = None) -> None:
previous_auto_launch = bool(
getattr(self, "runtime_settings", {}).get("auto_launch", False)
)
merged = dict(self._runtime_defaults())
merged.update(getattr(self, "runtime_settings", {}) or {})
merged.update(settings or self.settings_page.values())
self.runtime_settings = merged
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._broadcast_save_result(False, f"保存失败:{exc}")
self.append_log(f"运行参数保存失败:{exc}", "err")
else:
if previous_auto_launch != bool(self.runtime_settings.get("auto_launch", False)):
self._sync_auto_launch(bool(self.runtime_settings.get("auto_launch", False)))
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"]
))
thread.set_send_delay_seconds(float(
self.runtime_settings["send_delay_seconds"]
))
thread.set_send_mode(self.runtime_settings["send_mode"])
thread.set_foreign_draft_autosend_minutes(float(
self.runtime_settings["foreign_draft_autosend_minutes"]
))
self._broadcast_save_result(True, "设置已保存并生效")
def _broadcast_save_result(self, ok: bool, message: str) -> None:
"""把保存结果同时告诉所有共用这份运行参数的页面。"""
for page in (
getattr(self, "settings_page", None),
getattr(self, "system_page", None),
):
marker = getattr(page, "mark_saved", None)
if callable(marker):
try:
marker(ok, message)
except Exception:
pass
def _sync_auto_launch(self, enabled: bool) -> None:
"""同步当前用户的 Windows 启动项;失败只记日志,不影响设置保存。"""
if os.name != "nt":
return
try:
import winreg
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
key_path,
0,
winreg.KEY_SET_VALUE,
) as key:
if enabled:
if getattr(sys, "frozen", False):
command = f'"{Path(sys.executable).resolve()}"'
else:
entry = Path(__file__).resolve().with_name("app_main.py")
command = f'"{Path(sys.executable).resolve()}" "{entry}"'
winreg.SetValueEx(key, "ZhenYangTangRPA", 0, winreg.REG_SZ, command)
else:
try:
winreg.DeleteValue(key, "ZhenYangTangRPA")
except FileNotFoundError:
pass
except Exception as exc:
self.append_log(f"开机启动项更新失败:{exc}", "warn")
def show_page(self, index: int) -> None:
self.sidebar.select(index)
self.stack.setCurrentIndexAnimated(index)
if index == 2:
self.business_page.refresh_data()
if index == 1:
self.queue_page.refresh_data()
if index == 0 and getattr(self, "console_shell", None) is None:
self.portal_page.ensure_view()
QTimer.singleShot(80, self.portal_page.focus_view)
if 0 <= index < len(CONSOLE_VIEWS):
view = CONSOLE_VIEWS[index]
changed = view != getattr(self, "_console_view", None)
self._console_view = view
shell = getattr(self, "console_shell", None)
if changed and shell is not None:
shell.set_view(view)
self._schedule_console_push(urgent=True)
def _console_navigate(self, view: str) -> None:
name = view if view in CONSOLE_VIEWS else "auto"
self._console_view = name
shell = getattr(self, "console_shell", None)
if shell is not None:
shell.remember_view(name)
index = CONSOLE_VIEWS.index(name)
sidebar = getattr(self, "sidebar", None)
if sidebar is not None:
sidebar.select(index)
stack = getattr(self, "stack", None)
if stack is not None:
stack.setCurrentIndex(index)
if name in {"auto", "queue"} and getattr(self, "queue_page", None) is not None:
self.queue_page.refresh_data()
selected = getattr(self, "_console_selected_key", "")
if selected:
self._console_select_task(selected)
if name == "archive" and getattr(self, "business_page", None) is not None:
self.business_page.refresh_data()
# HTML renders the selected view immediately. Follow it with one
# coalesced state update for the newly visible page; all seven views
# contain live values, not only the queue/archive/log pages.
self._schedule_console_push(urgent=True)
def _console_select_task(self, key: str) -> bool:
wanted = str(key or "").strip()
if not wanted:
return False
table = self.queue_page.queue_table
for row in range(table.rowCount()):
item = table.item(row, 0)
stored = str(item.data(Qt.UserRole) or "") if item else ""
if stored == wanted:
self._console_selected_key = wanted
if row < self.queue_page.task_list.count():
self.queue_page.task_list.setCurrentRow(row)
self.queue_page._select_visible_task(row)
return True
return False
def _select_archive_session(self, key: str) -> bool:
wanted = str(key or "").strip()
if not wanted:
return False
listing = self.business_page.recent_list
for row in range(listing.count()):
item = listing.item(row)
stored = str(item.data(Qt.UserRole) or "") if item else ""
if stored == wanted and not item.isHidden():
listing.setCurrentRow(row)
return True
return False
def _schedule_console_push(self, *, urgent: bool = False) -> None:
timer = getattr(self, "_console_push_timer", None)
if timer is None:
return
delay = 80 if urgent else 1000
if urgent:
self._console_push_urgent = True
if timer.isActive():
# Coalesce bursts against the earliest deadline. Calling start()
# for every log/hover-adjacent event used to postpone the delivery
# indefinitely and made navigation race with later state changes.
remaining = timer.remainingTime()
if urgent and (remaining < 0 or remaining > delay):
timer.start(delay)
return
if not urgent:
self._console_push_urgent = False
timer.start(delay)
def _push_console_state(self, *, force: bool = False) -> None:
timer = getattr(self, "_console_push_timer", None)
if timer is not None and timer.isActive():
# A direct bridge action consumes the pending refresh; leaving its
# timer alive only rebuilds the same snapshot moments later.
timer.stop()
self._console_push_urgent = False
shell = getattr(self, "console_shell", None)
if shell is None:
return
if not getattr(shell, "page_ready", False) or not getattr(shell, "channel_ready", False):
self._console_state_pending = True
return
try:
payload = json.dumps(self._console_snapshot(), ensure_ascii=False)
except (TypeError, ValueError):
return
if not force and payload == getattr(self, "_console_last_payload", None):
self._console_state_pending = False
return
if not shell.set_state(payload):
self._console_state_pending = True
return
# Cache only a payload that had a live receiver. This avoids a blank
# shell after a reload when an unobserved pre-handshake emit happened
# to contain the same data as the first usable update.
self._console_last_payload = payload
self._console_state_pending = False
def _console_snapshot(self) -> dict:
smoke = "--qt-smoke-test" in sys.argv
listening = bool(self._running) or (
smoke and self._status_key not in {"error", "stopped"}
)
if smoke:
listening = True
status_label = "监听中" if listening else (
"连接中" if self._status_key in {"connecting", "waiting", "stopping"} else "已停止"
)
if smoke:
status_label = "监听中"
wecom = self._console_wecom_info()
wecom_connected = bool(wecom.get("connected")) or smoke
send_mode = str(self.runtime_settings.get("send_mode") or SEND_MODE_AUTO)
self.business_page._pending_lookup = dict(getattr(self.queue_page, "pending_states", {}) or {})
snapshot = {
"demo": smoke,
"view": getattr(self, "_console_view", "auto"),
"listening": listening,
"statusLabel": status_label,
"wecomConnected": wecom_connected,
"wecomLabel": "企业微信已连接" if wecom_connected else "企业微信未连接",
"aiOk": self._status_key != "error",
"aiLabel": "AI 服务正常" if self._status_key != "error" else "AI 服务异常",
"sendMode": "review" if send_mode == SEND_MODE_REVIEW else "auto",
"dpi": wecom.get("dpi") or self._console_dpi_label(),
"idleLabel": self._console_idle_label(),
"runtime": self._console_runtime_label(),
"queueFilter": getattr(self.queue_page, "active_task_filter", "全部") or "全部",
"archiveFilter": self._console_archive_filter_label(),
"archiveQuery": self.business_page.archive_search.text(),
"archiveDate": str(getattr(self.business_page, "archive_date", "") or ""),
"aiTab": getattr(self, "_console_ai_tab", "基础设置") or "基础设置",
"queueSort": getattr(self, "_console_queue_sort", "queue") or "queue",
"eventFilter": getattr(self.log_page, "active_event_filter", "全部") or "全部",
"eventQuery": self.log_page.event_search.text(),
"selectedKey": getattr(self, "_console_selected_key", ""),
"selectedArchive": getattr(self, "_console_selected_archive", ""),
}
if smoke:
return snapshot
people = self._console_sort_people(self._console_people())
if people and not snapshot["selectedKey"]:
snapshot["selectedKey"] = people[0]["key"]
selected = snapshot["selectedKey"]
for item in people:
item["active"] = item.get("key") == selected
archives = self._console_archives()
if archives and not snapshot["selectedArchive"]:
snapshot["selectedArchive"] = archives[0]["key"]
logs = self._console_logs()
journey = self._console_journey(people)
snapshot.update(
{
"people": people,
"task": self._console_task(people),
"archives": archives,
"chat": self._console_chat(),
"memory": self._console_memory(),
"settle": self._console_settle(),
"activities": self._console_activities(logs),
"logs": logs,
"logDetail": self._console_log_detail(logs),
"journey": journey,
"journeyTotal": self._console_journey_total(journey),
"chain": self._console_chain(people),
"metrics": self._console_metrics(people, logs),
"windowStats": self._console_window_stats(wecom, people),
"exceptions": {
"sendFail": int(getattr(self.log_page, "_send_fail_count", 0) or 0),
"timeout": int(getattr(self.log_page, "_timeout_count", 0) or 0),
"windowLost": int(getattr(self.log_page, "_window_lost_count", 0) or 0),
},
"ai": self._console_ai_state(),
"automation": self._console_automation_state(wecom),
"system": self._console_system_state(),
}
)
return snapshot
def _console_wecom_info(self) -> dict:
info = {"connected": False, "account": "—", "dpi": "", "hwnd": 0}
bot = getattr(getattr(self, "_thread", None), "bot", None)
hwnd = int(getattr(bot, "hwnd", 0) or 0) if bot is not None else 0
if hwnd:
info["connected"] = True
info["hwnd"] = hwnd
try:
info["dpi"] = f"{max(100, int(round(float(bot.scale) * 100)))}%"
except (TypeError, ValueError, AttributeError):
info["dpi"] = ""
try:
import win32gui
title = str(win32gui.GetWindowText(hwnd) or "").strip()
except Exception:
title = ""
info["account"] = title if title and title not in {"企业微信", "WeCom"} else "已登录"
return info
probe = dict(getattr(self.settings_page, "wecom_probe", {}) or {})
if probe.get("connected") or probe.get("hwnd"):
title = str(probe.get("title") or "").strip()
info.update(
{
"connected": True,
"hwnd": int(probe.get("hwnd") or 0),
"dpi": str(probe.get("dpi") or ""),
"account": title if title and title not in {"企业微信", "WeCom"} else "已登录",
}
)
return info
visual = dict(getattr(self.queue_page, "_visual_payload", {}) or {})
try:
stamp = float(visual.get("ts") or 0.0)
except (TypeError, ValueError):
stamp = 0.0
if visual.get("window_ready") and stamp and time.time() - stamp < 20:
info["connected"] = True
info["account"] = "已登录"
return info
if self._status_key == "running":
info["connected"] = True
info["account"] = "已登录"
return info
@staticmethod
def _console_dpi_label() -> str:
screen = QApplication.primaryScreen()
ratio = float(screen.devicePixelRatio()) if screen is not None else 1.0
return f"{max(100, int(round(ratio * 100)))}%"
@staticmethod
def _console_seconds_label(seconds) -> str:
try:
value = max(0.0, float(seconds))
except (TypeError, ValueError):
return "—"
if value < 10:
return f"{value:.1f}s"
return f"{int(round(value))}s"
@staticmethod
def _mask_sensitive_text(text: str) -> str:
value = str(text or "")
value = re.sub(r"(?<!\d)(1[3-9]\d{9})(?!\d)", lambda m: m.group(1)[:3] + "****" + m.group(1)[-4:], value)
value = re.sub(r"(?<!\d)(\d{6})\d{8}(\d{3}[\dXx])(?!\d)", r"\1********\2", value)
return value
def _console_idle_label(self) -> str:
seconds = self.runtime_settings.get("mouse_idle_seconds", 5)
enabled = bool(self.runtime_settings.get("mouse_idle_enabled", True))
if not enabled:
return "未开启"
try:
return f"{int(float(seconds))} 秒"
except (TypeError, ValueError):
return "5 秒"
def _console_runtime_label(self) -> str:
text = self.dashboard_page.timer.text().replace("运行时长", "").strip()
return text if text and text != "--:--:--" else "00:00:00"
def _console_archive_filter_label(self) -> str:
mode = getattr(self.business_page, "active_archive_filter", "全部") or "全部"
return "全部会话" if mode == "全部" else mode
def _console_people(self) -> list[dict]:
page = self.queue_page
tones = ("", "green", "", "violet", "cyan")
selected = getattr(self, "_console_selected_key", "")
people = []
for row in range(page.queue_table.rowCount()):
key_item = page.queue_table.item(row, 0)
name_item = page.queue_table.item(row, 1)
if name_item is None:
continue
key = str(key_item.data(Qt.UserRole) or "") if key_item else ""
if not key:
continue
list_item = page.task_list.item(row) if row < page.task_list.count() else None
if list_item is not None and list_item.isHidden():
continue
state_item = page.queue_table.item(row, 2)
wait_item = page.queue_table.item(row, 3)
retry_item = page.queue_table.item(row, 5)
status_text = state_item.text() if state_item else ""
display = page._display_task_status(status_text)
wait_detail = wait_item.text() if wait_item else ""
avatar = str(list_item.data(Qt.UserRole + 2) or "") if list_item else ""
if not avatar:
avatar = next((char for char in name_item.text() if "\u4e00" <= char <= "\u9fff"), "客")
try:
retries = int(retry_item.text()) if retry_item else 0
except (TypeError, ValueError):
retries = 0
state = (getattr(page, "pending_states", {}) or {}).get(key) or {}
user_text = str(state.get("staged_user_text") or "").strip()
if state.get("confirmed_unread") or user_text:
lines = [line for line in user_text.splitlines() if line.strip()]
bubbles = max(1, len(lines)) if lines else 1
else:
bubbles = 0
people.append(
{
"key": key,
"name": name_item.text() or "(未识别昵称)",
"status": display,
"wait": self._console_queue_wait_label(wait_detail, display),
"waitDetail": wait_detail,
"avatar": avatar,
"tone": tones[row % 5],
"bubbles": bubbles,
"retries": retries,
"dot": "orange" if display == "待重试" else ("" if display == "处理中" else "gray"),
"active": key == selected or (not selected and not people),
}
)
return people
@staticmethod
def _console_wait_seconds(text: str) -> float:
raw = str(text or "").strip()
if not raw or raw in {"—", "-", "--"}:
return -1.0
if re.fullmatch(r"\d+:\d{2}:\d{2}", raw):
hours, minutes, seconds = raw.split(":")
return int(hours) * 3600 + int(minutes) * 60 + int(seconds)
minutes = 0
seconds = 0
found_min = re.search(r"(\d+)\s*分", raw)
found_sec = re.search(r"(\d+)\s*秒", raw)
if found_min:
minutes = int(found_min.group(1))
if found_sec:
seconds = int(found_sec.group(1))
if found_min or found_sec:
return float(minutes * 60 + seconds)
try:
return float(raw.rstrip("sS"))
except ValueError:
return -1.0
@classmethod
def _console_queue_wait_label(cls, text: str, status: str = "") -> str:
"""Format queue-list waits exactly like the HTML reference."""
if status == "待重试":
return "—"
seconds = cls._console_wait_seconds(text)
if seconds < 0:
return "—"
total = int(seconds)
hours, remainder = divmod(total, 3600)
minutes, secs = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
@staticmethod
def _console_clock_label(value) -> str:
try:
stamp = float(value or 0.0)
except (TypeError, ValueError):
return ""
return time.strftime("%H:%M:%S", time.localtime(stamp)) if stamp > 0 else ""
def _console_sort_people(self, people: list[dict]) -> list[dict]:
mode = getattr(self, "_console_queue_sort", "queue") or "queue"
if mode == "name":
return sorted(people, key=lambda item: str(item.get("name") or ""))
if mode == "wait":
return sorted(
people,
key=lambda item: self._console_wait_seconds(item.get("wait") or ""),
reverse=True,
)
return people
def _console_window_stats(self, wecom: dict, people: list[dict]) -> list[list[str]]:
selected = getattr(self, "_console_selected_key", "")
current = next((item for item in people if item.get("key") == selected), people[0] if people else {})
connected = bool(wecom.get("connected"))
return [
["folder", "窗口", "已识别" if connected else "未识别"],
["message", "当前会话", str(current.get("name") or "—")],
["monitor", "DPI", str(wecom.get("dpi") or self._console_dpi_label())],
["shield", "人工保护", self._console_idle_label()],
["refresh", "最小化恢复", "已开启"],
]
def _console_task(self, people: list[dict]) -> dict | None:
if not people:
return None
selected = getattr(self, "_console_selected_key", "")
current = next((item for item in people if item["key"] == selected), people[0])
page = self.queue_page
row = next(
(
index
for index in range(page.queue_table.rowCount())
if str((page.queue_table.item(index, 0).data(Qt.UserRole) if page.queue_table.item(index, 0) else "") or "")
== current["key"]
),
-1,
)
state_text = page.queue_table.item(row, 2).text() if row >= 0 and page.queue_table.item(row, 2) else ""
detail_item = page.queue_table.item(row, 6) if row >= 0 else None
session_item = page.queue_table.item(row, 7) if row >= 0 else None
clock = str(detail_item.data(Qt.UserRole) or "") if detail_item else ""
table_message = detail_item.text() if detail_item else page.detail_message.text()
state = (getattr(page, "pending_states", {}) or {}).get(current["key"]) or {}
user_text = str(state.get("staged_user_text") or table_message or "").strip()
answer = str(state.get("staged_reply_text") or state.get("reply_text") or "").strip()
lines = [line.strip() for line in user_text.splitlines() if line.strip()]
if self.runtime_settings.get("mask_sensitive", True):
lines = [self._mask_sensitive_text(line) for line in lines]
answer = self._mask_sensitive_text(answer)
step_index = page._queue_stage_index(state_text)
processing = str(state.get("stage") or "") in {"generating", "collecting", "reading", "opening", "sending"}
durations = self._console_stage_durations(state)
stage_started = {}
for history_item in state.get("stage_history") or []:
if not isinstance(history_item, dict):
continue
stage_name = str(history_item.get("stage") or "")
if stage_name and stage_name not in stage_started:
stage_started[stage_name] = history_item.get("started_at")
current_stage = str(state.get("stage") or "")
if current_stage:
stage_started.setdefault(current_stage, state.get("stage_started_at"))
def stage_clock(*names: str) -> str:
return next(
(
self._console_clock_label(stage_started.get(name))
for name in names
if self._console_clock_label(stage_started.get(name))
),
"",
)
step_times = [
self._console_clock_label(state.get("created_at")) or stage_clock("queued", "opening"),
stage_clock("reading", "opening"),
stage_clock("generating"),
stage_clock("generating"),
stage_clock("sending", "ready_to_send"),
stage_clock("receipt_check"),
]
steps = []
labels = ("入队", "读取消息", "请求 AI", "生成回复", "回填发送", "完成")
for index, title in enumerate(labels):
if step_index > index:
label = step_times[index] or "已完成"
elif step_index == index:
label = "进行中"
else:
label = "等待中"
steps.append([title, label])
latency = durations.get("generating") or (
page.queue_table.item(row, 4).text() if row >= 0 and page.queue_table.item(row, 4) else "—"
)
return {
"key": current["key"],
"name": current["name"],
"sessionId": session_item.text() if session_item else page.detail_session.text(),
"sessionDisplay": page.detail_session.text(),
"wait": current.get("waitDetail") or page.detail_wait.text(),
"retries": f"{int(state.get('resume_failures') or 0)} 次",
"messageCount": f"已读取 {len(lines) or (1 if user_text else 0)} 条消息",
"action": "AI 生成回复中" if processing and not answer else (state_text or current["status"]),
"messages": [{"time": clock, "text": line} for line in (lines[-6:] or ([table_message] if table_message else []))],
"answer": answer,
"confidence": "已生成" if answer else ("生成中" if processing else "—"),
# 前端靠这一条决定要不要显示"通过并发送"。不传的话,审核模式下
# 草稿贴进输入框就没有任何放行入口——只能去企业微信里手动按回车,
# 或者干脆卡在那儿,看上去就是"回复生成了但死活不发"
"awaitingReview": bool(state.get("awaiting_review", False)),
# 有值 = 自动发送模式下命中了选择性审核规则(或裁判判高风险),
# 这一条是例外,不是全局审核模式;没有值 = 全局审核开关生效。
# 界面靠这个区分提示文案,运营才不会误以为审核模式又被打开了。
"reviewReason": str(state.get("review_reason") or ""),
# 有值 = 企业微信输入框里有人打的字,机器人这一条已经主动让开、
# 并且暂时不再重试。不告诉界面的话,运营只会看到这个会话莫名其妙
# 不动了,根本想不到是自己(或同事)在输入框里留了半句话。
"foreignDraft": str(state.get("foreign_draft_text") or ""),
"latency": latency or "—",
"steps": steps,
"stepIndex": max(0, min(step_index, 5)),
"schedule": {
"priority": page.schedule_values[0].text() if page.schedule_values else "普通",
"eta": page.schedule_values[1].text() if len(page.schedule_values) > 1 else "—",
"protect": page.schedule_values[2].text() if len(page.schedule_values) > 2 else "已开启",
"retry": page.schedule_values[3].text() if len(page.schedule_values) > 3 else "—",
},
}
def _console_stage_durations(self, state: dict) -> dict[str, str]:
now = time.time()
totals: dict[str, float] = {}
for item in state.get("stage_history") or []:
if not isinstance(item, dict):
continue
stage = str(item.get("stage") or "")
try:
duration = float(item.get("duration") or 0.0)
except (TypeError, ValueError):
duration = 0.0
if stage:
totals[stage] = totals.get(stage, 0.0) + max(0.0, duration)
current = str(state.get("stage") or "")
try:
started = float(state.get("stage_started_at") or 0.0)
except (TypeError, ValueError):
started = 0.0
if current and started:
totals[current] = totals.get(current, 0.0) + max(0.0, now - started)
return {key: self._console_seconds_label(value) for key, value in totals.items() if value > 0}
def _console_journey(self, people: list[dict]) -> list[dict]:
titles = (
("扫描未读", ("queued", "opening")),
("提取聊天记录", ("reading",)),
("合并连续消息", ("collecting",)),
("发送至 AI", ("generating",)),
("生成回复", ("generating", "ready_to_send")),
("回填企业微信", ("sending",)),
("自动发送与归档", ("receipt_check",)),
)
order = [
"queued", "opening", "reading", "collecting", "generating",
"ready_to_send", "sending", "receipt_check",
]
if not people:
return [
{"title": title, "status": "等待", "time": "—", "wait": True, "done": False}
for title, _stages in titles
]
selected = getattr(self, "_console_selected_key", "") or people[0]["key"]
state = (getattr(self.queue_page, "pending_states", {}) or {}).get(selected) or {}
current = str(state.get("stage") or "queued")
try:
current_index = order.index(current)
except ValueError:
current_index = 0
durations = self._console_stage_durations(state)
items = []
for title, stages in titles:
stage_indexes = [order.index(name) for name in stages if name in order]
min_index = min(stage_indexes) if stage_indexes else 0
max_index = max(stage_indexes) if stage_indexes else 0
done = current_index > max_index
active = (not done) and min_index <= current_index <= max_index
elapsed = next((durations.get(name) for name in stages if durations.get(name)), "")
items.append(
{
"title": title,
"status": "已完成" if done else ("进行中" if active else "等待"),
"time": elapsed if (done or active) else "—",
"mode": "" if done else ("active" if active else "wait"),
"done": done,
"wait": not done and not active,
}
)
return items
@staticmethod
def _console_journey_total(journey: list[dict]) -> str:
total = 0.0
for item in journey:
text = str(item.get("time") or "").rstrip("s")
try:
total += float(text)
except (TypeError, ValueError):
continue
return f"总耗时 {total:.1f}s" if total else ""
def _console_chain(self, people: list[dict]) -> list[dict]:
journey = self._console_journey(people)
mapping = (0, 1, 3, 5)
icons = ("activity", "message", "brain", "send")
titles = ("扫描未读", "读取消息", "请求 AI", "回填发送")
chain = []
for icon, title, index in zip(icons, titles, mapping):
item = journey[index] if index < len(journey) else {}
chain.append(
{
"icon": icon,
"title": title,
"time": item.get("time") or "",
}
)
return chain
def _console_archives(self) -> list[dict]:
listing = self.business_page.recent_list
selected = getattr(self, "_console_selected_archive", "")
tones = ("", "green", "", "violet", "cyan")
rows = []
for index in range(listing.count()):
item = listing.item(index)
if item is None or item.isHidden():
continue
record = item.data(Qt.UserRole + 5) or {}
key = str(item.data(Qt.UserRole) or "")
name = str(record.get("display_name") or record.get("name") or "(未识别昵称)")
preview = str(record.get("preview") or "会话已归档").replace("\n", " ")
if self.runtime_settings.get("mask_sensitive", True):
preview = self._mask_sensitive_text(preview)
if len(preview) > 18:
preview = preview[:17] + "…"
rows.append(
{
"key": key,
"name": name,
"avatar": str(record.get("avatar") or next((char for char in name if "\u4e00" <= char <= "\u9fff"), "客")),
"tone": tones[index % 5],
"preview": preview,
"time": str(record.get("display_time") or self.business_page._time_text(record.get("updated"))[-5:]),
"badge": str(item.data(Qt.UserRole + 3) or record.get("status") or "会话档案"),
"count": int(record.get("message_count") or 0),
"active": key == selected or (not selected and not rows),
}
)
return rows
def _console_chat(self) -> list[dict]:
listing = self.business_page.recent_list
item = listing.currentItem()
if item is None:
return []
record = item.data(Qt.UserRole + 5) or {}
session_id = str(item.data(Qt.UserRole) or "")
if record.get("_smoke"):
history = list(record.get("_history") or [])[-8:]
else:
try:
history = list(self.business_page.conversation_store.history(session_id))[-8:]
except Exception:
history = []
mask = bool(self.runtime_settings.get("mask_sensitive", True))
messages = []
for message in history:
text = str(message.get("content") or "")
if mask:
text = self._mask_sensitive_text(text)
messages.append(
{
"role": "assistant" if str(message.get("role") or "").lower() == "assistant" else "user",
"time": self.business_page._chat_time(message, ""),
"text": text,
}
)
return messages
def _console_memory(self) -> dict:
listing = self.business_page.recent_list
item = listing.currentItem()
if item is None:
return {"tags": [], "text": "选择会话后显示长期上下文。", "rounds": 0}
record = item.data(Qt.UserRole + 5) or {}
session_id = str(item.data(Qt.UserRole) or "")
text = str(record.get("memory_preview") or record.get("preview") or "").strip()
if self.runtime_settings.get("mask_sensitive", True):
text = self._mask_sensitive_text(text)
status = str(item.data(Qt.UserRole + 3) or record.get("status") or "")
tags = [tag for tag in (status,) if tag]
try:
store = getattr(self.business_page, "registration_store", None)
leads = store.list_leads(include_done=True) if store is not None else []
except Exception:
leads = []
if any(str(lead.get("session_id") or "") == session_id for lead in leads):
tags.append("挂号登记")
count = int(record.get("message_count") or 0)
if count:
tags.append(f"{count} 条消息")
return {
"tags": tags[:3],
"text": text or "该会话还没有可展示的客户原话。",
"rounds": max(0, (count + 1) // 2),
}
def _console_settle(self) -> list[dict]:
try:
store = getattr(self.business_page, "registration_store", None)
leads = store.list_leads(include_done=True) if store is not None else []
except Exception:
leads = []
pending = sum(1 for item in leads if item.get("status") in {"pending_symptom", "booked"})
followup = sum(1 for item in leads if item.get("status") in {"booked", "done"})
handoff = sum(
1
for state in (getattr(self.queue_page, "pending_states", {}) or {}).values()
if str(state.get("stage") or "") in {"manual_takeover", "manual_review"}
)
listing = self.business_page.recent_list
for row in range(listing.count()):
item = listing.item(row)
if item is None:
continue
if str(item.data(Qt.UserRole + 3) or "") == "已转人工":
handoff += 1
return [
{"title": "挂号登记", "hint": "待跟进的挂号线索", "value": f"{pending} 条"},
{"title": "回访线索", "hint": "已预约或已回联", "value": f"{followup} 条"},
{"title": "人工跟进", "hint": "转人工与待审核", "value": f"{handoff} 条"},
]
def _console_logs(self) -> list[dict]:
history = list(getattr(self.log_page, "_event_history", []) or [])
mode = getattr(self.log_page, "active_event_filter", "全部") or "全部"
needle = self.log_page.event_search.text().strip().casefold()
hide_chat = bool(self.runtime_settings.get("hide_chat_in_logs", False))
def safe_detail(value) -> str:
text = str(value or "")
if not hide_chat:
return text
text = re.sub(r"「[^」]*」", "「正文已隐藏」", text)
text = re.sub(r"『[^』]*』", "『正文已隐藏』", text)
text = re.sub(r"“[^”]*”", "“正文已隐藏”", text)
text = re.sub(r'"[^"\r\n]{8,}"', '"正文已隐藏"', text)
text = re.sub(
r"((?:客户|用户|对方)(?:消息|正文|内容)|(?:聊天|消息|回复)(?:正文|内容)|AI\s*回复)\s*[:].*$",
r"\1:正文已隐藏",
text,
flags=re.IGNORECASE,
)
return text[:79] + "…" if len(text) > 80 else text
rows = []
for item in history:
category = str(item.get("category") or "")
if mode != "全部" and category != mode:
continue
visible = dict(item)
visible["detail"] = safe_detail(visible.get("detail"))
haystack = f"{visible.get('title') or ''} {visible.get('detail') or ''}".casefold()
if needle and needle not in haystack:
continue
rows.append(visible)
return rows[-20:]
def _console_activities(self, logs: list[dict]) -> list[dict]:
return logs[-4:]
@staticmethod
def _console_log_detail(logs: list[dict]) -> dict:
if not logs:
return {
"title": "等待事件",
"text": "开始监听后,这里会显示最近一条运行事件。",
"tags": [],
}
latest = logs[-1]
tags = [latest.get("state") or "", latest.get("category") or ""]
return {
"title": latest.get("title") or "运行事件",
"text": latest.get("detail") or "",
"tags": [tag for tag in tags if tag],
}
def _console_metrics(self, people: list[dict], logs: list[dict]) -> dict:
processing = sum(1 for item in people if item.get("status") == "处理中")
waiting = sum(1 for item in people if item.get("status") == "等待中")
retry = sum(1 for item in people if item.get("status") == "待重试")
warnings = int(getattr(self.log_page, "_warning_count", 0) or 0)
try:
today = int(self.queue_page.sent_metric.value.text() or 0)
except (TypeError, ValueError):
today = 0
try:
failed = int(self.queue_page.failed_metric.value.text() or 0)
except (TypeError, ValueError):
failed = 0
total = today + failed
wecom = self._console_wecom_info()
avg_resp = "—"
if people:
task = self._console_task(people) or {}
avg_resp = str(task.get("latency") or "—")
avg_wait_seconds = self._console_wait_seconds(self.queue_page.band_average.text())
avg_wait = f"{int(round(avg_wait_seconds))}s" if avg_wait_seconds >= 0 else "0s"
return {
"listen": "运行中" if self._running else "已停止",
"queue": str(len(people)),
"today": str(today),
"failed": str(failed),
"processing": str(processing),
"waiting": str(waiting),
"retry": str(retry),
"avgWait": avg_wait,
"wecomHealth": "已连接" if wecom.get("connected") else "待连接",
"aiRate": f"{int(round(today * 100 / total))}%" if total else "—",
"avgResp": avg_resp,
"warnings": str(warnings),
}
@staticmethod
def _console_model_plan() -> dict:
"""后台下发的模型编排,给「AI 设置」页只读展示用。
桌面端这边不再有"当前模型 / 温度 / 最大 tokens"这些设置——它们全部由后台
的「模型清单 + 角色编排」决定,客户端只是执行。把编排摊开显示出来,是为了
排查"回复不对劲"时能一眼看出此刻到底在用谁:以前这一页显示的是本机那份
早已不生效的旧配置,看着像在用 A,实际发出去的是 B。
"""
empty = {
"answer": "",
"judge": "",
"mode": "",
"version": 0,
"gateway": "",
"syncedAt": "",
}
try:
import backend_client
settings = backend_client.load_settings()
plan = settings.get("model_plan") or {}
roles = plan.get("roles") or {}
models = {
str(item.get("id")): str(item.get("name") or item.get("id") or "")
for item in (plan.get("models") or [])
if isinstance(item, dict)
}
def label(ids: str) -> str:
names = [
models.get(one.strip(), one.strip())
for one in str(ids or "").split(",")
if one.strip()
]
return "、".join(names)
return {
"answer": label(roles.get("answer_ids")),
"judge": label(roles.get("judge_id")),
"mode": str(roles.get("judge_mode") or ""),
"version": int(roles.get("version") or 0),
"gateway": str((settings.get("gateway") or {}).get("url") or ""),
"syncedAt": str(plan.get("synced_at") or ""),
}
except Exception:
return empty
def _console_ai_state(self) -> dict:
page = self.persona_page
tools = [switch.isChecked() for switch in getattr(page, "tool_switches", [])]
while len(tools) < 3:
tools.append(False)
last_sync = "尚未同步"
try:
import backend_client
last_sync = str(backend_client.load_settings().get("last_sync_at") or "尚未同步")
except Exception:
last_sync = "尚未同步"
timeout = 120
try:
import ai_config
timeout = int(getattr(ai_config, "AI_TIMEOUT", 120) or 120)
except Exception:
timeout = 120
tokens = int(page.max_tokens.value())
length_index = page._reply_length_index(tokens)
servers = []
try:
parsed = json.loads(page.mcp_json.toPlainText().strip() or "[]")
if isinstance(parsed, list):
servers = parsed
except (json.JSONDecodeError, TypeError, AttributeError):
servers = []
return {
"name": page.agent_name.text().strip() or "贴心管家",
"prompt": page.persona_prompt.toPlainText(),
# 模型、温度、最大 tokens 都由后台的角色编排决定,这里只读展示
"plan": self._console_model_plan(),
"context": page.rounds.value(),
"maxTokens": page.max_tokens.value(),
"replyLengthIndex": length_index,
"timeout": timeout,
"knowledge": str(len(servers)),
"knowledgeCount": f"{len(servers):,}",
"knowledgeConnected": bool(servers),
"syncAt": last_sync,
"syncLabel": "尚未同步" if last_sync == "尚未同步" else "刚刚同步",
"mcpRounds": page.mcp_rounds.value(),
"tools": tools[:3],
"toolCount": sum(1 for item in tools[:3] if item),
"synced": bool(getattr(self, "_console_ai_synced", True)),
# 选择性审核规则,同样由后台下发、本机只读展示——道理和上面的
# 模型编排、MCP 服务器一样:改了会被下一次同步覆盖。
"reviewRules": self._console_review_rules(),
}
@staticmethod
def _console_review_rules() -> list[dict]:
try:
import ai_config
rules = getattr(ai_config, "AI_REVIEW_RULES", None) or []
except Exception:
rules = []
cleaned = []
for rule in rules:
if not isinstance(rule, dict):
continue
cleaned.append({
"label": str(rule.get("label") or "未命名规则"),
"keywords": [str(w) for w in (rule.get("keywords") or [])],
"enabled": bool(rule.get("enabled", True)),
})
return cleaned
def _console_automation_state(self, wecom) -> dict:
settings = self.settings_page.values()
connected = bool(wecom) if isinstance(wecom, bool) else bool((wecom or {}).get("connected"))
account = "—" if isinstance(wecom, bool) else str((wecom or {}).get("account") or "—")
timeout = 120
try:
import ai_config
timeout = int(getattr(ai_config, "AI_TIMEOUT", 120) or 120)
except Exception:
timeout = 120
return {
"poll": settings.get("poll_interval"),
"batch": settings.get("message_batch_window_seconds"),
"delay": settings.get("send_delay_seconds"),
"idle": settings.get("mouse_idle_seconds"),
"mouseProtect": bool(settings.get("mouse_idle_enabled")),
# 0 = 关闭。控制台靠它决定开关的亮灭和分钟框填什么。
"draftAutosend": settings.get("foreign_draft_autosend_minutes") or 0,
"reply": settings.get("auto_reply_text"),
"context": self.persona_page.rounds.value(),
"timeout": timeout,
"wecom": "已识别" if connected else "未识别",
"account": account,
}
def _console_system_state(self) -> dict:
values = self.system_page.values()
if self._console_storage is None:
self._console_storage = self._console_storage_stats()
storage = self._console_storage
network = self.system_page.network_status_rows
def network_text(title: str, fallback: str) -> str:
row = network.get(title)
if row is None:
return fallback
return str(row.value_label.text() or fallback)
def _backend_snapshot() -> tuple[str, str]:
"""当前的后台地址和网关地址。
后台地址要回给控制台,输入框才能显示成"现在填的是什么"而不是空白;
网关地址是只读的——它由后台下发,本机改不了,显示出来是为了排查
"模型不回复"时能一眼看出网关到底有没有到位。
"""
try:
import backend_client
saved = backend_client.load_settings()
return (
str(saved.get("server_url") or backend_client.DEFAULT_SERVER_URL),
str((saved.get("gateway") or {}).get("url") or ""),
)
except Exception:
return ("", "")
notice_time = "—"
try:
history = list(getattr(self.log_page, "_event_history", []) or [])
for item in reversed(history):
if str(item.get("title") or "") == "发送失败":
notice_time = str(item.get("time") or "—")
break
except Exception:
notice_time = "—"
update_label = "已是最新版本"
try:
import backend_client
status = backend_client.cached_release_status() or {}
if status.get("update_available"):
update_label = f"有新版本 {status.get('latest_version') or ''}".strip()
except Exception:
update_label = "已是最新版本"
return {
"version": APP_VERSION,
"autoLaunch": bool(values.get("auto_launch")),
"autoMonitor": bool(values.get("auto_monitor")),
"tray": bool(values.get("minimize_on_close")),
"background": bool(values.get("keep_background")),
"language": values.get("interface_language") or "简体中文",
"scale": values.get("scale_ratio") or "自动",
"notifyFail": bool(values.get("notify_send_failed")),
"notifyTimeout": bool(values.get("notify_timeout")),
"notifyManual": bool(values.get("notify_manual")),
"notifyDisconnect": bool(values.get("notify_disconnect")),
"maskSensitive": bool(values.get("mask_sensitive")),
"encryptLocal": bool(values.get("encrypt_local")),
"hideChat": bool(values.get("hide_chat_in_logs")),
"clearClipboard": bool(values.get("clear_clipboard_on_exit")),
"retention": values.get("log_retention_days", 90),
"storage": storage.get("total", "—"),
"archiveSize": storage.get("archive", "—"),
"logSize": storage.get("log", "—"),
"cacheSize": storage.get("cache", "—"),
"syncState": network_text("配置同步", "未检测"),
"gatewayState": network_text("模型网关", "未同步"),
"serverUrl": _backend_snapshot()[0],
"gatewayUrl": _backend_snapshot()[1],
"lastBackup": getattr(self.system_page, "last_backup_at", "") or "尚未备份",
"noticeTime": notice_time,
"updateLabel": update_label,
}
@staticmethod
def _console_size_label(nbytes: int) -> str:
if nbytes < 1024:
return f"{nbytes} B"
if nbytes < 1024 * 1024:
return f"{nbytes / 1024:.0f} KB"
return f"{nbytes / (1024 * 1024):.0f} MB"
def _console_storage_stats(self) -> dict:
def file_size(path: Path) -> int:
try:
return int(path.stat().st_size) if path.is_file() else 0
except OSError:
return 0
archive = file_size(SCRIPT_DIR / "conversations.json")
log = file_size(SCRIPT_DIR / "runtime.log") + file_size(SCRIPT_DIR / "wechat_rpa.log")
cache = (
file_size(SCRIPT_DIR / "pending_replies.json")
+ file_size(SCRIPT_DIR / "false_pos_cache.json")
+ file_size(SCRIPT_DIR / "vision_status.json")
)
total = archive + log + cache
return {
"total": self._console_size_label(total),
"archive": self._console_size_label(archive),
"log": self._console_size_label(log),
"cache": self._console_size_label(cache),
}
def _show_security_rules(self) -> None:
"""Open the AI safety-policy section and the matching console tab."""
self._console_ai_tab = "安全策略"
self.show_page(3)
buttons = getattr(self.persona_page, "persona_tab_buttons", [])
if len(buttons) >= 3:
buttons[2].setChecked(True)
QTimer.singleShot(
80,
lambda: self.persona_page._focus_persona_tab("安全策略"),
)
shell = getattr(self, "console_shell", None)
if shell is not None:
self._schedule_console_push(urgent=True)
def _refresh_queue_page(self) -> None:
"""队列页开着的时候,让它跟着机器人一起动。"""
view = getattr(self, "_console_view", "auto")
if view in {"auto", "queue"} or self.stack.currentWidget() is self.queue_page:
self.queue_page.refresh_data()
selected = getattr(self, "_console_selected_key", "")
if selected:
self._console_select_task(selected)
self._schedule_console_push()
def _delete_queue_tasks(self, keys) -> dict:
"""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 {"deleted": [], "scheduled": [], "protected": [], "error": ""}
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 {
"deleted": [],
"scheduled": [],
"protected": [],
"error": str(exc),
}
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()
return dict(result)
def _handoff_queue_tasks(self, keys) -> None:
requested = [str(key or "").strip() for key in (keys or [])]
requested = [key for key in requested if key]
if not requested:
return
result = self._delete_queue_tasks(requested)
accepted = set(result.get("deleted") or ()) | set(result.get("scheduled") or ())
if accepted:
self.append_log(
f"已将 {len(accepted)} 条会话转交人工,自动回复不会继续发送。",
"notify",
)
def _approve_queue_tasks(self, keys) -> None:
"""人工放行待审核草稿,交回给监听线程按正常流程发送。"""
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)
if thread is None or not thread.is_alive():
# 审核模式下监听常常是停着的(人看到草稿才来点放行)。这里必须
# 把它拉起来——否则点了没反应,正是用户抱怨的"就是不发送"
self.start_monitoring()
thread = getattr(self, "_thread", None)
if thread is None or not thread.is_alive():
QMessageBox.warning(
self, "无法发送", "监听服务未能启动,草稿仍停在待审核状态。"
)
return
try:
result = thread.approve_pending_tasks(requested)
except Exception as exc:
QMessageBox.warning(self, "无法发送", f"放行请求失败:{exc}")
self.append_log(f"人工放行请求失败:{exc}", "err")
return
error = str(result.get("error") or "")
approved = list(result.get("approved") or [])
scheduled = list(result.get("scheduled") or [])
not_pending = list(result.get("not_pending") or [])
missing = list(result.get("missing") or [])
if error:
QMessageBox.warning(self, "无法发送", error)
self.append_log(f"人工放行失败:{error}", "err")
if approved:
self.append_log(
f"已放行 {len(approved)} 条待审核回复,正在重新核对会话后发送。",
"ok",
)
if scheduled:
self.append_log(
f"监听服务正在启动,{len(scheduled)} 条放行请求将在启动后执行。",
"warn",
)
if not_pending:
self.append_log(
f"{len(not_pending)} 条任务并不处于待审核状态,已跳过。", "warn"
)
if missing:
self.append_log(f"{len(missing)} 条任务已不在队列中。", "warn")
self._schedule_console_push(urgent=True)
def _retry_queue_tasks(self, keys) -> None:
"""Schedule selected retry-wait tasks without disturbing normal work."""
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)
if thread is None or not thread.is_alive():
self.start_monitoring()
thread = getattr(self, "_thread", None)
if thread is None or not thread.is_alive():
QMessageBox.warning(self, "无法重试", "监听服务未能启动,任务仍保留在待重试队列。")
return
try:
result = thread.retry_pending_tasks(requested)
except Exception as exc:
QMessageBox.warning(self, "无法重试", f"队列重试请求失败:{exc}")
self.append_log(f"队列重试请求失败:{exc}", "err")
return
error = str(result.get("error") or "")
retried = list(result.get("retried") or [])
scheduled = list(result.get("scheduled") or [])
protected = list(result.get("protected") or [])
not_retryable = list(result.get("not_retryable") or [])
missing = list(result.get("missing") or [])
if error:
QMessageBox.warning(self, "无法重试", error)
self.append_log(f"队列重试失败:{error}", "err")
if retried:
self.append_log(
f"已将 {len(retried)} 条异常任务重新排队,下一轮立即安全处理。",
"notify",
)
if scheduled:
self.append_log(
f"已登记 {len(scheduled)} 条立即重试请求,后台初始化完成后执行。",
"notify",
)
if protected or not_retryable:
QMessageBox.information(
self,
"未打断正在处理的任务",
f"有 {len(protected) + len(not_retryable)} 条任务正在发送核对、人工审核或正常处理,未强制重试。",
)
if missing:
self.append_log(f"有 {len(missing)} 条任务已离开队列,无需重试。", "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,
retention_days=self.runtime_settings.get("log_retention_days"),
)
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"]
),
send_delay_seconds=float(self.runtime_settings["send_delay_seconds"]),
send_mode=self.runtime_settings["send_mode"],
foreign_draft_autosend_minutes=float(
self.runtime_settings["foreign_draft_autosend_minutes"]
),
enable_engine_b=bool(
self.runtime_settings.get("enable_engine_b", True)
),
engine_b_poll_interval=float(
self.runtime_settings.get("engine_b_poll_interval", 2.0)
),
engine_a_enabled=bool(
self.runtime_settings.get("engine_a_enabled", True)
),
engine_b_data_source=str(
self.runtime_settings.get("engine_b_data_source", "parallel")
),
)
self._running = True
self._start_time = time.time()
self.set_status("connecting", "连接中")
self.append_log("正在连接企业微信窗口…", "notify")
self._thread.start()
# 一旦开始监听就收进胶囊:控制台占着大半个屏幕,而机器人要操作的是
# 企业微信窗口——留着整块面板挡在前面既碍事,也容易被误点。胶囊上有
# 暂停和展开,连接失败时 _finish_thread 会自动把控制台还回来。
QTimer.singleShot(0, self.enter_capsule_mode)
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.log_page.set_system_status(state, text)
self.capsule.set_status(state, text)
self.queue_page.set_running(state not in ("stopped", "error", "verification"))
self._schedule_console_push(urgent=True)
def changeEvent(self, event) -> None:
if event.type() == QEvent.Type.WindowStateChange and hasattr(self, "_scale_timer"):
self._scale_timer.start()
if (
event.type() == QEvent.Type.WindowStateChange
and self.isMinimized()
and getattr(self, "_running", False)
and bool(self.runtime_settings.get("minimize_on_close", True))
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.
console = getattr(self, "console_shell", None)
if console is not None:
self._console_was_visible = console.isVisible()
if console.isVisible():
console.hide()
QApplication.processEvents()
else:
self._console_was_visible = False
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, "_console_was_visible", False) and getattr(self, "console_shell", None) is not None:
self.console_shell.show()
self._console_was_visible = False
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
if self._stdout_proxy is not None:
# Windows 删不掉正被打开的文件。上一轮的日志句柄不关,下次启动时
# 「数据保留 N 天」就删不动它,清理会静悄悄地失败。
self._stdout_proxy.close()
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:
text = str(message)
if self.runtime_settings.get("hide_chat_in_logs"):
text = re.sub(r"「[^」]{8,}」", "「…」", text)
if len(text) > 160:
text = text[:157] + "…"
self.log_page.append(text, tag)
self._schedule_console_push(urgent=tag in {"warn", "err"})
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:
if (
self._running
and bool(self.runtime_settings.get("keep_background", True))
and not self.capsule.isVisible()
):
event.ignore()
self.enter_capsule_mode()
return
self.save_runtime_settings()
self._running = False
if bool(self.runtime_settings.get("clear_clipboard_on_exit", True)):
try:
QApplication.clipboard().clear()
except Exception:
pass
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 _apply_saved_scale_preference() -> None:
"""Apply the saved Qt scale before QApplication reads its DPI settings."""
if "--qt-smoke-test" in sys.argv or os.environ.get("QT_SCALE_FACTOR"):
return
try:
saved = json.loads(APP_SETTINGS_FILE.read_text(encoding="utf-8"))
ratio = str(saved.get("scale_ratio") or "自动").strip()
except (OSError, ValueError, TypeError):
return
factor = {
"100%": "1",
"125%": "1.25",
"150%": "1.5",
"200%": "2",
}.get(ratio)
if factor:
os.environ["QT_SCALE_FACTOR"] = factor
def main() -> None:
_apply_saved_scale_preference()
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication.instance() or QApplication(sys.argv)
_register_bundled_fonts()
app.setApplicationName(WIN_TITLE)
app.setApplicationDisplayName(WIN_TITLE)
app.setWindowIcon(_app_icon())
app.setStyle("Fusion")
app.setStyleSheet(APP_QSS)
font = _ui_font(9)
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)
from archive_auto_backup import start_auto_backup
start_auto_backup()
if "--qt-smoke-test" in sys.argv:
# Native title bar plus the 1630×920 HTML workspace.
window.setFixedSize(QSize(1630, 920))
page_count = min(window.stack.count(), len(CONSOLE_VIEWS))
expected_titles = (
"桌面自动化控制台",
"任务队列",
"会话归档",
"AI 设置",
"自动化设置",
"运行日志",
"系统设置",
)
# The seven rail controls have fixed positions in the 1630×920 source
# design. Reading their colour saturation lets the smoke test verify
# the *painted Chromium frame*, not merely the already-updated DOM.
rail_centres = ((58, 145), (58, 222), (58, 297), (58, 372), (58, 450), (58, 528), (68, 861))
def painted_view_index(image) -> int:
if image.isNull() or image.width() < 100 or image.height() < 100:
return -1
scale_x = image.width() / 1630.0
scale_y = image.height() / 920.0
scores: list[float] = []
for centre_x, centre_y in rail_centres:
total = 0
count = 0
for source_x in range(42, 76, 3):
for source_y in range(centre_y - 14, centre_y + 15, 3):
x = min(image.width() - 1, max(0, round(source_x * scale_x)))
y = min(image.height() - 1, max(0, round(source_y * scale_y)))
colour = image.pixelColor(x, y)
channels = (colour.red(), colour.green(), colour.blue())
total += max(channels) - min(channels)
count += 1
scores.append(total / max(1, count))
strongest = max(range(len(scores)), key=scores.__getitem__)
return strongest if scores[strongest] >= 24.0 else -1
def painted_main_has_content(image) -> bool:
"""Reject a Chromium frame whose rail painted before the main surface."""
if image.isNull() or image.width() < 100 or image.height() < 100:
return False
scale_x = image.width() / 1630.0
scale_y = image.height() / 920.0
dark_samples = 0
# Every reference page has a dark h1 in this stable header region.
# A blank delegated surface contains only the pale background here.
for source_x in range(170, 700, 4):
for source_y in range(30, 130, 4):
x = min(image.width() - 1, max(0, round(source_x * scale_x)))
y = min(image.height() - 1, max(0, round(source_y * scale_y)))
colour = image.pixelColor(x, y)
if max(colour.red(), colour.green(), colour.blue()) < 175:
dark_samples += 1
if dark_samples >= 20:
return True
return False
def wait_for_test_view(
view_index: int,
callback,
attempt: int = 0,
) -> None:
view = CONSOLE_VIEWS[view_index]
expected_title = expected_titles[view_index]
script = (
"(() => {"
f"const wanted={json.dumps(view)};"
"if (window.render) window.render(wanted);"
"const main=document.getElementById('main');"
"const active=document.querySelector('[data-view].active');"
"const heading=main && main.querySelector('h1');"
"return Boolean(window.STATE && window.STATE.ready && "
"window.STATE.view===wanted && main && main.children.length && "
"active && active.dataset.view===wanted && heading && "
f"heading.textContent==={json.dumps(expected_title)});"
"})()"
)
def checked(ready, i=view_index, a=attempt) -> None:
if ready or a >= 30:
window.console_shell.update()
QTimer.singleShot(500, callback)
return
QTimer.singleShot(100, lambda: wait_for_test_view(i, callback, a + 1))
window.console_shell.page().runJavaScript(script, checked)
def capture_test_page(index: int, attempt: int = 0) -> None:
# On Windows software/offscreen rendering, the delegated WebEngine
# surface can be exactly one DOM transition behind. Accept a grab
# only when the active rail item proves that its pixels belong to
# the requested page; otherwise toggle one view to flush the
# compositor and inspect the next frame.
pixmap = window.console_shell.grab()
image = pixmap.toImage()
if painted_view_index(image) == index and painted_main_has_content(image):
pixmap.save(str(SCRIPT_DIR / f"qt-ui-smoke-{index + 1}.png"))
QTimer.singleShot(180, lambda i=index + 1: prepare_test_page(i))
return
if attempt >= 10:
print(f"Smoke capture failed to paint {CONSOLE_VIEWS[index]!r}", file=sys.stderr)
app.exit(2)
return
flush_index = (index + 1) % page_count
next_index = index if attempt % 2 else flush_index
wait_for_test_view(
next_index,
lambda i=index, a=attempt + 1: capture_test_page(i, a),
)
def prepare_test_page(index: int) -> None:
if index >= page_count:
QTimer.singleShot(250, app.quit)
return
view = CONSOLE_VIEWS[index]
window.show_page(index)
# Explicitly select the web view as well. This matters for the
# initial auto page, whose host-side view is already "auto" and
# therefore would otherwise be treated as unchanged.
window.console_shell.set_view(view)
window._push_console_state(force=True)
wait_for_test_view(index, lambda i=index: capture_test_page(i))
def begin_smoke_capture(attempt: int = 0) -> None:
if not window.console_shell.page_ready and attempt < 100:
QTimer.singleShot(100, lambda: begin_smoke_capture(attempt + 1))
return
# Warm the delegated Chromium surface on a different view, then
# return to auto. The first direct grab on Windows can otherwise
# be an unpainted solid background even though loadFinished fired.
window.console_shell.set_view("queue")
QTimer.singleShot(450, lambda: prepare_test_page(0))
QTimer.singleShot(1200, begin_smoke_capture)
try:
exit_code = app.exec()
except KeyboardInterrupt:
console_shutdown()
exit_code = 0
raise SystemExit(exit_code)
if __name__ == "__main__":
main()