11019 lines
489 KiB
Python
11019 lines
489 KiB
Python
# -*- 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 已回复", "建议每日 1–2 次服用", "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("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
.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()
|