This commit is contained in:
Your Name
2026-07-31 18:34:44 +08:00
parent f22cc1a70d
commit 5ac803f7a6
197 changed files with 7570 additions and 329 deletions
+378 -4
View File
@@ -28,6 +28,7 @@ from PySide6.QtGui import (
QColor,
QDesktopServices,
QFont,
QFontMetrics,
QIcon,
QPainter,
QPen,
@@ -853,6 +854,7 @@ class SessionDetailDialog(QDialog):
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)
@@ -868,6 +870,57 @@ class SessionDetailDialog(QDialog):
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": "系统记录"}
@@ -877,7 +930,11 @@ class SessionDetailDialog(QDialog):
name = role_names.get(role, role)
stamp = self._message_time(message.get("ts"))
heading = f"[{stamp}] {name}" if stamp else name
lines.append(f"{heading}\n{message.get('content') or ''}")
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))
@@ -1358,6 +1415,263 @@ class PersonaPage(QScrollArea):
self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
class QueuePage(QScrollArea):
"""回复队列:现在还欠谁一条回复,以及每个任务都经历了什么。
队列本身只存"未完成"的任务,做完就删,什么痕迹都不留。出问题时——某个客户
没收到回复、同一句话回了三遍——事后完全看不出它经历过什么。执行记录补的
就是这条时间线。
"""
logMessage = Signal(str, str)
EVENT_COLORS = {
"入队": COLORS["accent_dark"],
"开始处理": COLORS["ink"],
"调用模型": COLORS["accent_dark"],
"生成回复": COLORS["ink"],
"已发送": COLORS["success"],
"完成": COLORS["success"],
"对账": COLORS["muted"],
"页面诊断": COLORS["muted"],
"发送待核对": COLORS["warning"],
"跳过": COLORS["warning"],
"失败": COLORS["danger"],
}
def __init__(self):
super().__init__()
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
root = QWidget()
root.setObjectName("PageRoot")
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
header = QHBoxLayout()
header.addLayout(
_page_header("06", "回复队列", "排队等回复的会话,以及每个任务的执行流水。"),
1,
)
refresh = _button("刷新")
refresh.clicked.connect(self.refresh_data)
header.addWidget(refresh, 0, Qt.AlignBottom)
layout.addLayout(header)
summary = QGridLayout()
summary.setSpacing(12)
self.waiting_metric = MetricCard("排队中", "0", "等待回复的会话")
self.sent_metric = MetricCard("今日已回", "0", "成功发出的回复")
self.failed_metric = MetricCard("今日失败", "0", "发送未成功,会自动重试")
for column, card in enumerate(
(self.waiting_metric, self.sent_metric, self.failed_metric)
):
summary.addWidget(card, 0, column)
summary.setColumnStretch(column, 1)
layout.addLayout(summary)
queue_card, queue_layout = _card(
"当前队列",
"按进入顺序排队,一次只服务一个会话,聊天内容互不串扰。",
)
self._running = False
self.queue_notice = _label("", "CardSubtitle")
self.queue_notice.setStyleSheet(
f"color:{COLORS['warning']};background:#FFF7E8;"
"border-radius:6px;padding:7px 10px;"
)
self.queue_notice.hide()
queue_layout.addWidget(self.queue_notice)
self.queue_table = self._table(["排队", "客户", "状态", "等待时长", "会话 ID"])
queue_layout.addWidget(self.queue_table)
layout.addWidget(queue_card)
history_card, history_layout = _card(
"执行记录",
"每个任务从入队到完成的每一步,最新的排在最上面。",
)
self.history_table = self._table(["时间", "客户", "动作", "内容"])
history_layout.addWidget(self.history_table)
history_actions = QHBoxLayout()
history_actions.addStretch(1)
clear = _button("清空记录", "danger")
clear.clicked.connect(self._clear_history)
history_actions.addWidget(clear)
history_layout.addLayout(history_actions)
layout.addWidget(history_card)
layout.addStretch(1)
self.refresh_data()
@staticmethod
def _table(headers: list[str]) -> QTableWidget:
table = QTableWidget(0, len(headers))
table.setHorizontalHeaderLabels(headers)
table.setSelectionBehavior(QAbstractItemView.SelectRows)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
table.setAlternatingRowColors(True)
table.verticalHeader().hide()
table.verticalHeader().setDefaultSectionSize(38)
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
table.horizontalHeader().setStretchLastSection(True)
table.setMinimumHeight(220)
return table
def set_running(self, running: bool) -> None:
"""监听是否在跑。停着的时候队列里的等待时长会一直涨,不说明白会被当成卡死。"""
self._running = bool(running)
self._refresh_notice()
def _refresh_notice(self) -> None:
waiting = self.queue_table.rowCount() if self.queue_table.item(0, 1) else 0
if not self._running and waiting:
self.queue_notice.setText(
"监听已停止,这些任务原地保留;重新开始监听后会接着处理,等待时长仍在累计。"
)
self.queue_notice.show()
else:
self.queue_notice.hide()
@staticmethod
def _show_placeholder(table: QTableWidget, text: str) -> None:
"""空表就是一大片白,看着像坏了。用一行字说明它本来就该是空的。"""
table.setRowCount(1)
item = QTableWidgetItem(text)
item.setForeground(QColor(COLORS["muted"]))
table.setItem(0, 0, item)
table.setSpan(0, 0, 1, table.columnCount())
@staticmethod
def _as_time(value) -> float:
try:
return float(value)
except (TypeError, ValueError):
return float("inf")
@staticmethod
def _waited_text(since: float) -> str:
try:
seconds = max(0, int(time.time() - float(since)))
except (TypeError, ValueError):
return "--"
if seconds < 60:
return f"{seconds}"
if seconds < 3600:
return f"{seconds // 60}{seconds % 60}"
return f"{seconds // 3600} 小时 {(seconds % 3600) // 60}"
@staticmethod
def _state_text(state: dict) -> str:
send_state = str(state.get("send_state") or "")
if send_state == "sending":
return "正在发送"
if send_state == "uncertain":
return "发送待核对"
if send_state == "sent_uncommitted":
return "已发送,待归档"
if state.get("batch_ready"):
return "正在生成回复"
if state.get("confirmed_unread"):
return "等待处理"
return "排队中"
def refresh_data(self) -> None:
self._refresh_queue()
self._refresh_history()
self._refresh_notice()
def _refresh_queue(self) -> None:
try:
with open(SCRIPT_DIR / "pending_replies.json", encoding="utf-8") as handle:
raw = json.load(handle)
except FileNotFoundError:
raw = {}
except Exception as exc:
raw = {}
self.logMessage.emit(f"读取回复队列失败:{exc}", "err")
pending = raw.get("pending", raw) if isinstance(raw, dict) else {}
rows = sorted(
(
(key, value)
for key, value in pending.items()
if isinstance(value, dict)
),
# 先来的排前面,界面上的顺序就是实际服务顺序。时间戳脏了也不能
# 让整页读不出来——排到最后就是了
key=lambda item: self._as_time(item[1].get("created_at")),
)
self.waiting_metric.value.setText(str(len(rows)))
self.queue_table.clearSpans()
self.queue_table.setRowCount(len(rows))
if not rows:
self._show_placeholder(self.queue_table, "没有排队的会话,所有消息都已回复。")
return
for index, (key, state) in enumerate(rows):
values = (
str(index + 1),
state.get("display_name") or "(未识别昵称)",
self._state_text(state),
self._waited_text(state.get("created_at")),
str(key)[:32],
)
for column, value in enumerate(values):
self.queue_table.setItem(index, column, QTableWidgetItem(str(value)))
def _refresh_history(self) -> None:
try:
from queue_log import QueueLog
events = QueueLog().recent(200)
except Exception as exc:
events = []
self.logMessage.emit(f"读取队列执行记录失败:{exc}", "err")
self.history_table.clearSpans()
self.history_table.setRowCount(len(events))
if not events:
self._show_placeholder(self.history_table, "还没有队列执行记录。")
self.sent_metric.value.setText("0")
self.failed_metric.value.setText("0")
return
midnight = time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))
sent = failed = 0
for row, event in enumerate(events):
try:
stamp = float(event.get("ts") or 0.0)
except (TypeError, ValueError):
stamp = 0.0
action = str(event.get("event") or "")
if stamp >= midnight:
if action == "已发送":
sent += 1
elif action == "失败":
failed += 1
values = (
time.strftime("%m-%d %H:%M:%S", time.localtime(stamp)) if stamp else "--",
event.get("name") or "(未识别昵称)",
action,
str(event.get("detail") or "").replace("\n", " "),
)
for column, value in enumerate(values):
item = QTableWidgetItem(str(value))
if column == 2 and action in self.EVENT_COLORS:
item.setForeground(QColor(self.EVENT_COLORS[action]))
self.history_table.setItem(row, column, item)
self.sent_metric.value.setText(str(sent))
self.failed_metric.value.setText(str(failed))
def _clear_history(self) -> None:
try:
from queue_log import QueueLog
QueueLog().clear()
except Exception as exc:
self.logMessage.emit(f"清空队列执行记录失败:{exc}", "err")
return
self._refresh_history()
class LogPage(QWidget):
def __init__(self):
super().__init__()
@@ -1366,7 +1680,7 @@ class LogPage(QWidget):
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
header = QHBoxLayout()
header.addLayout(_page_header("06", "运行日志", "实时查看连接、识别、AI 与业务沉淀事件。"), 1)
header.addLayout(_page_header("07", "运行日志", "实时查看连接、识别、AI 与业务沉淀事件。"), 1)
clear = _button("清空日志")
clear.clicked.connect(self.clear)
header.addWidget(clear, 0, Qt.AlignBottom)
@@ -1534,6 +1848,16 @@ class CapsuleWindow(QWidget):
row.addWidget(self.status_group, 0, Qt.AlignVCenter)
row.addStretch(14)
# 计时器和进度文字叠成一列:光看时长看不出机器人此刻在忙什么,
# 缩成胶囊之后更是全靠这一行判断它是在等、在读、还是卡住了
clock = QWidget()
clock.setObjectName("CapsuleClock")
clock.setFixedSize(150, 46)
clock_column = QVBoxLayout(clock)
clock_column.setContentsMargins(0, 0, 0, 0)
clock_column.setSpacing(2)
clock_column.addStretch(1)
self.timer = QLabel("00:00:00")
self.timer.setObjectName("CapsuleTimer")
self.timer.setAccessibleName("监听运行时长")
@@ -1544,7 +1868,21 @@ class CapsuleWindow(QWidget):
"border-radius:8px;font-family:'Cascadia Mono','Consolas';"
"font-size:10px;font-weight:600;color:#1A1C1C;}"
)
row.addWidget(self.timer, 0, Qt.AlignVCenter)
clock_column.addWidget(self.timer, 0, Qt.AlignHCenter)
self._progress_text = ""
self.progress = QLabel("")
self.progress.setObjectName("CapsuleProgress")
self.progress.setAccessibleName("当前进度")
self.progress.setAlignment(Qt.AlignCenter)
self.progress.setFixedSize(150, 13)
self.progress.setStyleSheet(
"QLabel#CapsuleProgress{color:#66736D;font-size:10px;}"
)
clock_column.addWidget(self.progress, 0, Qt.AlignHCenter)
clock_column.addStretch(1)
row.addWidget(clock, 0, Qt.AlignVCenter)
row.addStretch(39)
self.divider = QFrame()
@@ -1629,6 +1967,20 @@ class CapsuleWindow(QWidget):
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("")
def set_progress(self, text: str) -> None:
"""胶囊里那行实时进度。客户昵称可能很长,放不下就省略中间。"""
self._progress_text = str(text or "")
metrics = QFontMetrics(self.progress.font())
self.progress.setText(
metrics.elidedText(
self._progress_text, Qt.ElideMiddle, self.progress.width()
)
)
self.progress.setToolTip(self._progress_text)
class Sidebar(QFrame):
@@ -1636,7 +1988,9 @@ class Sidebar(QFrame):
startRequested = Signal()
stopRequested = Signal()
PAGE_NAMES = ("AI 客服", "自动回复", "通用设置", "业务数据", "AI 人格", "运行日志")
PAGE_NAMES = (
"AI 客服", "自动回复", "通用设置", "业务数据", "AI 人格", "回复队列", "运行日志",
)
def __init__(self):
super().__init__()
@@ -1745,6 +2099,7 @@ class MainWindow(QMainWindow):
self.settings_page = SettingsPage(self.runtime_settings)
self.business_page = BusinessPage()
self.persona_page = PersonaPage()
self.queue_page = QueuePage()
self.log_page = LogPage()
for page in (
self.portal_page,
@@ -1752,6 +2107,7 @@ class MainWindow(QMainWindow):
self.settings_page,
self.business_page,
self.persona_page,
self.queue_page,
self.log_page,
):
self.stack.addWidget(page)
@@ -1771,6 +2127,13 @@ class MainWindow(QMainWindow):
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
@@ -1803,6 +2166,7 @@ class MainWindow(QMainWindow):
self.dashboard_page.pageRequested.connect(self.show_page)
self.settings_page.saved.connect(self.save_runtime_settings)
self.business_page.logMessage.connect(self.append_log)
self.queue_page.logMessage.connect(self.append_log)
self.persona_page.saved.connect(self._persona_saved)
self.capsule.expandRequested.connect(self.expand_console)
self.capsule.stopRequested.connect(self.stop_monitoring)
@@ -1867,9 +2231,16 @@ class MainWindow(QMainWindow):
self.stack.setCurrentIndexAnimated(index)
if index == 3:
self.business_page.refresh_data()
if index == 5:
self.queue_page.refresh_data()
if index == 0:
QTimer.singleShot(80, lambda: self.portal_page.view.setFocus(Qt.OtherFocusReason))
def _refresh_queue_page(self) -> None:
"""队列页开着的时候,让它跟着机器人一起动。"""
if self.stack.currentWidget() is self.queue_page and self.isVisible():
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")
@@ -1918,6 +2289,7 @@ class MainWindow(QMainWindow):
self.sidebar.set_status(state, text, hint)
self.dashboard_page.set_status(state, text, hint)
self.capsule.set_status(state, text)
self.queue_page.set_running(state not in ("stopped", "error", "verification"))
def changeEvent(self, event) -> None:
if (
@@ -2081,6 +2453,8 @@ class MainWindow(QMainWindow):
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)))