- merge duplicated eventFilter paths and gate send-by-Enter (P0-02) - display bash task layers in reverse start-time order while reusing layer instances and preserving scroll/expand state (P2-01) - add scoped 8px scrollbars with matching corner for the bash code box (P2-02) - rewrite RenameOverlay as a top-level transparent tool window so it can cover the native WebView2 child HWND (P2-03)
6223 lines
289 KiB
Python
6223 lines
289 KiB
Python
import sys
|
||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||
# 引入我们刚才写的线程工作类
|
||
from core.llm_engine import AgentWorker, ChatWorker, TitleWorker
|
||
import os
|
||
import json
|
||
from ui.views.custom_web_page import CustomWebPage
|
||
from PyQt6.QtCore import QUrl # 🌟 新增:用于加载本地 HTML
|
||
|
||
# ========== 流式诊断日志(排查显示问题的根本手段,写 stream_diag.log) ==========
|
||
import time as _diag_time
|
||
DIAG_LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||
"stream_diag.log")
|
||
|
||
def diag_log(line: str):
|
||
try:
|
||
t = _diag_time.time()
|
||
with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f:
|
||
f.write(f"[{_diag_time.strftime('%H:%M:%S', _diag_time.localtime(t))}."
|
||
f"{int(t * 1000) % 1000:03d}] {line}\n")
|
||
except Exception:
|
||
pass
|
||
import uuid # <--- 🌟 加上这一行!
|
||
from ui.views.chat_bridge import ChatBridge # 🌟 新增:引入桥接器
|
||
from core import renderer_backend as _renderer_backend # P1-03 渲染器矩阵(纯 stdlib)
|
||
# 🆕 WebView2 后端(A′ 双轨:Windows 首选,任何失败自动回落 QtWebEngine)
|
||
# P1-03:非 Windows 永不导入 core.webview2(不触达 pythonnet/Win32 API/WebView2 DLL/taskkill)
|
||
_wv2mod = None
|
||
if sys.platform == "win32":
|
||
try:
|
||
from core import webview2 as _wv2mod
|
||
except Exception as _wv2_imp_err: # pythonnet 缺失等
|
||
_wv2mod = None
|
||
print(f"[WV2] 后端不可用(回落 QtWebEngine): {_wv2_imp_err}")
|
||
from ui.views.system_tools.screen_capture import ScreenCaptureOverlay # 🌟 截图覆盖层
|
||
from ui.views.system_tools.global_hotkey import GlobalHotkeyThread # 🌟 全局热键监听
|
||
from ui.views.system_tools import desktop_session # P1-04 平台会话/能力路由(纯 stdlib)
|
||
from core.db_manager import DBManager
|
||
from core.debug_log import debug_log, poll_debug_cmd # 🆕 调试窗口文件协议(独立于项目树)
|
||
from ui.views.system_tools.file_reader import read_text_file, BINARY_EXTS # 🌟 文本/代码文件读取(编码探测+二进制探测)
|
||
from ui.views.bash_panel import BashPanel # 🆕 右侧任务面板(bash 任务监控)
|
||
from tools.builtin_tools.pdf_reader import extract_pdf_text, extract_pdf_images # 🌟 PDF 工具(文本+图片解析)
|
||
from PyQt6 import QtWidgets, QtCore, QtGui
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
设置窗口 (SettingsWindow) - 完整改进版
|
||
修复了:
|
||
1. 右侧按钮卡边问题(优化内边距)
|
||
2. 居中虚化遮罩(固定卡片尺寸 + 全屏半透明背景)
|
||
3. 模型垂直排列(改用 QVBoxLayout)
|
||
"""
|
||
class AttachmentPreviewOverlay(QtWidgets.QWidget):
|
||
"""🌟 原生附件预览覆盖层 (全屏半透明遮罩 + 居中卡片 + 现代UI + 一键复制)
|
||
|
||
🆕 必须是无边框顶层窗口(独立 HWND):WebView2 是原生子窗,永远绘制在主窗口
|
||
内所有 Qt 控件之上 —— 若预览层是 bg_widget 的子控件,遮罩盖不住 webview
|
||
(webview 显得特别亮),居中的卡片会被压到 webview 下面。独立顶层窗口
|
||
+ WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗。
|
||
"""
|
||
def __init__(self, meta_dict, parent=None, siblings=None, index=0, att_data=None):
|
||
super().__init__(parent)
|
||
self.meta = meta_dict
|
||
self._siblings = siblings if siblings else [meta_dict] # 同组可翻页图片(单图时即自身)
|
||
self._index = index
|
||
self._att_data = att_data # PDF 附件字典(提供时显示“选择”按钮并回写 selected_images)
|
||
# 🆕 无边框顶层 Tool 窗(不入任务栏,Windows 上默认浮在父窗之上)
|
||
self._main = parent.window() if parent is not None else None
|
||
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||
if self._main is not None:
|
||
# 完全覆盖主窗口(含边框);移动/大小变化由 eventFilter 跟随
|
||
self.setGeometry(self._main.frameGeometry())
|
||
self._main.installEventFilter(self)
|
||
|
||
# 灰色半透明遮罩
|
||
self._overlay_color = QtGui.QColor(0, 0, 0, 100)
|
||
|
||
# 主卡片
|
||
self.card = QtWidgets.QFrame(self)
|
||
self.card.setObjectName("preview_card")
|
||
self.card.setStyleSheet("""
|
||
#preview_card { background: #ffffff; border-radius: 12px; border: 1px solid #dcdcdc; }
|
||
""")
|
||
|
||
lay = QtWidgets.QVBoxLayout(self.card)
|
||
lay.setContentsMargins(20, 16, 20, 20)
|
||
lay.setSpacing(12)
|
||
|
||
# 🌟 统一的现代滚动条样式
|
||
modern_scrollbar_qss = """
|
||
QScrollBar:vertical {
|
||
border: none; background: transparent; width: 8px; margin: 0px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #d0d0d0; min-height: 40px; border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: #a0a0a0;
|
||
}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||
height: 0px; border: none; background: transparent;
|
||
}
|
||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
||
background: transparent;
|
||
}
|
||
QScrollBar:horizontal {
|
||
border: none; background: transparent; height: 8px; margin: 0px;
|
||
}
|
||
QScrollBar::handle:horizontal {
|
||
background: #d0d0d0; min-width: 40px; border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:horizontal:hover {
|
||
background: #a0a0a0;
|
||
}
|
||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||
width: 0px; border: none; background: transparent;
|
||
}
|
||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
|
||
background: transparent;
|
||
}
|
||
"""
|
||
|
||
# 标题栏
|
||
title_lay = QtWidgets.QHBoxLayout()
|
||
title_label = QtWidgets.QLabel(self.meta.get("name", "文本内容"))
|
||
self._title_label = title_label
|
||
title_label.setStyleSheet("""
|
||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||
font-size: 16px; font-weight: bold; color: #222; background: transparent;
|
||
""")
|
||
title_lay.addWidget(title_label)
|
||
title_lay.addStretch()
|
||
|
||
# 🌟 核心新增:如果是长文本,添加“复制”按钮
|
||
if self.meta.get("type") != "image":
|
||
self.btn_copy = QtWidgets.QPushButton("复制")
|
||
self.btn_copy.setFixedSize(64, 28)
|
||
self.btn_copy.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self._set_copy_btn_style(self.btn_copy, False) # 设置初始样式
|
||
self.btn_copy.clicked.connect(self._on_copy_clicked)
|
||
title_lay.addWidget(self.btn_copy)
|
||
title_lay.addSpacing(8) # 复制按钮和关闭按钮之间留点空隙
|
||
|
||
# PDF 解析图:添加“选择”按钮(选中后绿色边框,行为对标复制按钮)
|
||
if self.meta.get("type") == "image" and self._att_data is not None:
|
||
self.btn_select = QtWidgets.QPushButton("选择")
|
||
self.btn_select.setFixedSize(64, 28)
|
||
self.btn_select.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self._set_select_btn_style(self._is_current_selected())
|
||
self.btn_select.clicked.connect(self._on_select_clicked)
|
||
title_lay.addWidget(self.btn_select)
|
||
title_lay.addSpacing(8) # 选择按钮和关闭按钮之间留点空隙
|
||
|
||
btn_x = QtWidgets.QPushButton("✕")
|
||
btn_x.setFixedSize(28, 28)
|
||
btn_x.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn_x.setStyleSheet("""
|
||
QPushButton {
|
||
font-family: "Microsoft YaHei UI", sans-serif;
|
||
background: transparent; border: none; border-radius: 14px; color: #999; font-size: 15px;
|
||
}
|
||
QPushButton:hover { background: #eee; color: #333; }
|
||
""")
|
||
btn_x.clicked.connect(self.close_overlay)
|
||
title_lay.addWidget(btn_x)
|
||
lay.addLayout(title_lay)
|
||
|
||
# 内容区 (图片或文本)
|
||
if self.meta.get("type") == "image":
|
||
import os
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
abs_path = os.path.join(root_dir, self.meta.get("local_path", ""))
|
||
|
||
scroll = QtWidgets.QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setStyleSheet(f"""
|
||
QScrollArea {{ border: 1px solid #eee; border-radius: 8px; background: #f9f9f9; }}
|
||
{modern_scrollbar_qss}
|
||
""")
|
||
|
||
img_label = QtWidgets.QLabel()
|
||
img_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||
img_label.setStyleSheet("background: transparent;")
|
||
self._img_label = img_label # 翻页时复用它来切换图片
|
||
|
||
if os.path.exists(abs_path):
|
||
pixmap = QtGui.QPixmap(abs_path)
|
||
if pixmap.width() > 800 or pixmap.height() > 600:
|
||
pixmap = pixmap.scaled(800, 600, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation)
|
||
img_label.setPixmap(pixmap)
|
||
else:
|
||
img_label.setText("❌ 图片文件已丢失或被物理删除")
|
||
img_label.setStyleSheet('font-family: "Microsoft YaHei UI", sans-serif; color: red; font-size: 14px;')
|
||
|
||
scroll.setWidget(img_label)
|
||
lay.addWidget(scroll)
|
||
self.card.setFixedSize(860, 680)
|
||
else:
|
||
text_edit = QtWidgets.QTextEdit()
|
||
text_edit.setReadOnly(True)
|
||
text_edit.setPlainText(self.meta.get("content", ""))
|
||
text_edit.setStyleSheet(f"""
|
||
QTextEdit {{
|
||
border: 1px solid #eee; border-radius: 8px; padding: 12px;
|
||
background: #fafafa; color: #333;
|
||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||
font-size: 14px; line-height: 1.5;
|
||
}}
|
||
{modern_scrollbar_qss}
|
||
""")
|
||
lay.addWidget(text_edit)
|
||
self.card.setFixedSize(600, 500)
|
||
|
||
self._center_card()
|
||
# 多张图片(如 PDF 解析图)时,叠加左右翻页按钮 + 页码计数
|
||
if self.meta.get("type") == "image" and len(self._siblings) > 1:
|
||
self._add_nav_buttons()
|
||
self.show()
|
||
self.raise_()
|
||
self.activateWindow()
|
||
self._animate_in()
|
||
|
||
# ==================== 🌟 复制功能相关方法 ====================
|
||
def _on_copy_clicked(self):
|
||
"""一键复制到剪贴板,并触发按钮变色动画"""
|
||
text = self.meta.get("content", "")
|
||
if text:
|
||
# 写入系统剪贴板
|
||
QtWidgets.QApplication.clipboard().setText(text)
|
||
|
||
# 按钮变绿反馈
|
||
self.btn_copy.setText("已复制")
|
||
self._set_copy_btn_style(self.btn_copy, is_copied=True)
|
||
|
||
# 1.5秒后恢复原状
|
||
QtCore.QTimer.singleShot(1500, lambda: self._restore_copy_btn())
|
||
|
||
def _restore_copy_btn(self):
|
||
"""恢复复制按钮状态"""
|
||
try:
|
||
self.btn_copy.setText("复制")
|
||
self._set_copy_btn_style(self.btn_copy, is_copied=False)
|
||
except RuntimeError:
|
||
pass # 防止窗口已经关闭导致报错
|
||
|
||
def _set_copy_btn_style(self, btn, is_copied):
|
||
"""统一管理复制按钮的 QSS 样式"""
|
||
if is_copied:
|
||
# 成功状态:浅绿色背景,深绿色文字
|
||
btn.setStyleSheet("""
|
||
QPushButton {
|
||
font-family: "Microsoft YaHei UI", sans-serif;
|
||
background: #e6f4ea; border: none; border-radius: 6px; color: #1e8e3e; font-size: 13px; font-weight: bold;
|
||
}
|
||
""")
|
||
else:
|
||
# 默认状态:浅灰色背景,深灰色文字
|
||
btn.setStyleSheet("""
|
||
QPushButton {
|
||
font-family: "Microsoft YaHei UI", sans-serif;
|
||
background: #f0f0f0; border: none; border-radius: 6px; color: #555; font-size: 13px;
|
||
}
|
||
QPushButton:hover { background: #e0e0e0; color: #333; }
|
||
""")
|
||
# =========================================================
|
||
|
||
# ==================== 🌟 图片选择功能相关方法(PDF 解析图) ====================
|
||
def _current_image_dict(self):
|
||
"""当前预览图对应的完整 image dict(用于写入 selected_images)。"""
|
||
if not (self._att_data and self._att_data.get("images")):
|
||
return None
|
||
lp = self._siblings[self._index].get("local_path")
|
||
return next((x for x in self._att_data["images"] if x.get("local_path") == lp), None)
|
||
|
||
def _is_current_selected(self):
|
||
"""当前预览图是否已在 selected_images 中。"""
|
||
if not self._att_data:
|
||
return False
|
||
lp = self._siblings[self._index].get("local_path")
|
||
return any(s.get("local_path") == lp for s in self._att_data.get("selected_images", []))
|
||
|
||
def _on_select_clicked(self):
|
||
"""切换当前图的选中态,并回写 att_data["selected_images"](重开弹窗勾选仍在)。"""
|
||
im = self._current_image_dict()
|
||
if im is None:
|
||
return
|
||
sel = self._att_data.setdefault("selected_images", [])
|
||
lp = im["local_path"]
|
||
if any(s.get("local_path") == lp for s in sel):
|
||
self._att_data["selected_images"] = [s for s in sel if s.get("local_path") != lp]
|
||
selected = False
|
||
else:
|
||
sel.append(im)
|
||
selected = True
|
||
self._set_select_btn_style(selected)
|
||
|
||
def _set_select_btn_style(self, selected):
|
||
"""统一管理选择按钮的 QSS 样式:选中=绿色边框,未选=灰底。"""
|
||
if selected:
|
||
self.btn_select.setText("已选择")
|
||
self.btn_select.setStyleSheet("""
|
||
QPushButton {
|
||
font-family: "Microsoft YaHei UI", sans-serif;
|
||
background: #e6f4ea; border: 1px solid #1e8e3e; border-radius: 6px;
|
||
color: #1e8e3e; font-size: 13px; font-weight: bold;
|
||
}
|
||
""")
|
||
else:
|
||
self.btn_select.setText("选择")
|
||
self.btn_select.setStyleSheet("""
|
||
QPushButton {
|
||
font-family: "Microsoft YaHei UI", sans-serif;
|
||
background: #f0f0f0; border: 1px solid transparent; border-radius: 6px;
|
||
color: #555; font-size: 13px;
|
||
}
|
||
QPushButton:hover { background: #e0e0e0; color: #333; }
|
||
""")
|
||
# =========================================================
|
||
|
||
def _center_card(self):
|
||
x = (self.width() - self.card.width()) // 2
|
||
y = (self.height() - self.card.height()) // 2
|
||
self.card.move(x, y)
|
||
|
||
def paintEvent(self, event):
|
||
painter = QtGui.QPainter(self)
|
||
painter.fillRect(self.rect(), self._overlay_color)
|
||
painter.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
if not self.card.geometry().contains(event.pos()):
|
||
self.close_overlay()
|
||
|
||
def resizeEvent(self, event):
|
||
self._center_card()
|
||
|
||
def eventFilter(self, obj, ev):
|
||
"""跟随主窗口移动/大小变化;主窗口隐藏(最小化)时自动关闭预览。"""
|
||
if self._main is not None and obj is self._main:
|
||
t = ev.type()
|
||
if t == QtCore.QEvent.Type.Move:
|
||
self.move(self._main.frameGeometry().topLeft())
|
||
elif t == QtCore.QEvent.Type.Resize:
|
||
self.setGeometry(self._main.frameGeometry())
|
||
self._center_card()
|
||
elif t in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
|
||
self.close_overlay()
|
||
return super().eventFilter(obj, ev)
|
||
|
||
def _animate_in(self):
|
||
# 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠)
|
||
self.setWindowOpacity(0.0)
|
||
self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||
self._anim.setDuration(150)
|
||
self._anim.setStartValue(0.0)
|
||
self._anim.setEndValue(1.0)
|
||
self._anim.start()
|
||
|
||
def close_overlay(self):
|
||
self.deleteLater()
|
||
|
||
# ==================== 🌟 图片翻页功能(PDF 解析图等多图场景) ====================
|
||
def _load_image(self, local_path):
|
||
"""按相对路径加载图片到 self._img_label,超出 800x600 则等比缩放。"""
|
||
import os
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
abs_path = os.path.join(root_dir, local_path)
|
||
if os.path.exists(abs_path):
|
||
pixmap = QtGui.QPixmap(abs_path)
|
||
if pixmap.width() > 800 or pixmap.height() > 600:
|
||
pixmap = pixmap.scaled(800, 600, QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||
QtCore.Qt.TransformationMode.SmoothTransformation)
|
||
self._img_label.setStyleSheet("background: transparent;")
|
||
self._img_label.setPixmap(pixmap)
|
||
else:
|
||
self._img_label.clear()
|
||
self._img_label.setText("❌ 图片文件已丢失或被物理删除")
|
||
self._img_label.setStyleSheet(
|
||
'font-family: "Microsoft YaHei UI", sans-serif; color: red; font-size: 14px;')
|
||
|
||
def _add_nav_buttons(self):
|
||
"""在卡片左右两侧叠加圆形翻页按钮(页码信息改由顶部标题展示)。"""
|
||
nav_qss = """
|
||
QPushButton {
|
||
background: rgba(0,0,0,0.35); border: none; border-radius: 20px;
|
||
color: #ffffff; font-size: 24px; font-weight: bold;
|
||
}
|
||
QPushButton:hover { background: rgba(0,0,0,0.55); }
|
||
"""
|
||
self.btn_prev = QtWidgets.QPushButton("‹", self.card)
|
||
self.btn_next = QtWidgets.QPushButton("›", self.card)
|
||
for b in (self.btn_prev, self.btn_next):
|
||
b.setFixedSize(40, 40)
|
||
b.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
b.setStyleSheet(nav_qss)
|
||
b.raise_()
|
||
self.btn_prev.clicked.connect(self._nav_prev)
|
||
self.btn_next.clicked.connect(self._nav_next)
|
||
cy = self.card.height() // 2 - 20
|
||
self.btn_prev.move(44, cy) # 较初版向内收 30px
|
||
self.btn_next.move(self.card.width() - 84, cy) # 较初版向内收 30px(与左侧对称)
|
||
|
||
def _show_sibling(self):
|
||
meta = self._siblings[self._index]
|
||
self._title_label.setText(meta.get("name", ""))
|
||
self._load_image(meta.get("local_path", ""))
|
||
if getattr(self, "btn_select", None) is not None: # 翻页后刷新“选择”按钮状态
|
||
self._set_select_btn_style(self._is_current_selected())
|
||
|
||
def _nav_prev(self):
|
||
self._index = (self._index - 1) % len(self._siblings)
|
||
self._show_sibling()
|
||
|
||
def _nav_next(self):
|
||
self._index = (self._index + 1) % len(self._siblings)
|
||
self._show_sibling()
|
||
# =====================================================================
|
||
|
||
|
||
class SettingsWindow(QtWidgets.QWidget):
|
||
"""设置窗口:虚化遮罩居中自适应卡片"""
|
||
close_requested = QtCore.pyqtSignal()
|
||
|
||
def __init__(self, parent=None, config_data=None):
|
||
super().__init__(parent)
|
||
self.config_data = config_data or {}
|
||
self._setup_ui()
|
||
self._setup_animation()
|
||
|
||
# ------------------------------------------------------------------
|
||
def _setup_ui(self):
|
||
# 全屏透明遮罩(只负责背景展示,不负责关闭)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint)
|
||
|
||
self.overlay = QtWidgets.QWidget(self)
|
||
self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);")
|
||
# 🌟 遮罩不负责关闭,只响应空白区域
|
||
|
||
# 主卡片(居中,动态尺寸)
|
||
self.main_card = QtWidgets.QWidget(self.overlay)
|
||
self.main_card.setStyleSheet("background-color: #ffffff; border-radius: 12px;")
|
||
# 🌟 关键:安装事件过滤器,阻止点击冒泡到 overlay
|
||
self.main_card.installEventFilter(self)
|
||
|
||
# 内部垂直布局
|
||
self._vlayout = QtWidgets.QVBoxLayout(self.main_card)
|
||
self._vlayout.setContentsMargins(0, 0, 0, 0)
|
||
self._vlayout.setSpacing(0)
|
||
|
||
self._build_topbar()
|
||
|
||
body = QtWidgets.QWidget()
|
||
body_layout = QtWidgets.QHBoxLayout(body)
|
||
body_layout.setContentsMargins(0, 0, 0, 0)
|
||
body_layout.setSpacing(0)
|
||
|
||
self.sidebar = QtWidgets.QWidget()
|
||
self.sidebar.setStyleSheet("background-color: #f7f7f7; border-radius: 0 0 0 12px;")
|
||
self._build_sidebar()
|
||
body_layout.addWidget(self.sidebar)
|
||
|
||
self.content_stack = QtWidgets.QStackedWidget()
|
||
self.content_stack.addWidget(self._build_provider_section())
|
||
self.content_stack.addWidget(self._build_placeholder_section())
|
||
body_layout.addWidget(self.content_stack, 1)
|
||
|
||
self._vlayout.addWidget(body)
|
||
|
||
self.resize_to_parent()
|
||
self.content_stack.setCurrentWidget(self.content_stack.widget(0))
|
||
|
||
# ------------------------------------------------------------------
|
||
def eventFilter(self, obj, event):
|
||
"""🌟 核心:拦截 main_card 上的鼠标点击,防止冒泡到 overlay 触发关闭"""
|
||
if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress:
|
||
event.ignore() # 阻止冒泡
|
||
return True
|
||
return super().eventFilter(obj, event)
|
||
|
||
# ------------------------------------------------------------------
|
||
def _build_topbar(self):
|
||
topbar = QtWidgets.QWidget()
|
||
topbar.setFixedHeight(48)
|
||
topbar.setStyleSheet("background-color: #ffffff; border-radius: 12px 12px 0 0;")
|
||
topbar_layout = QtWidgets.QHBoxLayout(topbar)
|
||
topbar_layout.setContentsMargins(24, 0, 8, 0)
|
||
topbar_layout.setSpacing(0)
|
||
|
||
# 🌟 用 layout 自动推 close_btn 到最右边
|
||
title = QtWidgets.QLabel("设置")
|
||
title.setStyleSheet(
|
||
"font-size: 15px; font-weight: 600; color: #333; background: transparent;"
|
||
)
|
||
topbar_layout.addWidget(title)
|
||
topbar_layout.addStretch()
|
||
|
||
close_btn = QtWidgets.QPushButton("✕")
|
||
close_btn.setFixedSize(40, 40)
|
||
close_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
close_btn.setStyleSheet(
|
||
"QPushButton { background: transparent; border: none; "
|
||
"font-size: 16px; color: #999; border-radius: 8px; } "
|
||
"QPushButton:hover { background-color: #f0f0f0; color: #555; }"
|
||
)
|
||
close_btn.clicked.connect(lambda: self._close(immediate=True))
|
||
|
||
topbar_layout.addWidget(close_btn)
|
||
|
||
self._vlayout.addWidget(topbar)
|
||
|
||
# ------------------------------------------------------------------
|
||
def _build_sidebar(self):
|
||
sidebar_layout = QtWidgets.QVBoxLayout(self.sidebar)
|
||
sidebar_layout.setContentsMargins(0, 16, 0, 16)
|
||
sidebar_layout.setSpacing(4)
|
||
|
||
nav_data = [
|
||
("模型提供商", "provider"),
|
||
("该功能未开发", "placeholder"),
|
||
]
|
||
self.nav_buttons = {}
|
||
for label, key in nav_data:
|
||
btn = QtWidgets.QPushButton(label)
|
||
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn.setStyleSheet(
|
||
"QPushButton { "
|
||
" background: transparent; border: none; "
|
||
" text-align: left; padding: 10px 20px; "
|
||
" font-size: 14px; color: #555; "
|
||
"} "
|
||
"QPushButton:hover { background-color: #e8e8e8; color: #333; } "
|
||
"QPushButton.active { "
|
||
" background-color: #e3f2fd; color: #1976d2; font-weight: 500; "
|
||
" border-left: 3px solid #1976d2; "
|
||
"} "
|
||
)
|
||
self.nav_buttons[key] = btn
|
||
sidebar_layout.addWidget(btn)
|
||
|
||
self.nav_buttons["provider"].setProperty("class", "active")
|
||
self.nav_buttons["provider"].clicked.connect(lambda: self._show_section("provider"))
|
||
self.nav_buttons["placeholder"].clicked.connect(lambda: self._show_placeholder_toast())
|
||
sidebar_layout.addStretch()
|
||
|
||
# ------------------------------------------------------------------
|
||
def _build_provider_section(self) -> QtWidgets.QWidget:
|
||
page = QtWidgets.QWidget()
|
||
layout = QtWidgets.QVBoxLayout(page)
|
||
layout.setContentsMargins(24, 20, 24, 20)
|
||
layout.setSpacing(12)
|
||
|
||
header = QtWidgets.QWidget()
|
||
header_layout = QtWidgets.QHBoxLayout(header)
|
||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
header_title = QtWidgets.QLabel("模型提供商")
|
||
header_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #111;")
|
||
header_layout.addWidget(header_title)
|
||
header_layout.addStretch()
|
||
|
||
add_btn = QtWidgets.QPushButton("+ 新建")
|
||
add_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
add_btn.setFixedHeight(28)
|
||
add_btn.setStyleSheet(
|
||
"QPushButton { "
|
||
" background-color: #1976d2; color: #fff; border: none; "
|
||
" padding: 0 14px; border-radius: 6px; font-size: 12px; font-weight: 500; "
|
||
"} "
|
||
"QPushButton:hover { background-color: #1565c0; }"
|
||
)
|
||
header_layout.addWidget(add_btn)
|
||
layout.addWidget(header)
|
||
|
||
scroll = QtWidgets.QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setStyleSheet(
|
||
"QScrollArea { border: none; background: transparent; } "
|
||
"QScrollBar:vertical { width: 5px; background: #f0f0f0; border-radius: 3px; } "
|
||
"QScrollBar::handle { background: #ccc; border-radius: 3px; }"
|
||
)
|
||
|
||
inner = QtWidgets.QWidget()
|
||
inner_layout = QtWidgets.QVBoxLayout(inner)
|
||
inner_layout.setContentsMargins(0, 4, 0, 0)
|
||
inner_layout.setSpacing(10)
|
||
|
||
providers = self.config_data.get("providers", {})
|
||
if not providers:
|
||
empty = QtWidgets.QLabel("暂无配置")
|
||
empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
|
||
empty.setStyleSheet("color: #bbb; font-size: 14px; padding: 40px;")
|
||
inner_layout.addWidget(empty)
|
||
else:
|
||
for provider_name, provider_config in providers.items():
|
||
card = self._build_provider_card(provider_name, provider_config)
|
||
inner_layout.addWidget(card)
|
||
|
||
inner_layout.addStretch()
|
||
scroll.setWidget(inner)
|
||
layout.addWidget(scroll, 1)
|
||
|
||
return page
|
||
|
||
# ------------------------------------------------------------------
|
||
def _build_provider_card(self, name: str, config: dict) -> QtWidgets.QWidget:
|
||
card = QtWidgets.QWidget()
|
||
card.setStyleSheet(
|
||
"background-color: #fafafa; border-radius: 10px; "
|
||
"border: 1px solid #e8e8e8;"
|
||
)
|
||
layout = QtWidgets.QVBoxLayout(card)
|
||
layout.setContentsMargins(16, 14, 16, 14)
|
||
layout.setSpacing(10)
|
||
|
||
row1 = QtWidgets.QHBoxLayout()
|
||
row1.setSpacing(10)
|
||
|
||
name_label = QtWidgets.QLabel(f"🤖 {name}")
|
||
name_label.setStyleSheet("font-size: 14px; font-weight: 600; color: #222;")
|
||
row1.addWidget(name_label)
|
||
row1.addStretch()
|
||
|
||
edit_btn = QtWidgets.QPushButton("API Key")
|
||
edit_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
edit_btn.setFixedHeight(26)
|
||
edit_btn.setStyleSheet(
|
||
"QPushButton { "
|
||
" background-color: #f0f0f0; color: #666; border: none; "
|
||
" padding: 0 12px; border-radius: 5px; font-size: 12px; "
|
||
"} "
|
||
"QPushButton:hover { background-color: #e0e0e0; color: #333; }"
|
||
)
|
||
row1.addWidget(edit_btn)
|
||
|
||
add_btn = QtWidgets.QPushButton("+模型")
|
||
add_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
add_btn.setFixedHeight(26)
|
||
add_btn.setStyleSheet(
|
||
"QPushButton { "
|
||
" background-color: #e3f2fd; color: #1976d2; border: none; "
|
||
" padding: 0 12px; border-radius: 5px; font-size: 12px; font-weight: 500; "
|
||
"} "
|
||
"QPushButton:hover { background-color: #bbdefb; }"
|
||
)
|
||
row1.addWidget(add_btn)
|
||
layout.addLayout(row1)
|
||
|
||
# 🌟 第二行:模型垂直排列,加左缩进,不超出边框
|
||
models = config.get("models", [])
|
||
if models:
|
||
models_wrap = QtWidgets.QWidget()
|
||
models_layout = QtWidgets.QVBoxLayout(models_wrap)
|
||
models_layout.setContentsMargins(4, 4, 0, 4) # 左4px,右0px
|
||
models_layout.setSpacing(5)
|
||
|
||
for model_name in models:
|
||
mbtn = QtWidgets.QPushButton(model_name)
|
||
mbtn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开
|
||
mbtn.setStyleSheet(
|
||
"QPushButton { "
|
||
" background-color: #e8f4fd; color: #1976d2; "
|
||
" border: 1px solid #b3d9f7; "
|
||
" padding: 0 12px; border-radius: 5px; "
|
||
" font-size: 13px; text-align: left; "
|
||
"} "
|
||
"QPushButton:hover { background-color: #d0ebff; }"
|
||
)
|
||
mbtn.clicked.connect(
|
||
lambda checked, m=model_name: print(f"[设置] 选中模型: {m}")
|
||
)
|
||
models_layout.addWidget(mbtn)
|
||
|
||
layout.addWidget(models_wrap)
|
||
else:
|
||
empty = QtWidgets.QLabel("暂无模型")
|
||
empty.setStyleSheet(
|
||
"font-size: 12px; color: #aaa; font-style: italic; padding-left: 4px;"
|
||
)
|
||
layout.addWidget(empty)
|
||
|
||
return card
|
||
|
||
# ------------------------------------------------------------------
|
||
def _build_placeholder_section(self) -> QtWidgets.QWidget:
|
||
page = QtWidgets.QWidget()
|
||
layout = QtWidgets.QVBoxLayout(page)
|
||
layout.setContentsMargins(24, 20, 24, 20)
|
||
layout.addStretch()
|
||
label = QtWidgets.QLabel("🚧 该功能未开发")
|
||
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
|
||
label.setStyleSheet("font-size: 16px; color: #bbb;")
|
||
layout.addWidget(label)
|
||
layout.addStretch()
|
||
return page
|
||
|
||
# ------------------------------------------------------------------
|
||
def _show_section(self, key: str):
|
||
idx = 0 if key == "provider" else 1
|
||
self.content_stack.setCurrentIndex(idx)
|
||
for k, btn in self.nav_buttons.items():
|
||
btn.setProperty("class", "active" if k == key else "")
|
||
btn.style().unpolish(btn)
|
||
btn.style().polish(btn)
|
||
|
||
# ------------------------------------------------------------------
|
||
def _show_placeholder_toast(self):
|
||
toast = QtWidgets.QLabel(self.main_card)
|
||
toast.setText("🚧 该功能未开发")
|
||
toast.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||
toast.setFixedSize(160, 34)
|
||
toast.move(
|
||
(self.main_card.width() - toast.width()) // 2,
|
||
self.main_card.height() - 70
|
||
)
|
||
toast.setStyleSheet(
|
||
"background-color: #333; color: #fff; border-radius: 8px; font-size: 13px;"
|
||
)
|
||
toast.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
toast.show()
|
||
QtCore.QTimer.singleShot(1500, toast.deleteLater)
|
||
|
||
# ------------------------------------------------------------------
|
||
def resize_to_parent(self):
|
||
parent = self.parentWidget()
|
||
if not parent:
|
||
return
|
||
self.setFixedSize(parent.size())
|
||
self.overlay.setFixedSize(self.size())
|
||
|
||
W = int(self.width() * 0.8)
|
||
H = int(self.height() * 0.88)
|
||
self.main_card.setFixedSize(W, H)
|
||
self.main_card.move(
|
||
(self.width() - W) // 2,
|
||
(self.height() - H) // 2
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
def _setup_animation(self):
|
||
self._anim = QtCore.QPropertyAnimation(self.overlay, b"windowOpacity")
|
||
self._anim.setDuration(500)
|
||
self._anim.setStartValue(0.0)
|
||
self._anim.setEndValue(1.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
def show_with_animation(self):
|
||
# 🌟 关键:先 setFixedSize 再 show,否则 show() 时瞬间以极小尺寸闪一下
|
||
self.setFixedSize(self.parentWidget().size())
|
||
self.overlay.setFixedSize(self.size())
|
||
self.overlay.setGraphicsEffect(None) # 重置之前可能的 effect
|
||
|
||
# 🌟 初始 opacity=0,再 show 出来,然后做正向动画淡入
|
||
self._anim.setStartValue(0.0)
|
||
self._anim.setEndValue(1.0)
|
||
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward)
|
||
self.show()
|
||
self._anim.start()
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
def _close(self, immediate=False):
|
||
"""关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画"""
|
||
if immediate:
|
||
self._do_close()
|
||
else:
|
||
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Backward)
|
||
self._anim.finished.connect(self._on_close_finished)
|
||
self._anim.start()
|
||
|
||
def _do_close(self):
|
||
"""直接关闭,不播放动画"""
|
||
try:
|
||
self._anim.finished.disconnect(self._on_close_finished)
|
||
except TypeError:
|
||
pass
|
||
|
||
self.hide()
|
||
parent = self.parent()
|
||
if parent:
|
||
for attr_name in dir(parent):
|
||
try:
|
||
if getattr(parent, attr_name, None) is self:
|
||
setattr(parent, attr_name, None)
|
||
break
|
||
except Exception:
|
||
pass
|
||
self.deleteLater()
|
||
self.close_requested.emit()
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
def _on_close_finished(self):
|
||
self._anim.finished.disconnect(self._on_close_finished)
|
||
self.hide()
|
||
parent = self.parent()
|
||
if parent:
|
||
for attr_name in dir(parent):
|
||
try:
|
||
if getattr(parent, attr_name, None) is self:
|
||
setattr(parent, attr_name, None)
|
||
break
|
||
except Exception:
|
||
pass
|
||
self.deleteLater()
|
||
self.close_requested.emit()
|
||
|
||
# ------------------------------------------------------------------
|
||
def keyPressEvent(self, event):
|
||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||
self._close()
|
||
|
||
|
||
|
||
class HLine(QtWidgets.QFrame):
|
||
"""水平分隔线"""
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setFixedHeight(1)
|
||
self.setStyleSheet("background-color: #e8e8e8; border: none;")
|
||
|
||
|
||
class PasteAwareTextEdit(QtWidgets.QPlainTextEdit):
|
||
"""拦截粘贴/拖拽操作:图片自动识别,超长文本自动折叠,并修复中文输入法占位符重叠Bug"""
|
||
long_text_pasted = QtCore.pyqtSignal(str)
|
||
image_pasted = QtCore.pyqtSignal(object) # 传递 QImage 或文件路径列表(str)
|
||
files_pasted = QtCore.pyqtSignal(list) # 传递文本/代码文件路径列表(拖拽/粘贴/上传)
|
||
|
||
LONG_TEXT_THRESHOLD = 2000
|
||
LONG_TEXT_LINES = 50
|
||
IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif')
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self._original_placeholder = ""
|
||
self._is_composing = False # 标记是否正在使用输入法
|
||
self.setAcceptDrops(True) # 允许拖拽接收
|
||
|
||
def setPlaceholderText(self, text: str):
|
||
"""重写设置占位符方法,保存原始文本"""
|
||
if not self._is_composing:
|
||
self._original_placeholder = text
|
||
super().setPlaceholderText(text)
|
||
|
||
def inputMethodEvent(self, event: QtGui.QInputMethodEvent):
|
||
"""拦截输入法事件,修复拼音与占位符重叠"""
|
||
# 检查是否有正在输入的拼音字母 (pre-edit string)
|
||
if event.preeditString():
|
||
if not self._is_composing:
|
||
self._is_composing = True
|
||
# 输入法激活时,临时清空占位符
|
||
super().setPlaceholderText("")
|
||
else:
|
||
if self._is_composing:
|
||
self._is_composing = False
|
||
# 输入法提交或取消时,恢复原始占位符
|
||
super().setPlaceholderText(self._original_placeholder)
|
||
|
||
# 必须调用父类方法,否则输入法无法正常打字
|
||
super().inputMethodEvent(event)
|
||
|
||
def insertFromMimeData(self, source: QtCore.QMimeData):
|
||
"""拦截粘贴操作:优先识别图片,其次超长文本折叠,最后普通文本"""
|
||
# 1. 优先检查剪切板中是否有图片(如微信截图、QQ截图等)
|
||
if source and source.hasImage():
|
||
image = source.imageData()
|
||
if isinstance(image, QtGui.QImage):
|
||
self.image_pasted.emit(image)
|
||
return
|
||
|
||
# 2. 检查剪切板中的文件 URL:图片走图片管线,其余交给文件管线探测
|
||
if source and source.hasUrls():
|
||
image_paths, other_paths = self._classify_urls(source.urls())
|
||
if image_paths:
|
||
self.image_pasted.emit(image_paths)
|
||
if other_paths:
|
||
self.files_pasted.emit(other_paths)
|
||
if image_paths or other_paths:
|
||
return
|
||
|
||
# 3. 文本粘贴处理(原有逻辑)
|
||
text = source.text() if source else ''
|
||
if not text:
|
||
super().insertFromMimeData(source)
|
||
return
|
||
|
||
line_count = text.count('\n') + 1
|
||
char_count = len(text)
|
||
|
||
if char_count > self.LONG_TEXT_THRESHOLD or line_count > self.LONG_TEXT_LINES:
|
||
self.long_text_pasted.emit(text)
|
||
else:
|
||
super().insertFromMimeData(source)
|
||
|
||
def dragEnterEvent(self, event):
|
||
"""拖拽进入:接受图片数据和图片文件"""
|
||
mime = event.mimeData()
|
||
if mime.hasImage() or mime.hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
super().dragEnterEvent(event)
|
||
|
||
def dragMoveEvent(self, event):
|
||
"""拖拽移动:保持接受状态"""
|
||
mime = event.mimeData()
|
||
if mime.hasImage() or mime.hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
super().dragMoveEvent(event)
|
||
|
||
def dropEvent(self, event):
|
||
"""拖拽放下:提取图片并发送信号"""
|
||
mime = event.mimeData()
|
||
|
||
# 1. 直接拖拽的图片数据(如从浏览器拖入的图片)
|
||
if mime.hasImage():
|
||
image = mime.imageData()
|
||
if isinstance(image, QtGui.QImage):
|
||
self.image_pasted.emit(image)
|
||
event.acceptProposedAction()
|
||
return
|
||
|
||
# 2. 拖拽的文件:图片走图片管线,其余交给文件管线探测
|
||
if mime.hasUrls():
|
||
image_paths, other_paths = self._classify_urls(mime.urls())
|
||
if image_paths:
|
||
self.image_pasted.emit(image_paths)
|
||
if other_paths:
|
||
self.files_pasted.emit(other_paths)
|
||
if image_paths or other_paths:
|
||
event.acceptProposedAction()
|
||
return
|
||
|
||
super().dropEvent(event)
|
||
|
||
def _classify_urls(self, urls):
|
||
"""把文件 URL 分成 (图片路径, 其他文件路径);跳过文件夹。
|
||
非图片文件(含黑名单)一律交给下游 _on_files_dropped 探测并反馈。"""
|
||
image_paths, other_paths = [], []
|
||
for url in urls:
|
||
if not url.isLocalFile():
|
||
continue
|
||
path = url.toLocalFile()
|
||
if not os.path.isfile(path): # 跳过文件夹
|
||
continue
|
||
ext = os.path.splitext(path)[1].lower()
|
||
(image_paths if ext in self.IMAGE_EXTS else other_paths).append(path)
|
||
return image_paths, other_paths
|
||
|
||
|
||
class DraggableHistoryList(QtWidgets.QListWidget):
|
||
"""长按拖拽排序 + 星标/普通分区 + 禁止跨区 (零崩溃版)"""
|
||
order_changed = QtCore.pyqtSignal(list)
|
||
SEPARATOR_ROLE = QtCore.Qt.ItemDataRole.UserRole + 1
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self.setMouseTracking(True)
|
||
|
||
self._long_press_timer = QtCore.QTimer(self)
|
||
self._long_press_timer.setSingleShot(True)
|
||
self._long_press_timer.setInterval(100)
|
||
self._long_press_timer.timeout.connect(self._on_long_press)
|
||
|
||
# 边缘滚动定时器
|
||
self._scroll_timer = QtCore.QTimer(self)
|
||
self._scroll_timer.setInterval(30)
|
||
self._scroll_timer.timeout.connect(self._do_edge_scroll)
|
||
self._scroll_dir = 0
|
||
|
||
self._dragging = False
|
||
self._drag_item = None
|
||
self._drag_start_row = -1
|
||
self._press_pos = None
|
||
self._ghost = None
|
||
self._ghost_offset = None
|
||
self._drop_anim = None
|
||
self._drag_zone = None
|
||
self._insert_row = -1 # 松手后要插入的目标行
|
||
|
||
# 插入指示线
|
||
self._indicator = QtWidgets.QFrame(self.viewport())
|
||
self._indicator.setFixedHeight(2)
|
||
self._indicator.setStyleSheet("background-color: #4a90d9; border-radius: 1px;")
|
||
self._indicator.hide()
|
||
|
||
# ---------- 工具方法 ----------
|
||
def _is_separator(self, item):
|
||
return item and item.data(self.SEPARATOR_ROLE) == "separator"
|
||
|
||
def _get_zone(self, item):
|
||
if not item or self._is_separator(item):
|
||
return None
|
||
row = self.row(item)
|
||
for i in range(row, -1, -1):
|
||
if self._is_separator(self.item(i)):
|
||
return "normal"
|
||
return "starred"
|
||
|
||
def _find_separator_row(self):
|
||
for i in range(self.count()):
|
||
if self._is_separator(self.item(i)):
|
||
return i
|
||
return -1
|
||
|
||
def _zone_bounds(self, zone):
|
||
"""返回指定区域的 (起始行, 结束行+1)"""
|
||
sep = self._find_separator_row()
|
||
total = self.count()
|
||
if sep < 0:
|
||
return (0, total)
|
||
if zone == "starred":
|
||
return (0, sep)
|
||
else:
|
||
return (sep + 1, total)
|
||
|
||
# ---------- 长按触发 ----------
|
||
def _on_long_press(self):
|
||
if not self._drag_item or self._is_separator(self._drag_item):
|
||
return
|
||
self._dragging = True
|
||
self._drag_zone = self._get_zone(self._drag_item)
|
||
self._drag_start_row = self.row(self._drag_item)
|
||
self._insert_row = self._drag_start_row
|
||
|
||
# 半透明标记原位
|
||
self._drag_item.setForeground(QtGui.QBrush(QtGui.QColor(200, 200, 200)))
|
||
|
||
# 创建幽灵
|
||
rect = self.visualItemRect(self._drag_item)
|
||
w = self.itemWidget(self._drag_item)
|
||
title = w.title_label.text() if w and hasattr(w, 'title_label') else ""
|
||
|
||
self._ghost = QtWidgets.QLabel(self.parentWidget())
|
||
self._ghost.setText(title)
|
||
self._ghost.setFixedSize(rect.width() - 12, rect.height())
|
||
self._ghost.setStyleSheet(
|
||
"background-color: rgba(255,255,255,0.95);"
|
||
"border: 1.5px solid #c0c0c0; border-radius: 8px;"
|
||
"padding: 10px 12px; color: #222; font-size: 13px;"
|
||
)
|
||
self._ghost.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
item_tl = self.mapToParent(rect.topLeft())
|
||
press_parent = self.mapToParent(self._press_pos)
|
||
self._ghost_offset = press_parent - item_tl
|
||
self._ghost.move(item_tl)
|
||
self._ghost.show()
|
||
|
||
shadow = QtWidgets.QGraphicsDropShadowEffect(self._ghost)
|
||
shadow.setBlurRadius(16)
|
||
shadow.setOffset(0, 3)
|
||
shadow.setColor(QtGui.QColor(0, 0, 0, 45))
|
||
self._ghost.setGraphicsEffect(shadow)
|
||
|
||
# ---------- 鼠标事件 ----------
|
||
def mousePressEvent(self, event):
|
||
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||
item = self.itemAt(event.pos())
|
||
if item and not self._is_separator(item):
|
||
self._press_pos = event.pos()
|
||
self._drag_item = item
|
||
self._long_press_timer.start()
|
||
super().mousePressEvent(event)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if self._long_press_timer.isActive() and self._press_pos:
|
||
if (event.pos() - self._press_pos).manhattanLength() > 10:
|
||
self._long_press_timer.stop()
|
||
|
||
if self._dragging and self._ghost:
|
||
# 移动幽灵(轻量操作)
|
||
self._ghost.move(self.mapToParent(event.pos()) - self._ghost_offset)
|
||
|
||
# 计算目标插入行
|
||
y = event.pos().y()
|
||
lo, hi = self._zone_bounds(self._drag_zone)
|
||
best_row = self._drag_start_row
|
||
min_dist = 999999
|
||
|
||
for i in range(lo, hi):
|
||
it = self.item(i)
|
||
if self._is_separator(it):
|
||
continue
|
||
r = self.visualItemRect(it)
|
||
mid = r.top() + r.height() // 2
|
||
d = abs(y - mid)
|
||
if d < min_dist:
|
||
min_dist = d
|
||
best_row = i
|
||
|
||
self._insert_row = best_row
|
||
|
||
# 画指示线(轻量操作)
|
||
if best_row != self._drag_start_row:
|
||
target_item = self.item(best_row)
|
||
if target_item:
|
||
r = self.visualItemRect(target_item)
|
||
if best_row > self._drag_start_row:
|
||
line_y = r.bottom()
|
||
else:
|
||
line_y = r.top()
|
||
self._indicator.setGeometry(8, line_y - 1, self.viewport().width() - 16, 2)
|
||
self._indicator.show()
|
||
else:
|
||
self._indicator.hide()
|
||
|
||
# 边缘滚动
|
||
if y < 30:
|
||
self._scroll_dir = -4
|
||
if not self._scroll_timer.isActive():
|
||
self._scroll_timer.start()
|
||
elif y > self.viewport().height() - 30:
|
||
self._scroll_dir = 4
|
||
if not self._scroll_timer.isActive():
|
||
self._scroll_timer.start()
|
||
else:
|
||
self._scroll_timer.stop()
|
||
return
|
||
|
||
super().mouseMoveEvent(event)
|
||
|
||
def _do_edge_scroll(self):
|
||
self.verticalScrollBar().setValue(
|
||
self.verticalScrollBar().value() + self._scroll_dir
|
||
)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
self._long_press_timer.stop()
|
||
self._scroll_timer.stop()
|
||
self._indicator.hide()
|
||
|
||
if self._dragging:
|
||
if self._drag_item:
|
||
self._drag_item.setForeground(QtGui.QBrush())
|
||
|
||
src = self._drag_start_row
|
||
dst = self._insert_row
|
||
|
||
if self._ghost and src != dst:
|
||
# 落地动画
|
||
target_rect = self.visualItemRect(self.item(dst))
|
||
land_pos = self.mapToParent(target_rect.topLeft())
|
||
|
||
self._drop_anim = QtCore.QPropertyAnimation(self._ghost, b"pos")
|
||
self._drop_anim.setDuration(180)
|
||
self._drop_anim.setStartValue(self._ghost.pos())
|
||
self._drop_anim.setEndValue(land_pos)
|
||
self._drop_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutBack)
|
||
self._drop_anim.finished.connect(lambda: self._finalize_drop(src, dst))
|
||
self._drop_anim.start()
|
||
else:
|
||
self._cleanup()
|
||
|
||
self._dragging = False
|
||
self._drag_zone = None
|
||
return
|
||
|
||
super().mouseReleaseEvent(event)
|
||
|
||
def _finalize_drop(self, src, dst):
|
||
"""动画结束后,一次性完成真实重排"""
|
||
# 🌟 先取数据,再清理
|
||
item_data = None
|
||
if self._drag_item:
|
||
item_data = self._drag_item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||
|
||
self._cleanup()
|
||
|
||
if not item_data or src == dst or src < 0 or dst < 0:
|
||
return
|
||
|
||
self.takeItem(src)
|
||
|
||
new_item = QtWidgets.QListWidgetItem()
|
||
new_item.setData(QtCore.Qt.ItemDataRole.UserRole, item_data)
|
||
new_item.setSizeHint(QtCore.QSize(0, 44))
|
||
self.insertItem(dst, new_item)
|
||
|
||
main_win = self.window()
|
||
if hasattr(main_win, '_create_session_widget'):
|
||
widget = main_win._create_session_widget(item_data)
|
||
self.setItemWidget(new_item, widget)
|
||
|
||
self.setCurrentItem(new_item)
|
||
|
||
new_order = []
|
||
for i in range(self.count()):
|
||
it = self.item(i)
|
||
if not self._is_separator(it):
|
||
sid = it.data(QtCore.Qt.ItemDataRole.UserRole)
|
||
if sid:
|
||
new_order.append(sid)
|
||
if new_order:
|
||
self.order_changed.emit(new_order)
|
||
|
||
|
||
def _cleanup(self):
|
||
if self._ghost:
|
||
self._ghost.deleteLater()
|
||
self._ghost = None
|
||
self._drag_item = None
|
||
self._press_pos = None
|
||
self._insert_row = -1
|
||
self._drag_start_row = -1
|
||
|
||
|
||
|
||
class SessionItemWidget(QtWidgets.QWidget):
|
||
"""会话列表项:标题 + 右侧悬浮 ··· 按钮"""
|
||
menu_requested = QtCore.pyqtSignal(str, QtCore.QPoint) # session_id, 按钮全局坐标
|
||
|
||
def __init__(self, session_id, title, parent=None):
|
||
super().__init__(parent)
|
||
self.session_id = session_id
|
||
self.setMouseTracking(True)
|
||
|
||
# 🟢 改为:
|
||
layout = QtWidgets.QHBoxLayout(self)
|
||
layout.setContentsMargins(10, 0, 6, 0)
|
||
layout.setSpacing(0)
|
||
layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||
|
||
self.title_label = QtWidgets.QLabel(title)
|
||
self.title_label.setStyleSheet("color: #444; font-size: 15px; background: transparent;")
|
||
self.title_label.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
self.menu_btn = QtWidgets.QPushButton("···")
|
||
self.menu_btn.setFixedSize(25, 20)
|
||
self.menu_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self.menu_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent; border: none; border-radius: 6px;
|
||
color: #999; font-size: 16px; font-weight: bold; letter-spacing: 2px;
|
||
padding-bottom: 4px;
|
||
}
|
||
QPushButton:hover { background-color: #e0e0e0; color: #333; }
|
||
""")
|
||
self.menu_btn.setVisible(False)
|
||
self.menu_btn.clicked.connect(self._on_menu_click)
|
||
|
||
layout.addWidget(self.title_label, 1)
|
||
layout.addWidget(self.menu_btn, 0)
|
||
|
||
def set_title(self, title):
|
||
self.title_label.setText(title)
|
||
|
||
def _on_menu_click(self):
|
||
pos = self.menu_btn.mapToGlobal(QtCore.QPoint(0, self.menu_btn.height()))
|
||
self.menu_requested.emit(self.session_id, pos)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
# 只有鼠标在右侧 40px 区域才显示 ···
|
||
in_zone = event.pos().x() > self.width() - 40
|
||
self.menu_btn.setVisible(in_zone)
|
||
super().mouseMoveEvent(event)
|
||
|
||
def leaveEvent(self, event):
|
||
self.menu_btn.setVisible(False)
|
||
super().leaveEvent(event)
|
||
class SessionContextPopup(QtWidgets.QWidget):
|
||
"""会话操作弹出菜单:编辑 / 星标 / 复制 / 删除"""
|
||
action_triggered = QtCore.pyqtSignal(str, str) # (action, session_id)
|
||
|
||
def __init__(self, session_id, is_starred=False, parent=None):
|
||
super().__init__(parent)
|
||
self.session_id = session_id
|
||
self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setFixedWidth(160)
|
||
|
||
container = QtWidgets.QFrame(self)
|
||
container.setObjectName("ctx_popup")
|
||
main_lay = QtWidgets.QVBoxLayout(self)
|
||
main_lay.setContentsMargins(0, 0, 0, 0)
|
||
main_lay.addWidget(container)
|
||
|
||
lay = QtWidgets.QVBoxLayout(container)
|
||
lay.setContentsMargins(6, 6, 6, 6)
|
||
lay.setSpacing(2)
|
||
|
||
# 编辑
|
||
btn_edit = self._make_btn("✏️ 编辑", "edit")
|
||
lay.addWidget(btn_edit)
|
||
|
||
# 星标 / 取消星标
|
||
star_text = "⭐ 取消星标" if is_starred else "☆ 星标"
|
||
btn_star = self._make_btn(star_text, "star")
|
||
lay.addWidget(btn_star)
|
||
|
||
# 🆕 复制会话(深度克隆:分支/压缩标记/附件文件)
|
||
btn_copy = self._make_btn("📋 复制", "copy")
|
||
lay.addWidget(btn_copy)
|
||
|
||
# 分隔线
|
||
sep = QtWidgets.QFrame()
|
||
sep.setFrameShape(QtWidgets.QFrame.Shape.HLine)
|
||
sep.setStyleSheet("color: #eee; margin: 4px 8px;")
|
||
lay.addWidget(sep)
|
||
|
||
# 删除
|
||
btn_del = self._make_btn("🗑️ 删除", "delete")
|
||
btn_del.setStyleSheet(btn_del.styleSheet() + "QPushButton { color: #e04040; } QPushButton:hover { background: #fdecea; color: #c62828; }")
|
||
lay.addWidget(btn_del)
|
||
|
||
self.setStyleSheet("""
|
||
#ctx_popup {
|
||
background: #ffffff;
|
||
border: 1px solid #dcdcdc;
|
||
border-radius: 10px;
|
||
}
|
||
""")
|
||
self.adjustSize()
|
||
|
||
def _make_btn(self, text, action):
|
||
btn = QtWidgets.QPushButton(text)
|
||
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn.setStyleSheet("""
|
||
QPushButton {
|
||
text-align: left; border: none; border-radius: 6px;
|
||
padding: 8px 12px; font-size: 13px; color: #333;
|
||
background: transparent;
|
||
}
|
||
QPushButton:hover { background-color: #f0f4f9; }
|
||
""")
|
||
btn.clicked.connect(lambda: self._emit(action))
|
||
return btn
|
||
|
||
def _emit(self, action):
|
||
self.action_triggered.emit(action, self.session_id)
|
||
self.close()
|
||
|
||
def show_at(self, pos: QtCore.QPoint):
|
||
"""带浮现动画显示"""
|
||
self.setWindowOpacity(0.0)
|
||
self.move(pos.x(), pos.y())
|
||
self.show()
|
||
|
||
self._anim_group = QtCore.QParallelAnimationGroup(self)
|
||
|
||
opacity_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||
opacity_anim.setDuration(120)
|
||
opacity_anim.setStartValue(0.0)
|
||
opacity_anim.setEndValue(1.0)
|
||
opacity_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
|
||
pos_anim = QtCore.QPropertyAnimation(self, b"pos")
|
||
pos_anim.setDuration(120)
|
||
pos_anim.setStartValue(QtCore.QPoint(pos.x(), pos.y() + 8))
|
||
pos_anim.setEndValue(pos)
|
||
pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
|
||
self._anim_group.addAnimation(opacity_anim)
|
||
self._anim_group.addAnimation(pos_anim)
|
||
self._anim_group.start()
|
||
|
||
class RenameOverlay(QtWidgets.QWidget):
|
||
"""无边框重命名 — 独立顶层透明窗口(P2-03 修复)
|
||
|
||
🆕 必须是可覆盖原生 WebView2 的独立顶层窗(独立 HWND):WebView2 是原生子窗,
|
||
永远绘制在主窗口内所有 Qt 控件之上 —— 旧实现是 bg_widget 的子控件,遮罩盖不住
|
||
webview 区域(聊天区不暗)、卡片被压到 webview 下面。独立顶层 Tool 窗 +
|
||
WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗(同 AttachmentPreviewOverlay)。
|
||
|
||
只覆盖主窗口【客户区】:系统标题栏与窗口控制保持可操作。
|
||
主窗口 Move / Resize / WindowStateChange(最大化/还原,含多显示器/DPI 变化时
|
||
Qt 合成的 move+resize)通过 eventFilter + mapToGlobal 跟随。
|
||
行为保持:点空白关闭、Esc 关闭、✕ 关闭、输入框初始全选、Enter 提交、关闭后焦点回主窗口。
|
||
"""
|
||
renamed = QtCore.pyqtSignal(str) # 发射新标题
|
||
|
||
def __init__(self, current_title, parent=None):
|
||
super().__init__(parent)
|
||
self._main = parent.window() if parent is not None else None
|
||
# 无边框顶层 Tool 窗(不入任务栏;Windows 上作为拥有者窗口默认浮在父窗之上)
|
||
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||
|
||
# 半透明灰色遮罩
|
||
self._overlay_color = QtGui.QColor(0, 0, 0, 80)
|
||
|
||
# ---- 居中表单 ----
|
||
self.form = QtWidgets.QFrame(self)
|
||
self.form.setFixedSize(380, 200)
|
||
self.form.setObjectName("rename_form")
|
||
self.form.setStyleSheet("""
|
||
#rename_form {
|
||
background: #ffffff;
|
||
border: 1px solid #dcdcdc;
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
|
||
lay = QtWidgets.QVBoxLayout(self.form)
|
||
lay.setContentsMargins(24, 20, 24, 18)
|
||
lay.setSpacing(16)
|
||
|
||
# 标题行
|
||
title_lay = QtWidgets.QHBoxLayout()
|
||
title_label = QtWidgets.QLabel("编辑会话名称")
|
||
title_label.setStyleSheet("font-size: 15px; font-weight: bold; color: #222;")
|
||
title_lay.addWidget(title_label)
|
||
title_lay.addStretch()
|
||
|
||
btn_x = QtWidgets.QPushButton("✕")
|
||
btn_x.setFixedSize(24, 24)
|
||
btn_x.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn_x.setStyleSheet("""
|
||
QPushButton { background: transparent; border: none; border-radius: 12px; color: #999; font-size: 14px; }
|
||
QPushButton:hover { background: #eee; color: #333; }
|
||
""")
|
||
btn_x.clicked.connect(self.close_overlay)
|
||
title_lay.addWidget(btn_x)
|
||
lay.addLayout(title_lay)
|
||
|
||
# 输入框(加大内边距防文字被裁)
|
||
self.input = QtWidgets.QLineEdit(current_title)
|
||
self.input.selectAll()
|
||
self.input.setStyleSheet("""
|
||
QLineEdit {
|
||
border: 1.5px solid #e0e0e0; border-radius: 8px;
|
||
padding: 10px 14px; font-size: 14px; color: #222;
|
||
background: #fafafa;
|
||
}
|
||
QLineEdit:focus { border-color: #4a90d9; background: #fff; }
|
||
""")
|
||
self.input.returnPressed.connect(self.confirm)
|
||
lay.addWidget(self.input)
|
||
|
||
# 按钮行
|
||
btn_lay = QtWidgets.QHBoxLayout()
|
||
btn_lay.addStretch()
|
||
|
||
btn_cancel = QtWidgets.QPushButton("取消")
|
||
btn_cancel.setFixedSize(72, 32)
|
||
btn_cancel.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn_cancel.setStyleSheet("""
|
||
QPushButton { background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px; color: #666; font-size: 13px; }
|
||
QPushButton:hover { background: #eaeaea; }
|
||
""")
|
||
btn_cancel.clicked.connect(self.close_overlay)
|
||
|
||
btn_ok = QtWidgets.QPushButton("确定")
|
||
btn_ok.setFixedSize(72, 32)
|
||
btn_ok.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn_ok.setStyleSheet("""
|
||
QPushButton { background: #4a90d9; border: none; border-radius: 8px; color: white; font-size: 13px; font-weight: bold; }
|
||
QPushButton:hover { background: #3a7bc8; }
|
||
""")
|
||
btn_ok.clicked.connect(self.confirm)
|
||
|
||
btn_lay.addWidget(btn_cancel)
|
||
btn_lay.addSpacing(8)
|
||
btn_lay.addWidget(btn_ok)
|
||
lay.addLayout(btn_lay)
|
||
|
||
# 拖拽(卡片可拖动,限制在客户区内)
|
||
self._drag_pos = None
|
||
self.form.mousePressEvent = self._form_press
|
||
self.form.mouseMoveEvent = self._form_move
|
||
self.form.mouseReleaseEvent = self._form_release
|
||
|
||
self._result = None
|
||
self._closed = False
|
||
|
||
# 先同步几何(覆盖主窗口客户区)并挂事件过滤器,再显示
|
||
self._sync_geometry()
|
||
if self._main is not None:
|
||
self._main.installEventFilter(self)
|
||
self.show()
|
||
self.raise_()
|
||
self.activateWindow()
|
||
self.input.setFocus()
|
||
self._animate_in()
|
||
|
||
# ==================== 几何同步(客户区覆盖,标题栏/窗口控制可操作) ====================
|
||
def _client_top_left(self):
|
||
"""主窗口客户区左上角的【全局】坐标(mapToGlobal 已含多显示器/DPI 换算)。"""
|
||
return self._main.mapToGlobal(self._main.rect().topLeft())
|
||
|
||
def _sync_geometry(self):
|
||
if self._main is None:
|
||
return
|
||
self.setGeometry(QtCore.QRect(self._client_top_left(), self._main.rect().size()))
|
||
self._center_form()
|
||
|
||
def eventFilter(self, obj, ev):
|
||
"""跟随主窗口移动/大小变化/窗口状态(最大化、还原);主窗口关闭/隐藏时关闭遮罩。
|
||
DPI 变化时 Qt 对主窗口合成 move+resize 事件,此过滤器随之自动跟随。"""
|
||
if self._main is not None and obj is self._main:
|
||
t = ev.type()
|
||
if t == QtCore.QEvent.Type.Move:
|
||
self.move(self._client_top_left())
|
||
elif t in (QtCore.QEvent.Type.Resize, QtCore.QEvent.Type.WindowStateChange):
|
||
self._sync_geometry()
|
||
elif t in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
|
||
self.close_overlay()
|
||
return super().eventFilter(obj, ev)
|
||
# ==============================================================================
|
||
|
||
def _center_form(self):
|
||
x = (self.width() - self.form.width()) // 2
|
||
y = (self.height() - self.form.height()) // 2
|
||
self.form.move(x, y)
|
||
|
||
def _animate_in(self):
|
||
# 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠)
|
||
self.setWindowOpacity(0.0)
|
||
self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||
self._anim.setDuration(150)
|
||
self._anim.setStartValue(0.0)
|
||
self._anim.setEndValue(1.0)
|
||
self._anim.start()
|
||
|
||
# ---- 绘制半透明遮罩 ----
|
||
def paintEvent(self, event):
|
||
painter = QtGui.QPainter(self)
|
||
painter.fillRect(self.rect(), self._overlay_color)
|
||
painter.end()
|
||
|
||
# ---- 点击遮罩区域关闭 ----
|
||
def mousePressEvent(self, event):
|
||
if not self.form.geometry().contains(event.pos()):
|
||
self.close_overlay()
|
||
|
||
# ---- Esc 关闭 ----
|
||
def keyPressEvent(self, event):
|
||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||
self.close_overlay()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
# ---- 表单拖拽(限制在主窗口客户区内)----
|
||
def _form_press(self, event):
|
||
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||
self._drag_pos = event.pos()
|
||
|
||
def _form_move(self, event):
|
||
if self._drag_pos:
|
||
new_pos = self.form.pos() + event.pos() - self._drag_pos
|
||
# 限制在覆盖层范围内
|
||
x = max(0, min(new_pos.x(), self.width() - self.form.width()))
|
||
y = max(0, min(new_pos.y(), self.height() - self.form.height()))
|
||
self.form.move(x, y)
|
||
|
||
def _form_release(self, event):
|
||
self._drag_pos = None
|
||
|
||
def confirm(self):
|
||
title = self.input.text().strip()
|
||
if title:
|
||
self._result = title
|
||
self.renamed.emit(title)
|
||
self.close_overlay()
|
||
|
||
def close_overlay(self):
|
||
"""关闭并释放:卸事件过滤器、焦点回主窗口、延迟删除(不残留遮罩/过滤器/焦点捕获)。"""
|
||
if self._closed:
|
||
return
|
||
self._closed = True
|
||
if self._main is not None:
|
||
self._main.removeEventFilter(self)
|
||
self._main.activateWindow()
|
||
self._main.raise_()
|
||
self.deleteLater()
|
||
|
||
def get_result(self):
|
||
return self._result
|
||
|
||
|
||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||
def _popup_svg_path(filename: str) -> str:
|
||
"""🌟 弹窗类内部取 svg 图标绝对路径(与 MainWindow.get_svg_path 同一套定位逻辑,不依赖主窗口实例)"""
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
project_root = os.path.abspath(os.path.join(base_dir, "..", ".."))
|
||
return os.path.join(project_root, "svg", filename)
|
||
|
||
|
||
# 🆕 抽屉 v2:模型行淡入淡出 / 相邻行滑移 的 per-item 数据角色
|
||
_FADE_ROLE = QtCore.Qt.ItemDataRole.UserRole + 901 # 模型行不透明度 0..1(None=1)
|
||
_SLIDE_ROLE = QtCore.Qt.ItemDataRole.UserRole + 902 # 纯 item 垂直滑移偏移 dy (px)(None=0)
|
||
|
||
|
||
class _ModelFadeDelegate(QtWidgets.QStyledItemDelegate):
|
||
"""模型行 淡出+滑移 委托(抽屉 v2)
|
||
|
||
- _FADE_ROLE: paint 时 setOpacity → 模型行渐渐变浅透明
|
||
- _SLIDE_ROLE: paint 时 translate(0, dy) → 供应商头以下的整块行垂直滑移
|
||
(供应商头是 setItemWidget 真实控件,不走 delegate,由 _SlideListView 偏移)
|
||
"""
|
||
|
||
def paint(self, painter, option, index):
|
||
fade = index.data(_FADE_ROLE)
|
||
dy = index.data(_SLIDE_ROLE)
|
||
if fade is None and not dy:
|
||
super().paint(painter, option, index)
|
||
return
|
||
painter.save()
|
||
if fade is not None:
|
||
painter.setOpacity(max(0.0, min(1.0, float(fade))))
|
||
if dy:
|
||
painter.translate(0, int(dy))
|
||
# 🆕 背景穿透修复:滑移中的行先铺不透明白底(随平移后的位置),
|
||
# 盖住底下正在淡出的模型行文字,避免两层文字重叠
|
||
painter.fillRect(option.rect, QtGui.QColor("#ffffff"))
|
||
super().paint(painter, option, index)
|
||
painter.restore()
|
||
|
||
|
||
class _SlideListView(QtWidgets.QListWidget):
|
||
"""可保持供应商头控件垂直滑移偏移的 QListWidget(抽屉 v2)
|
||
|
||
QListView 会在每次 layout/resize/scroll 时把 setItemWidget 子控件
|
||
复位到自然位置——单帧 move() 会被下一个事件冲掉。这里在基类处理
|
||
完成之后重新应用偏移,保证滑移状态持续到该帧绘制。
|
||
"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._slide_offsets = [] # [(QListWidgetItem, dy), ...](item 不可哈希,用列表)
|
||
|
||
def _offset_of(self, it):
|
||
for item, dy in self._slide_offsets:
|
||
if item is it:
|
||
return dy
|
||
return 0
|
||
|
||
def set_slide_offset(self, it, dy):
|
||
if dy:
|
||
for i, (item, _) in enumerate(self._slide_offsets):
|
||
if item is it:
|
||
self._slide_offsets[i] = (item, int(dy))
|
||
break
|
||
else:
|
||
self._slide_offsets.append((it, int(dy)))
|
||
else:
|
||
self._slide_offsets = [(item, d) for item, d in self._slide_offsets if item is not it]
|
||
w = self.itemWidget(it)
|
||
if w is not None:
|
||
r = self.visualItemRect(it)
|
||
w.move(r.x(), r.y() + self._offset_of(it))
|
||
|
||
def clear_slide_offsets(self):
|
||
self._slide_offsets.clear()
|
||
|
||
def _reapply_slide(self):
|
||
for it, dy in list(self._slide_offsets):
|
||
w = self.itemWidget(it)
|
||
if w is not None:
|
||
r = self.visualItemRect(it)
|
||
w.move(r.x(), r.y() + dy)
|
||
|
||
def paintEvent(self, e):
|
||
# 每帧绘制前重应用偏移:layout/resize/scroll 都会把 itemWidget 复位,
|
||
# paint 前再移一次保证该帧最终视觉(注:PyQt6 的 doItemsLayout 无法
|
||
# 经 super() 调用,不能在那里钩)
|
||
if self._slide_offsets:
|
||
self._reapply_slide()
|
||
super().paintEvent(e)
|
||
|
||
def resizeEvent(self, e):
|
||
super().resizeEvent(e)
|
||
self._reapply_slide()
|
||
|
||
def scrollContentsBy(self, dx, dy):
|
||
super().scrollContentsBy(dx, dy)
|
||
self._reapply_slide()
|
||
|
||
|
||
class ModelSelectPopup(QtWidgets.QWidget):
|
||
"""
|
||
🌟 极客级自定义向上弹出菜单 (带丝滑浮现动画 & 供应商抽屉折叠)
|
||
- 供应商分组头:[SVG箭头] [provider.svg] 名称(微软雅黑·不加粗) —空白— 数量(右对齐·纯文字),整行可点
|
||
- 模型项:model.svg 图标(左带透明边,比供应商头缩进一点)+ 模型名,行距紧凑
|
||
- 滚动条上下内缩 12px,避免滑轨顶满右边两个圆角
|
||
"""
|
||
model_selected = QtCore.pyqtSignal(str, str)
|
||
|
||
ICON_SIZE = 16 # 模型项图标尺寸(调参工具确认)
|
||
MODEL_ICON_PAD = 34 # 模型图标左侧透明边(模型行缩进,调参工具确认)
|
||
HEADER_ICON_SIZE = 16 # 供应商头图标尺寸(调参工具确认 15→16)
|
||
HEADER_ICON_PAD = 0 # 供应商头图标水平偏移(左侧留白,调参工具可调)
|
||
HEADER_ICON_PAD_V = 1 # 供应商头图标垂直偏移(正=下移,调参工具确认 0→1)
|
||
MODEL_ROW_H = 27 # 模型行高(调参工具确认 26→27;抽屉 v2 中恒定不压缩)
|
||
MIN_POPUP_H = 50 # 弹窗高度钳制下限(与 adjust_popup_height 一致)
|
||
MAX_POPUP_H = 400 # 弹窗高度钳制上限(与 adjust_popup_height 一致)
|
||
DRAWER_DURATION = 200 # 抽屉淡出/滑移段时长 (ms)
|
||
DRAWER_SETTLE = 120 # 抽屉回座段时长(收起后整体下滑 / 展开前整体上移,ms)
|
||
DRAWER_STEP = 16 # 抽屉动画帧间隔 (ms) ~60fps
|
||
PROVIDER_SVG = "provider.svg" # 供应商图标(可选 provider/provider2/provider3,调参工具可切)
|
||
ARROW_SIZE = 13 # 展开/收起箭头尺寸(调参工具确认)
|
||
|
||
def __init__(self, parent=None, config_data=None):
|
||
super().__init__(parent)
|
||
self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
|
||
self.config_data = config_data or {"providers": {}}
|
||
self.setFixedWidth(340)
|
||
|
||
# 供应商头图标(PROVIDER_SVG 可切换,调参工具确认)
|
||
self._provider_pixmap = QtGui.QPixmap(_popup_svg_path(self.PROVIDER_SVG)).scaled(
|
||
self.HEADER_ICON_SIZE, self.HEADER_ICON_SIZE,
|
||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||
QtCore.Qt.TransformationMode.SmoothTransformation)
|
||
# 🆕 展开/收起 SVG 箭头(替换旧 ▾/▸ 文字字符)
|
||
self._arrow_expanded = QtGui.QIcon(_popup_svg_path("chevron_down.svg")).pixmap(self.ARROW_SIZE, self.ARROW_SIZE)
|
||
self._arrow_collapsed = QtGui.QIcon(_popup_svg_path("chevron_right.svg")).pixmap(self.ARROW_SIZE, self.ARROW_SIZE)
|
||
# 🆕 模型图标:左带 MODEL_ICON_PAD 透明边 → 模型行比供应商头缩进一点点
|
||
_m = QtGui.QPixmap(_popup_svg_path("model.svg")).scaled(
|
||
self.ICON_SIZE, self.ICON_SIZE,
|
||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||
QtCore.Qt.TransformationMode.SmoothTransformation)
|
||
_pad = QtGui.QPixmap(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE)
|
||
_pad.fill(QtCore.Qt.GlobalColor.transparent)
|
||
_p = QtGui.QPainter(_pad)
|
||
_p.drawPixmap(self.MODEL_ICON_PAD, 0, _m)
|
||
_p.end()
|
||
self._model_icon = QtGui.QIcon(_pad)
|
||
self._groups = [] # [{"provider", "header_item", "model_items", "expanded", "chevron"}]
|
||
self._drawer = None # 进行中的抽屉动画状态 {group, t, timer, row_h, anchor_bottom}
|
||
|
||
self.setup_ui()
|
||
self.populate_data()
|
||
self.adjust_popup_height()
|
||
|
||
def setup_ui(self):
|
||
self.container = QtWidgets.QFrame(self)
|
||
self.container.setObjectName("popup_container")
|
||
self.main_layout = QtWidgets.QVBoxLayout(self)
|
||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.main_layout.addWidget(self.container)
|
||
|
||
self.container_layout = QtWidgets.QVBoxLayout(self.container)
|
||
self.container_layout.setContentsMargins(0, 4, 0, 4)
|
||
self.container_layout.setSpacing(0)
|
||
|
||
self.list_widget = _SlideListView()
|
||
self.list_widget.setObjectName("model_list")
|
||
self.list_widget.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self.list_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
# 模型行字体:widget 级 setFont(实测 QSS ::item 的 font-size 在本构建无效)
|
||
_list_font = QtGui.QFont("Microsoft YaHei")
|
||
_list_font.setPixelSize(13)
|
||
self.list_widget.setFont(_list_font)
|
||
self.container_layout.addWidget(self.list_widget)
|
||
|
||
self.list_widget.itemClicked.connect(self.on_item_clicked)
|
||
self._fade_delegate = _ModelFadeDelegate(self.list_widget) # 抽屉 v2 淡出/滑移
|
||
self.list_widget.setItemDelegate(self._fade_delegate) # 🐛 必须安装才生效(之前只创建未挂载→fade/遮罩全不渲染)
|
||
|
||
# 🎨 注入灵魂:加入圆润字体栈,调整字重与间距,让视觉更柔和
|
||
self.setStyleSheet("""
|
||
* {
|
||
/* 全局字体:微软雅黑(调参工具确认)→ 鸿蒙黑体 → Noto Sans SC */
|
||
font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif;
|
||
}
|
||
#popup_container {
|
||
background-color: #ffffff;
|
||
border: 1px solid #dcdcdc;
|
||
border-radius: 12px;
|
||
}
|
||
/* 🆕 分组头整行可点(抽屉折叠触发器) */
|
||
#group_toggle_btn { background: transparent; border: none; }
|
||
#group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; }
|
||
#model_list {
|
||
border: none;
|
||
background: transparent;
|
||
outline: none;
|
||
}
|
||
#model_list::item {
|
||
/* ⚠️ 显式声明必须保留——作用是【屏蔽 MainWindow 全局 QListWidget::item
|
||
的级联泄漏】(padding 10px 12px / margin 2px 4px / border-radius 8px):
|
||
id 选择器 > 类型选择器,级联必胜;实测泄漏会把 38px 供应商头 widget
|
||
压裁(“卡进去”)、并在供应商行与首模型行间多出间隙 */
|
||
font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif;
|
||
padding: 0px;
|
||
margin: 0px;
|
||
border: none;
|
||
border-radius: 0px;
|
||
color: #333333;
|
||
font-weight: normal;
|
||
}
|
||
#model_list::item:hover {
|
||
background-color: #f0f4f9;
|
||
color: #111111;
|
||
}
|
||
#model_list::item:selected {
|
||
background-color: #e8f0fe;
|
||
color: #1a73e8;
|
||
font-weight: bold; /* 选中时使用真正的粗体 */
|
||
border: none; border-left: none; /* 屏蔽全局选中态的 3px 蓝色左竖条 */
|
||
}
|
||
|
||
/* 🆕 滚动条:上下内缩 12px,避免灰色滑轨顶满右边两个圆角 */
|
||
QScrollBar:vertical {
|
||
border: none;
|
||
background: transparent;
|
||
width: 5px;
|
||
margin: 12px 2px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #d0d0d0;
|
||
min-height: 20px;
|
||
border-radius: 3px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: #a0a0a0;
|
||
}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||
height: 0px;
|
||
}
|
||
""")
|
||
|
||
|
||
def populate_data(self):
|
||
providers = self.config_data.get("providers", {})
|
||
# 🆕 图标区宽 = 16 + 34px 透明左边(缩进),高 16(调参工具确认)
|
||
self.list_widget.setIconSize(QtCore.QSize(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE))
|
||
|
||
for provider_name, info in providers.items():
|
||
models = info.get("models", [])
|
||
if not models: continue
|
||
|
||
# --- 🆕 供应商分组头(整行可点:展开/收起抽屉) ---
|
||
header_item = QtWidgets.QListWidgetItem(self.list_widget)
|
||
header_item.setFlags(header_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsSelectable & ~QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||
|
||
header_widget = QtWidgets.QWidget()
|
||
header_widget.setFixedHeight(38) # 🐛 供应商行高 38 = 整行高(调参工具确认)
|
||
outer_layout = QtWidgets.QVBoxLayout(header_widget)
|
||
outer_layout.setContentsMargins(2, 2, 2, 2)
|
||
|
||
toggle_btn = QtWidgets.QPushButton()
|
||
toggle_btn.setObjectName("group_toggle_btn")
|
||
toggle_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
# 🐛 QPushButton 默认垂直 sizePolicy=Fixed,不会填满行高 → 内容行只有 12px,
|
||
# 16px 图标溢出被裁、文字偏下。“字只有一半”的另一半原因。
|
||
toggle_btn.setSizePolicy(
|
||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
|
||
btn_layout = QtWidgets.QHBoxLayout(toggle_btn)
|
||
# 🐛 垂直边距归零:图标+名称由布局自动垂直居中
|
||
# (旧版 (8,6,8,2) 不对称边距会把内容往下顶,加剧底部裁切)
|
||
btn_layout.setContentsMargins(3, 0, 8, 0) # 🐛 左边距 8→3:供应商头整体再靠左一点
|
||
btn_layout.setSpacing(6)
|
||
|
||
icon_lbl = QtWidgets.QLabel()
|
||
icon_lbl.setPixmap(self._provider_pixmap)
|
||
# 尺寸含水平/垂直偏移:内容区 = 图标尺寸,图标整体平移。
|
||
# 垂直 margin 取 2 倍:label 在行内居中会吸收一半增高,双倍才 1:1 位移
|
||
_top = 2 * max(self.HEADER_ICON_PAD_V, 0)
|
||
_bot = 2 * max(-self.HEADER_ICON_PAD_V, 0)
|
||
icon_lbl.setFixedSize(
|
||
self.HEADER_ICON_SIZE + self.HEADER_ICON_PAD,
|
||
self.HEADER_ICON_SIZE + _top + _bot)
|
||
icon_lbl.setContentsMargins(self.HEADER_ICON_PAD, _top, 0, _bot)
|
||
icon_lbl.setStyleSheet("background: transparent;")
|
||
|
||
# 🆕 供应商名:鸿蒙黑体·不加粗
|
||
lbl_name = QtWidgets.QLabel(provider_name)
|
||
lbl_name.setStyleSheet("""
|
||
font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif;
|
||
color: #555555;
|
||
font-weight: normal;
|
||
font-size: 13px; /* 调参工具确认 12→13px */
|
||
letter-spacing: 1px;
|
||
background: transparent;
|
||
""")
|
||
|
||
# 🆕 数量:去掉灰色方块,纯文字右对齐(靠 stretch)
|
||
lbl_count = QtWidgets.QLabel(str(len(models)))
|
||
lbl_count.setStyleSheet("""
|
||
font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif;
|
||
color: #888888;
|
||
font-size: 10px;
|
||
font-weight: normal;
|
||
background: transparent;
|
||
""")
|
||
|
||
# 🆕 SVG 箭头(展开=朝下 / 收起=朝右),放在最左
|
||
lbl_chevron = QtWidgets.QLabel()
|
||
lbl_chevron.setFixedSize(self.ARROW_SIZE, self.ARROW_SIZE)
|
||
lbl_chevron.setPixmap(self._arrow_expanded)
|
||
lbl_chevron.setStyleSheet("background: transparent;")
|
||
|
||
btn_layout.addWidget(lbl_chevron)
|
||
btn_layout.addWidget(icon_lbl)
|
||
btn_layout.addWidget(lbl_name)
|
||
btn_layout.addStretch()
|
||
btn_layout.addWidget(lbl_count)
|
||
outer_layout.addWidget(toggle_btn)
|
||
|
||
# item 高度 = widget 高度(实测 QSS padding/margin 在 ::item 无效,行高直接等于 widget 高)
|
||
header_item.setSizeHint(QtCore.QSize(0, 38))
|
||
self.list_widget.setItemWidget(header_item, header_widget)
|
||
toggle_btn.clicked.connect(lambda _checked=False, p=provider_name: self.toggle_group(p))
|
||
|
||
# --- 模型项(model.svg 图标 + 纯模型名) ---
|
||
model_items = []
|
||
for model_name in models:
|
||
item = QtWidgets.QListWidgetItem(model_name)
|
||
item.setIcon(self._model_icon)
|
||
# 🐛 显式 sizeHint(宽 0):防止最长模型名把单列网格撑宽超出 viewport
|
||
# (网格被撑宽时供应商头 widget 会按网格宽布局 → 右侧数量溢出弹窗外看不见)
|
||
item.setSizeHint(QtCore.QSize(0, self.MODEL_ROW_H)) # 调参工具确认 26→27
|
||
item.setData(QtCore.Qt.ItemDataRole.UserRole, (provider_name, model_name))
|
||
self.list_widget.addItem(item)
|
||
model_items.append(item)
|
||
|
||
self._groups.append({
|
||
"provider": provider_name,
|
||
"header_item": header_item,
|
||
"model_items": model_items,
|
||
"expanded": True,
|
||
"chevron": lbl_chevron,
|
||
})
|
||
|
||
# ==================== 🆕 抽屉 v2:fade + 相邻供应商行滑移 ====================
|
||
# 收起:A 段(顶部锚定,模型行 27px 不变原地淡出 1→0,下方供应商行整体
|
||
# 上滑逐渐盖住 → 两供应商行合并)+ B 段(整体下滑回座按钮锚点)
|
||
# 展开:B' 段(整体上移让位)+ A' 段(顶部锚定,下方供应商行整体下滑打开,
|
||
# 模型行淡入 0→1,底缘同步落回按钮)——收起的严格逆过程
|
||
# ⚠️ 窗口几何一律用 delta_h = clamp(终态内容高) - clamp(起态内容高)
|
||
# (被 50/400 钳制后的真实窗口高差);内容滑移 dy 才用 span。
|
||
# 列表超长(钳制在 400)时 delta_h 可能 = 0 → 窗口完全不动,
|
||
# 动画全部发生在窗口高度之下(滚动区内淡出/滑移)。
|
||
def _natural_content_height(self):
|
||
"""内容自然总高(可见 item + 10,未钳制;与 adjust_popup_height 同公式)"""
|
||
total = 0
|
||
lw = self.list_widget
|
||
for i in range(lw.count()):
|
||
it = lw.item(i)
|
||
if it is None or it.isHidden():
|
||
continue
|
||
total += lw.visualItemRect(it).height()
|
||
return total + 10
|
||
|
||
def _clamped_h(self, natural):
|
||
return max(self.MIN_POPUP_H, min(self.MAX_POPUP_H, natural))
|
||
|
||
def toggle_group(self, provider_name: str):
|
||
"""点击供应商头 → 折叠/展开该供应商的模型组(🆕 抽屉 v2 动画)"""
|
||
g = None
|
||
for gg in self._groups:
|
||
if gg["provider"] == provider_name:
|
||
g = gg
|
||
break
|
||
if g is None:
|
||
return
|
||
# 上一个抽屉动画进行中 → 先立即收敛到终态(允许快速连点切换)
|
||
self._finish_drawer()
|
||
g["expanded"] = not g["expanded"]
|
||
# SVG 箭头切换(展开=朝下 / 收起=朝右)
|
||
g["chevron"].setPixmap(
|
||
self._arrow_collapsed if not g["expanded"] else self._arrow_expanded)
|
||
if not self.isVisible():
|
||
# 防御:弹窗未显示时直接应用终态(不播动画)
|
||
self._apply_group_state(g)
|
||
self._apply_height_and_position()
|
||
return
|
||
self._start_drawer(g)
|
||
|
||
def _apply_group_state(self, g):
|
||
"""应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)"""
|
||
for it in g["model_items"]:
|
||
it.setSizeHint(QtCore.QSize(0, self.MODEL_ROW_H))
|
||
it.setData(_FADE_ROLE, None)
|
||
it.setHidden(not g["expanded"])
|
||
|
||
def _clear_fx(self):
|
||
"""清除所有淡出/滑移偏移(收敛/打断共用)"""
|
||
self.list_widget.clear_slide_offsets()
|
||
lw = self.list_widget
|
||
for i in range(lw.count()):
|
||
it = lw.item(i)
|
||
if it is None:
|
||
continue
|
||
it.setData(_FADE_ROLE, None)
|
||
it.setData(_SLIDE_ROLE, None)
|
||
|
||
def _set_slide(self, it, dy):
|
||
"""给单个 item 施加滑移:纯模型行→data role(delegate 绘制);
|
||
供应商头→_SlideListView 偏移(可抗 layout/resize 复位)"""
|
||
if self.list_widget.itemWidget(it) is not None:
|
||
self.list_widget.set_slide_offset(it, dy)
|
||
else:
|
||
it.setData(_SLIDE_ROLE, dy if dy else None)
|
||
|
||
def _start_drawer(self, g):
|
||
"""启动抽屉 v2 动画:抓取几何,创建帧定时器"""
|
||
timer = QtCore.QTimer(self)
|
||
timer.setInterval(self.DRAWER_STEP)
|
||
timer.timeout.connect(self._drawer_tick)
|
||
n = len(g["model_items"])
|
||
lw = self.list_widget
|
||
after = []
|
||
if n:
|
||
last_idx = lw.indexFromItem(g["model_items"][-1]).row() + 1
|
||
for i in range(last_idx, lw.count()):
|
||
after.append(lw.item(i))
|
||
# 🆕 背景穿透修复:滑移的供应商头(真实控件)铺不透明白底,
|
||
# 真正“盖住”底下淡出的模型行;动画收敛时恢复透明
|
||
for it in after:
|
||
w = lw.itemWidget(it)
|
||
if w is not None:
|
||
w.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||
w.setStyleSheet("background-color: #ffffff;")
|
||
# 强制立即生效(stylesheet 默认要等下一次 polish,会留一帧穿透)
|
||
st = w.style()
|
||
st.unpolish(w)
|
||
st.polish(w)
|
||
w.update()
|
||
self._drawer = {
|
||
"group": g, "timer": timer,
|
||
"span": self.MODEL_ROW_H * n, # 内容总滑移距离(行高 × 行数)
|
||
"after_items": after, # 本组最后一个模型行之后的所有 item
|
||
# 收起: A(淡出+滑上 200ms) → B(下滑回座 120ms)
|
||
# 展开: B(上移让位 120ms) → A(淡入+滑下 200ms)
|
||
"phase": "A" if not g["expanded"] else "B",
|
||
"t": 0.0,
|
||
"top0": self.y(),
|
||
"h0": self.minimumHeight(),
|
||
"bottom0": self.y() + self.minimumHeight(),
|
||
}
|
||
# 🆕 窗口真实高度差(被 50/400 钳制后)——窗口位移/长高只许用这个,
|
||
# 不能用 span(内容差):列表超长时窗口被钳在 400,delta_h=0,
|
||
# 动画全部发生在窗口高度之下(否则窗口会越界飞走)
|
||
natural_now = self._natural_content_height()
|
||
natural_final = natural_now + self._drawer["span"] if g["expanded"] \
|
||
else natural_now - self._drawer["span"]
|
||
self._drawer["delta_h"] = self._clamped_h(natural_final) - self._clamped_h(natural_now)
|
||
timer.start()
|
||
|
||
def _drawer_tick(self):
|
||
"""抽屉 v2 每帧(方向/阶段感知)"""
|
||
d = self._drawer
|
||
if d is None:
|
||
return
|
||
g = d["group"]
|
||
span = d["span"]
|
||
dh = d["delta_h"] # 窗口真实高差(已钳制,可正可负可为 0)
|
||
if d["phase"] == "A":
|
||
# ---- A 段:顶部锚定(顶部左上角不动,setFixedHeight 天然保顶) ----
|
||
d["t"] = min(1.0, d["t"] + self.DRAWER_STEP / self.DRAWER_DURATION)
|
||
e = 1.0 - (1.0 - d["t"]) ** 3 # ease-out 三次缓出
|
||
if g["expanded"]:
|
||
# 展开 A':模型行淡入,下方块下滑打开,窗口高按 delta_h 渐长
|
||
for it in g["model_items"]:
|
||
it.setData(_FADE_ROLE, e)
|
||
dy = -int(round(span * (1.0 - e))) # 内容滑移:-span → 0
|
||
top_fixed = d["top0"] - dh # B' 段已上移到位
|
||
else:
|
||
# 收起 A:模型行淡出,下方块上滑盖住,窗口高按 delta_h 渐缩
|
||
for it in g["model_items"]:
|
||
it.setData(_FADE_ROLE, 1.0 - e)
|
||
dy = -int(round(span * e)) # 内容滑移:0 → -span
|
||
top_fixed = d["top0"]
|
||
h_new = d["h0"] + int(round(dh * e)) # 只用钳制后的高差
|
||
self.setFixedHeight(max(1, h_new))
|
||
self.move(self.x(), top_fixed) # 显式保顶(稳健)
|
||
self.list_widget.doItemsLayout()
|
||
for it in d["after_items"]:
|
||
self._set_slide(it, dy)
|
||
if d["t"] >= 1.0:
|
||
if g["expanded"]:
|
||
self._finish_drawer() # 展开完成 → 落座
|
||
else:
|
||
# 收起 A 完成 → 隐藏行、清滑移,进入 B(下滑回座)
|
||
self._apply_group_state(g)
|
||
for it in d["after_items"]:
|
||
it.setData(_SLIDE_ROLE, None)
|
||
self.list_widget.clear_slide_offsets()
|
||
self.list_widget.doItemsLayout()
|
||
d["phase"] = "B"
|
||
d["t"] = 0.0
|
||
d["h0"] = self.minimumHeight()
|
||
else:
|
||
# ---- B 段:整体平移回座(收起=下滑 / 展开=上移;距离 = |delta_h|) ----
|
||
d["t"] = min(1.0, d["t"] + self.DRAWER_STEP / self.DRAWER_SETTLE)
|
||
e = 1.0 - (1.0 - d["t"]) ** 3
|
||
delta = int(round(abs(dh) * e))
|
||
if g["expanded"]:
|
||
self.move(self.x(), d["top0"] - delta) # 展开 B':整体上移让位
|
||
if d["t"] >= 1.0:
|
||
# B' 完成 → 模型行以透明态就位(27px 恒高),进入 A' 淡入
|
||
for it in g["model_items"]:
|
||
it.setHidden(False)
|
||
it.setSizeHint(QtCore.QSize(0, self.MODEL_ROW_H))
|
||
it.setData(_FADE_ROLE, 0.0)
|
||
self.list_widget.doItemsLayout()
|
||
d["phase"] = "A"
|
||
d["t"] = 0.0
|
||
d["h0"] = self.minimumHeight()
|
||
return
|
||
else:
|
||
self.move(self.x(), d["top0"] + delta) # 收起 B:整体下滑回座
|
||
if d["t"] >= 1.0:
|
||
self._finish_drawer()
|
||
|
||
def _finish_drawer(self):
|
||
"""抽屉动画收敛到终态(动画完成 / 连点打断共用)"""
|
||
d = self._drawer
|
||
if d is None:
|
||
return
|
||
d["timer"].stop()
|
||
self._drawer = None
|
||
# 🆕 恢复滑移供应商头的透明背景(_start_drawer 里铺的白底)
|
||
for it in d["after_items"]:
|
||
w = self.list_widget.itemWidget(it)
|
||
if w is not None:
|
||
w.setStyleSheet("")
|
||
w.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, False)
|
||
self._clear_fx()
|
||
self._apply_group_state(d["group"])
|
||
self.list_widget.doItemsLayout()
|
||
self.adjust_popup_height()
|
||
if not self.isVisible():
|
||
return
|
||
parent = self.parentWidget()
|
||
btn = getattr(parent, "model_selector", None) if parent else None
|
||
if btn is not None:
|
||
# 真实 App:回到“按钮正上方右对齐”锚点
|
||
btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0))
|
||
self.move(btn_pos.x() + btn.width() - self.width(),
|
||
btn_pos.y() - self.minimumHeight() - 5)
|
||
elif d.get("bottom0") is not None:
|
||
# 无锚定按钮(如调参工具宿主):保持动画起点底缘不漂
|
||
self.move(self.x(), d["bottom0"] - self.minimumHeight())
|
||
|
||
def _apply_height_and_position(self):
|
||
"""折叠/展开后:按可见项重算高度;若弹窗正显示中,保持“按钮正上方右对齐”锚点"""
|
||
self.adjust_popup_height()
|
||
if not self.isVisible():
|
||
return
|
||
parent = self.parentWidget()
|
||
btn = getattr(parent, "model_selector", None)
|
||
if btn is None:
|
||
return
|
||
btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0))
|
||
x = btn_pos.x() + btn.width() - self.width()
|
||
y = btn_pos.y() - self.height() - 5
|
||
self.move(x, y)
|
||
|
||
def adjust_popup_height(self):
|
||
self.list_widget.doItemsLayout()
|
||
total_height = 0
|
||
for i in range(self.list_widget.count()):
|
||
it = self.list_widget.item(i)
|
||
if it.isHidden():
|
||
continue
|
||
rect = self.list_widget.visualItemRect(it)
|
||
total_height += rect.height()
|
||
|
||
target_height = total_height + 10
|
||
|
||
if target_height > 400:
|
||
target_height = 400
|
||
elif target_height < 50:
|
||
target_height = 50
|
||
|
||
self.setFixedHeight(target_height)
|
||
|
||
# ==================== 🌟 核心新增:丝滑浮现动画 ====================
|
||
def show_with_animation(self, target_pos: QtCore.QPoint):
|
||
"""带透明度和位移的弹出动画"""
|
||
# 初始状态:完全透明,且位置比目标位置低 15 个像素
|
||
self.setWindowOpacity(0.0)
|
||
self.move(target_pos.x(), target_pos.y() + 15)
|
||
self.show()
|
||
|
||
# 创建并行动画组 (同时执行位移和透明度)
|
||
self.anim_group = QtCore.QParallelAnimationGroup(self)
|
||
|
||
# 1. 透明度动画 (0.0 -> 1.0)
|
||
self.opacity_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||
self.opacity_anim.setDuration(150) # 150毫秒,极速响应
|
||
self.opacity_anim.setStartValue(0.0)
|
||
self.opacity_anim.setEndValue(1.0)
|
||
self.opacity_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) # 缓出曲线,非常自然
|
||
|
||
# 2. 位移动画 (向上滑动 15 像素)
|
||
self.pos_anim = QtCore.QPropertyAnimation(self, b"pos")
|
||
self.pos_anim.setDuration(150)
|
||
self.pos_anim.setStartValue(QtCore.QPoint(target_pos.x(), target_pos.y() + 15))
|
||
self.pos_anim.setEndValue(target_pos)
|
||
self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
|
||
self.anim_group.addAnimation(self.opacity_anim)
|
||
self.anim_group.addAnimation(self.pos_anim)
|
||
self.anim_group.start()
|
||
|
||
def on_item_clicked(self, item):
|
||
data = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||
if data:
|
||
provider, model = data
|
||
self.model_selected.emit(provider, model)
|
||
self.close()
|
||
|
||
|
||
class _ModeRow(QtWidgets.QWidget):
|
||
"""会话模式弹窗的单个行控件:左图标 + 右两行文字(名称/作用),自管 hover/选中/点击"""
|
||
row_clicked = QtCore.pyqtSignal(str)
|
||
|
||
ICON_SIZE = 14
|
||
|
||
def __init__(self, mode: str, name: str, desc: str, icon_path: str, parent=None):
|
||
super().__init__(parent)
|
||
self.mode = mode
|
||
self._selected = False
|
||
self._hover = False
|
||
self._normal_bg = "transparent"
|
||
self._hover_bg = "#f0f4f9"
|
||
|
||
layout = QtWidgets.QHBoxLayout(self)
|
||
layout.setContentsMargins(10, 8, 12, 8)
|
||
layout.setSpacing(12)
|
||
|
||
# 左:SVG 图标(必须显式 transparent:行级 background-color 会传染给未声明背景的子控件,
|
||
# 否则选中/悬停时图标会被涂成浅色色块)
|
||
self.lbl_icon = QtWidgets.QLabel()
|
||
self.lbl_icon.setStyleSheet("background: transparent;")
|
||
self.lbl_icon.setFixedSize(self.ICON_SIZE, self.ICON_SIZE)
|
||
pix = QtGui.QPixmap(icon_path)
|
||
if not pix.isNull():
|
||
self.lbl_icon.setPixmap(pix.scaled(
|
||
self.ICON_SIZE, self.ICON_SIZE,
|
||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||
QtCore.Qt.TransformationMode.SmoothTransformation))
|
||
|
||
# 右:两行(名称 + 作用)
|
||
self.lbl_name = QtWidgets.QLabel(name)
|
||
self.lbl_name.setStyleSheet("""
|
||
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "微软雅黑", sans-serif;
|
||
font-size: 12px; font-weight: bold; color: #333333;
|
||
background: transparent;
|
||
""")
|
||
self.lbl_desc = QtWidgets.QLabel(desc)
|
||
self.lbl_desc.setStyleSheet("""
|
||
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "微软雅黑", sans-serif;
|
||
font-size: 12px; color: #888888;
|
||
background: transparent;
|
||
""")
|
||
txt = QtWidgets.QVBoxLayout()
|
||
txt.setContentsMargins(0, 0, 0, 0)
|
||
txt.setSpacing(4)
|
||
txt.addWidget(self.lbl_name)
|
||
txt.addWidget(self.lbl_desc)
|
||
|
||
layout.addWidget(self.lbl_icon, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addLayout(txt, 1)
|
||
|
||
# ---- 状态与样式 ----
|
||
def set_selected(self, selected: bool):
|
||
self._selected = selected
|
||
self._apply()
|
||
|
||
def _apply(self):
|
||
if self._selected:
|
||
self._normal_bg = "#e8f0fe"
|
||
name_color, name_weight = "#1a73e8", "bold"
|
||
else:
|
||
self._normal_bg = "transparent"
|
||
name_color, name_weight = "#333333", "bold"
|
||
self.setStyleSheet(
|
||
f"background-color: {self._hover_bg if self._hover else self._normal_bg};"
|
||
"border-radius: 8px;")
|
||
self.lbl_name.setStyleSheet(f"""
|
||
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "微软雅黑", sans-serif;
|
||
font-size: 13px; font-weight: {name_weight}; color: {name_color};
|
||
background: transparent;
|
||
""")
|
||
|
||
def _hover_set(self, on: bool):
|
||
self._hover = on
|
||
self._apply()
|
||
|
||
def enterEvent(self, e): # noqa: N802
|
||
self._hover_set(True)
|
||
super().enterEvent(e)
|
||
|
||
def leaveEvent(self, e): # noqa: N802
|
||
self._hover_set(False)
|
||
super().leaveEvent(e)
|
||
|
||
def mouseReleaseEvent(self, e): # noqa: N802
|
||
if e.button() == QtCore.Qt.MouseButton.LeftButton:
|
||
self.row_clicked.emit(self.mode)
|
||
super().mouseReleaseEvent(e)
|
||
|
||
|
||
class SessionModePopup(QtWidgets.QWidget):
|
||
"""
|
||
🆕 会话模式选择浮动弹窗(无边框浮动窗 + 150ms 透明淡入 + 15px 上移丝滑浮现动画)
|
||
改版:去掉「会话模式」标题行;每行 = 左 SVG 图标 + 右两行文字(名称/作用)
|
||
"""
|
||
mode_selected = QtCore.pyqtSignal(str)
|
||
|
||
MODES = (
|
||
("chat", "Chat 模式", "普通问答,无工具调用", "mode_chat.svg"),
|
||
("worker", "Worker 模式", "Agent 循环:工具 / 重试 / 压缩", "mode_worker.svg"),
|
||
)
|
||
ROW_HEIGHT = 56
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setFixedWidth(320)
|
||
self.setup_ui()
|
||
self.adjust_popup_height()
|
||
|
||
def setup_ui(self):
|
||
self.container = QtWidgets.QFrame(self)
|
||
self.container.setObjectName("popup_container")
|
||
self.main_layout = QtWidgets.QVBoxLayout(self)
|
||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.main_layout.addWidget(self.container)
|
||
|
||
self.container_layout = QtWidgets.QVBoxLayout(self.container)
|
||
self.container_layout.setContentsMargins(4, 4, 4, 4)
|
||
self.container_layout.setSpacing(2)
|
||
|
||
for mode, name, desc, icon in self.MODES:
|
||
row = _ModeRow(mode, name, desc, _popup_svg_path(icon), self.container)
|
||
row.row_clicked.connect(self.on_row_clicked)
|
||
self.container_layout.addWidget(row)
|
||
|
||
self.setStyleSheet("""
|
||
* {
|
||
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||
}
|
||
#popup_container {
|
||
background-color: #ffffff;
|
||
border: 1px solid #dcdcdc;
|
||
border-radius: 10px;
|
||
}
|
||
""")
|
||
|
||
def on_row_clicked(self, mode: str):
|
||
if mode:
|
||
self.mode_selected.emit(mode)
|
||
self.close()
|
||
|
||
def mark_selected(self, mode):
|
||
# 行控件直管选中态
|
||
for w in self.container.findChildren(_ModeRow):
|
||
w.set_selected(w.mode == mode)
|
||
|
||
def adjust_popup_height(self):
|
||
# 无列表:直接取内容提示高度(两行 + 容器边距)
|
||
target_height = self.container.sizeHint().height() + 2
|
||
if target_height > 400:
|
||
target_height = 400
|
||
elif target_height < 50:
|
||
target_height = 50
|
||
self.setFixedHeight(target_height)
|
||
|
||
def show_with_animation(self, target_pos: QtCore.QPoint):
|
||
"""带透明度和位移的弹出动画(与模型选择弹窗同款风格)"""
|
||
self.setWindowOpacity(0.0)
|
||
self.move(target_pos.x(), target_pos.y() + 15)
|
||
self.show()
|
||
self.anim_group = QtCore.QParallelAnimationGroup(self)
|
||
self.opacity_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||
self.opacity_anim.setDuration(150)
|
||
self.opacity_anim.setStartValue(0.0)
|
||
self.opacity_anim.setEndValue(1.0)
|
||
self.opacity_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
self.pos_anim = QtCore.QPropertyAnimation(self, b"pos")
|
||
self.pos_anim.setDuration(150)
|
||
self.pos_anim.setStartValue(QtCore.QPoint(target_pos.x(), target_pos.y() + 15))
|
||
self.pos_anim.setEndValue(target_pos)
|
||
self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
self.anim_group.addAnimation(self.opacity_anim)
|
||
self.anim_group.addAnimation(self.pos_anim)
|
||
self.anim_group.start()
|
||
|
||
|
||
# ==================== 🌟 PDF 图片提取子线程工作者 ====================
|
||
class _PdfImageExtractWorker(QtCore.QObject):
|
||
"""在独立线程里调用 pdf_reader 提取 PDF 内嵌图片,避免大文件解析阻塞 UI。
|
||
|
||
通过 finished / failed 信号把结果送回主线程。本类不持有任何控件引用,
|
||
因此可安全地 moveToThread 到子线程运行;线程与工作者均无父级、自管理生命周期
|
||
(finished 后各自 deleteLater),即使弹窗中途关闭也不会因删除运行中的线程而崩溃。
|
||
"""
|
||
finished = QtCore.pyqtSignal(list) # 提取到的图片元数据列表
|
||
failed = QtCore.pyqtSignal(str) # 失败原因
|
||
|
||
def __init__(self, pdf_abs: str, out_dir: str):
|
||
super().__init__()
|
||
self.pdf_abs = pdf_abs
|
||
self.out_dir = out_dir
|
||
|
||
def run(self):
|
||
try:
|
||
raw = extract_pdf_images(self.pdf_abs, self.out_dir)
|
||
except (ValueError, OSError) as e:
|
||
self.failed.emit(str(e))
|
||
return
|
||
images = []
|
||
for im in raw:
|
||
images.append({
|
||
"page": im["page"], "index": im["index"],
|
||
"local_path": f"data/attachments/{os.path.basename(im['abs_path'])}",
|
||
"abs_path": im["abs_path"],
|
||
"mime": im["mime"], "size_kb": im["size_kb"],
|
||
"width": im["width"], "height": im["height"],
|
||
})
|
||
self.finished.emit(images)
|
||
|
||
|
||
# ==================== 🌟 PDF 解析模式候选栏 ====================
|
||
class PdfModePopup(QtWidgets.QWidget):
|
||
"""PDF 附件的解析模式候选栏(弹出于 PDF 标签正上方,仿 ModelSelectPopup)。
|
||
|
||
- 两个互斥模式按钮:文本读取模式 / 图片解析模式。
|
||
- 选中图片模式后懒提取 PDF 全部内嵌图片,展开纯文字勾选列表(默认不勾选),
|
||
提供“全选”,每项可调用原生预览层查看图片。
|
||
- 鼠标移出框边界即淡出消失;点击按钮不会关闭。
|
||
- 通过 mode_changed / preview_requested 信号与主窗口通信,
|
||
并直接读写共享的 att_data(mode / images / selected_images)。
|
||
"""
|
||
mode_changed = QtCore.pyqtSignal(str) # "text" / "image"
|
||
preview_requested = QtCore.pyqtSignal(dict, list, int, dict) # 当前图 meta, 同组列表, 下标, 附件字典
|
||
|
||
# ---- 布局尺寸常量(_setup_ui 与 _adjust_size 共用,保证估算与实际一致)----
|
||
POPUP_W = 360 # 弹窗固定宽度
|
||
MARGIN = 12 # 容器四周留白
|
||
SPACING = 10 # 主布局控件间距
|
||
TITLE_H = 24 # 标题行高
|
||
BTN_H = 36 # 模式按钮高度
|
||
ROW_H = 40 # 图片列表单行高度
|
||
MAX_VISIBLE_ROWS = 6 # 图片列表最多显示行数(超出滚动)
|
||
|
||
def __init__(self, att_data, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setMouseTracking(True)
|
||
self.att_data = att_data
|
||
self.setFixedWidth(self.POPUP_W)
|
||
self._left_x = 0
|
||
self._bottom_y = 0
|
||
self._row_checks = [] # 每行勾选框引用
|
||
self._processing = False # 是否正在子线程解析图片
|
||
self._dot_count = 0 # “正在处理...”动态省略号计数
|
||
self._extract_thread = None # 当前解析线程(无父级,自管理生命周期)
|
||
self._extract_worker = None
|
||
self._dot_timer = QtCore.QTimer(self)
|
||
self._dot_timer.setInterval(400)
|
||
self._dot_timer.timeout.connect(self._tick_dots)
|
||
self._setup_ui()
|
||
self._apply_mode(att_data.get("mode", "text"), first=True)
|
||
|
||
def _setup_ui(self):
|
||
self.container = QtWidgets.QFrame(self)
|
||
self.container.setObjectName("pdf_popup_container")
|
||
self.container.setMouseTracking(True)
|
||
outer = QtWidgets.QVBoxLayout(self)
|
||
outer.setContentsMargins(0, 0, 0, 0)
|
||
outer.addWidget(self.container)
|
||
|
||
lay = QtWidgets.QVBoxLayout(self.container)
|
||
lay.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN)
|
||
lay.setSpacing(self.SPACING)
|
||
|
||
# 标题
|
||
title = QtWidgets.QLabel(f"PDF 解析模式 · {self.att_data.get('name', '')}")
|
||
title.setFixedHeight(self.TITLE_H)
|
||
title.setStyleSheet("color:#333333; font-size:13px; font-weight:bold;")
|
||
lay.addWidget(title)
|
||
|
||
# 两个模式按钮(互斥)
|
||
self.btn_text = QtWidgets.QPushButton("📝 文本读取模式")
|
||
self.btn_image = QtWidgets.QPushButton("🖼️ 图片解析模式")
|
||
for b in (self.btn_text, self.btn_image):
|
||
b.setCheckable(True)
|
||
b.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
b.setFixedHeight(self.BTN_H)
|
||
self.btn_text.clicked.connect(lambda: self._apply_mode("text"))
|
||
self.btn_image.clicked.connect(lambda: self._apply_mode("image"))
|
||
lay.addWidget(self.btn_image) # 图片解析模式在上
|
||
lay.addWidget(self.btn_text) # 文本读取模式在下
|
||
|
||
# 图片区(默认隐藏,选中图片模式后展开)
|
||
self.image_section = QtWidgets.QWidget()
|
||
img_lay = QtWidgets.QVBoxLayout(self.image_section)
|
||
img_lay.setContentsMargins(0, 2, 0, 0)
|
||
img_lay.setSpacing(8)
|
||
|
||
head = QtWidgets.QHBoxLayout()
|
||
self.select_all_cb = QtWidgets.QCheckBox("全选")
|
||
self.select_all_cb.stateChanged.connect(self._on_select_all)
|
||
self.lbl_img_count = QtWidgets.QLabel("已提取 0 张")
|
||
self.lbl_img_count.setStyleSheet("color:#888888; font-size:11px;")
|
||
head.addWidget(self.select_all_cb)
|
||
head.addStretch()
|
||
head.addWidget(self.lbl_img_count)
|
||
img_lay.addLayout(head)
|
||
|
||
self.image_list = QtWidgets.QListWidget()
|
||
self.image_list.setObjectName("pdf_image_list")
|
||
self.image_list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self.image_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
img_lay.addWidget(self.image_list)
|
||
|
||
self.lbl_empty = QtWidgets.QLabel("该 PDF 未提取到内嵌图片")
|
||
self.lbl_empty.setStyleSheet("color:#aaaaaa; font-size:12px; padding:6px;")
|
||
self.lbl_empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||
self.lbl_empty.setVisible(False)
|
||
img_lay.addWidget(self.lbl_empty)
|
||
|
||
# 子线程解析期间的动态提示(与列表/空提示互斥显示)
|
||
self.lbl_processing = QtWidgets.QLabel("正在处理")
|
||
self.lbl_processing.setStyleSheet("color:#1a73e8; font-size:13px; padding:14px;")
|
||
self.lbl_processing.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||
self.lbl_processing.setVisible(False)
|
||
img_lay.addWidget(self.lbl_processing)
|
||
|
||
lay.addWidget(self.image_section)
|
||
self.image_section.setVisible(False)
|
||
|
||
self.setStyleSheet("""
|
||
* { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif; }
|
||
#pdf_popup_container { background-color:#ffffff; border:1px solid #dcdcdc; border-radius:10px; }
|
||
#pdf_popup_container > QPushButton {
|
||
background-color:#f5f5f5; color:#444444; border:1px solid #e5e5e5;
|
||
border-radius:6px; font-size:13px; text-align:left; padding:0 12px;
|
||
}
|
||
#pdf_popup_container > QPushButton:hover { border:1px solid #b3d9ff; color:#0066cc; }
|
||
#pdf_popup_container > QPushButton:checked {
|
||
background-color:#e8f0fe; color:#1a73e8; border:1px solid #b3d9ff; font-weight:bold;
|
||
}
|
||
#pdf_image_list { border:1px solid #eeeeee; border-radius:6px; background:#fafafa; outline:none; }
|
||
#pdf_image_list::item { padding:0px; margin:0px; border:none; background:transparent; }
|
||
#pdf_image_list::item:hover { background:#f0f7ff; }
|
||
QCheckBox { font-size:13px; color:#333333; }
|
||
QScrollBar:vertical { border:none; background:transparent; width:5px; margin:2px 2px 2px 0px; }
|
||
QScrollBar::handle:vertical { background:#d0d0d0; min-height:20px; border-radius:2px; }
|
||
QScrollBar::handle:vertical:hover { background:#a0a0a0; }
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height:0px; }
|
||
""")
|
||
|
||
# ---------- 模式切换 ----------
|
||
def _apply_mode(self, mode, first=False):
|
||
self.att_data["mode"] = mode
|
||
is_image = (mode == "image")
|
||
self.btn_text.setChecked(not is_image)
|
||
self.btn_image.setChecked(is_image)
|
||
self.image_section.setVisible(is_image)
|
||
if is_image:
|
||
if self.att_data.get("images"):
|
||
# 已缓存:直接渲染,不重复解析(一个文档只解析一次)
|
||
self._set_processing(False)
|
||
self._populate_image_list()
|
||
else:
|
||
# 首次:子线程解析,期间显示动态“正在处理...”
|
||
self._start_extract_async()
|
||
else:
|
||
self._set_processing(False)
|
||
self._adjust_size()
|
||
if not first:
|
||
self.mode_changed.emit(mode)
|
||
|
||
# ---------- 子线程图片解析 ----------
|
||
def _start_extract_async(self):
|
||
"""启动子线程提取 PDF 内嵌图片,期间 UI 显示动态“正在处理...”。"""
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
pdf_abs = os.path.join(root_dir, self.att_data.get("local_path", ""))
|
||
out_dir = os.path.join(root_dir, "data", "attachments")
|
||
|
||
self._set_processing(True)
|
||
self._extract_thread = QtCore.QThread() # 无父级,自管理生命周期
|
||
self._extract_worker = _PdfImageExtractWorker(pdf_abs, out_dir)
|
||
self._extract_worker.moveToThread(self._extract_thread)
|
||
self._extract_thread.started.connect(self._extract_worker.run)
|
||
self._extract_worker.finished.connect(self._on_extract_done)
|
||
self._extract_worker.failed.connect(self._on_extract_failed)
|
||
self._extract_worker.finished.connect(self._extract_thread.quit)
|
||
self._extract_worker.failed.connect(self._extract_thread.quit)
|
||
# 线程/工作者结束后各自销毁,弹窗中途关闭也不会删除运行中的线程
|
||
self._extract_thread.finished.connect(self._extract_thread.deleteLater)
|
||
self._extract_worker.finished.connect(self._extract_worker.deleteLater)
|
||
self._extract_worker.failed.connect(self._extract_worker.deleteLater)
|
||
self._extract_thread.start()
|
||
|
||
def _set_processing(self, on: bool):
|
||
"""切换“处理中 / 正常”两种界面状态(列表、空提示、按钮、计数互斥)。"""
|
||
self._processing = on
|
||
has_imgs = len(self.att_data.get("images", [])) > 0
|
||
self.lbl_processing.setVisible(on)
|
||
self.image_list.setVisible((not on) and has_imgs)
|
||
self.lbl_empty.setVisible((not on) and not has_imgs)
|
||
self.btn_text.setEnabled(not on)
|
||
self.btn_image.setEnabled(not on)
|
||
self.select_all_cb.setEnabled(not on)
|
||
if on:
|
||
self.lbl_img_count.setText("处理中…")
|
||
self._dot_count = 0
|
||
self.lbl_processing.setText("正在处理")
|
||
self._dot_timer.start()
|
||
else:
|
||
self._dot_timer.stop()
|
||
|
||
def _tick_dots(self):
|
||
"""动态省略号:正在处理. → .. → ... 循环。"""
|
||
self._dot_count = (self._dot_count % 3) + 1
|
||
self.lbl_processing.setText("正在处理" + "." * self._dot_count)
|
||
|
||
def _on_extract_done(self, images):
|
||
"""子线程解析成功:缓存结果(此后不再重复解析)并渲染列表。"""
|
||
self.att_data["images"] = images
|
||
self.att_data["selected_images"] = [] # 默认不勾选
|
||
self._set_processing(False)
|
||
self._populate_image_list()
|
||
self._adjust_size()
|
||
|
||
def _on_extract_failed(self, msg):
|
||
"""子线程解析失败:按“无图片”处理,不阻断其它操作。"""
|
||
print(f"[Warn]: PDF 图片解析失败: {msg}")
|
||
self.att_data["images"] = []
|
||
self._set_processing(False)
|
||
self._populate_image_list()
|
||
self._adjust_size()
|
||
|
||
def closeEvent(self, event):
|
||
"""关闭时停掉动画定时器;解析线程自管理,无需在此阻塞等待。"""
|
||
self._dot_timer.stop()
|
||
super().closeEvent(event)
|
||
|
||
def _populate_image_list(self):
|
||
self.image_list.clear()
|
||
self._row_checks = []
|
||
images = self.att_data.get("images", [])
|
||
selected_paths = {s["local_path"] for s in self.att_data.get("selected_images", [])}
|
||
self.lbl_img_count.setText(f"已提取 {len(images)} 张")
|
||
self.lbl_empty.setVisible(len(images) == 0)
|
||
self.image_list.setVisible(len(images) > 0) # 无图时不显示空白列表框
|
||
for im in images:
|
||
item = QtWidgets.QListWidgetItem(self.image_list)
|
||
item.setSizeHint(QtCore.QSize(0, self.ROW_H))
|
||
row = QtWidgets.QWidget()
|
||
row.setFixedHeight(self.ROW_H)
|
||
hl = QtWidgets.QHBoxLayout(row)
|
||
hl.setContentsMargins(16, 0, 16, 0)
|
||
hl.setSpacing(14)
|
||
cb = QtWidgets.QCheckBox(f"第 {im['page']} 页 no.{im['index']}")
|
||
cb.setFixedHeight(self.ROW_H) # 撑满行高,由 QStyle 在整行内垂直居中文字
|
||
cb.setChecked(im["local_path"] in selected_paths) # 还原上次勾选(在 connect 前设置,不触发信号)
|
||
cb.stateChanged.connect(lambda st, m=im: self._on_row_toggled(m, st))
|
||
btn = QtWidgets.QPushButton("预览")
|
||
btn.setFixedSize(56, 28)
|
||
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
btn.setStyleSheet(
|
||
"QPushButton{background:#eeeeee;border:none;border-radius:6px;color:#555555;font-size:12px;}"
|
||
"QPushButton:hover{background:#d9e8ff;color:#1a73e8;}"
|
||
)
|
||
btn.clicked.connect(lambda chk=False, m=im: self._request_preview(m))
|
||
# checkbox 已撑满行高(内容自居中);预览按钮较小,显式垂直居中
|
||
hl.addWidget(cb)
|
||
hl.addStretch()
|
||
hl.addWidget(btn, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||
self.image_list.addItem(item)
|
||
self.image_list.setItemWidget(item, row)
|
||
self._row_checks.append(cb)
|
||
# 列表高度:最多显示 MAX_VISIBLE_ROWS 行,超出滚动;上下各留 6px 内边距
|
||
rows = min(max(len(images), 1), self.MAX_VISIBLE_ROWS)
|
||
self.image_list.setFixedHeight(rows * self.ROW_H + 12)
|
||
n = len(self._row_checks)
|
||
k = sum(1 for c in self._row_checks if c.isChecked())
|
||
self.select_all_cb.blockSignals(True)
|
||
self.select_all_cb.setChecked(n > 0 and k == n) # 还原“全选”勾选态
|
||
self.select_all_cb.blockSignals(False)
|
||
|
||
# ---------- 勾选逻辑 ----------
|
||
def _on_row_toggled(self, im, state):
|
||
checked = (state == QtCore.Qt.CheckState.Checked.value)
|
||
sel = self.att_data.setdefault("selected_images", [])
|
||
if checked:
|
||
if not any(s["local_path"] == im["local_path"] for s in sel):
|
||
sel.append(im)
|
||
else:
|
||
self.att_data["selected_images"] = [s for s in sel if s["local_path"] != im["local_path"]]
|
||
n = len(self._row_checks)
|
||
k = sum(1 for c in self._row_checks if c.isChecked())
|
||
self.select_all_cb.blockSignals(True)
|
||
self.select_all_cb.setChecked(n > 0 and k == n)
|
||
self.select_all_cb.blockSignals(False)
|
||
|
||
def _on_select_all(self, state):
|
||
checked = (state == QtCore.Qt.CheckState.Checked.value)
|
||
for cb in self._row_checks:
|
||
cb.blockSignals(True)
|
||
cb.setChecked(checked)
|
||
cb.blockSignals(False)
|
||
self.att_data["selected_images"] = list(self.att_data.get("images", [])) if checked else []
|
||
|
||
def _request_preview(self, im):
|
||
"""呼叫主窗口用原生预览层查看该图片,并带上同组图片供左右翻页。
|
||
|
||
先立即关闭本弹窗(越快越好),再让主窗口打开预览覆盖层。
|
||
"""
|
||
base_name = self.att_data.get("name", "")
|
||
images = self.att_data.get("images", [])
|
||
siblings = [{
|
||
"type": "image",
|
||
"name": f'{base_name} · 第 {x["page"]} 页 no.{x["index"]}',
|
||
"local_path": x["local_path"],
|
||
"mime": x["mime"],
|
||
"size_kb": x.get("size_kb", 0),
|
||
} for x in images]
|
||
index = next((i for i, x in enumerate(images) if x["local_path"] == im["local_path"]), 0)
|
||
self.close() # 解析选择窗口立即消失
|
||
self.preview_requested.emit(siblings[index], siblings, index, self.att_data)
|
||
|
||
# ---------- 尺寸与定位 ----------
|
||
def _adjust_size(self):
|
||
# 高度 = 上边距 + 标题 + 间距 + 两按钮 + [图片区] + 下边距,全部取自布局常量
|
||
m = self.MARGIN
|
||
h = m + self.TITLE_H + self.SPACING + self.BTN_H + self.SPACING + self.BTN_H
|
||
# 用 mode 而非 image_section.isVisible():构造期弹窗未 show,isVisible() 恒为 False,
|
||
# 会导致图片模式被误判成文本模式、按矮高度计算从而压扁列表
|
||
if self.att_data.get("mode") == "image":
|
||
if self._processing:
|
||
body_h = 46 # “正在处理...”动态提示区
|
||
else:
|
||
n = len(self.att_data.get("images", []))
|
||
if n == 0:
|
||
body_h = 30 # “未提取到图片”提示行
|
||
else:
|
||
rows = min(n, self.MAX_VISIBLE_ROWS)
|
||
body_h = rows * self.ROW_H + 12 # 列表行高 + 上下内边距
|
||
h += self.SPACING + (2 + 26 + 8 + body_h) # 间距 + 图片区(上边距+表头+间距+主体)
|
||
h += m
|
||
self.setFixedHeight(int(h))
|
||
if self._bottom_y: # 保持底边锚定在标签上方
|
||
self.move(self._left_x, self._bottom_y - self.height())
|
||
|
||
def show_with_animation(self, target_pos: QtCore.QPoint):
|
||
"""淡入 + 上滑弹出(仿 ModelSelectPopup),并记录锚点供增高时重新定位。"""
|
||
self._left_x = target_pos.x()
|
||
self.setWindowOpacity(0.0)
|
||
self.move(target_pos.x(), target_pos.y() + 15)
|
||
self.show()
|
||
self._bottom_y = target_pos.y() + self.height()
|
||
|
||
self.anim_group = QtCore.QParallelAnimationGroup(self)
|
||
op = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||
op.setDuration(150); op.setStartValue(0.0); op.setEndValue(1.0)
|
||
op.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
ps = QtCore.QPropertyAnimation(self, b"pos")
|
||
ps.setDuration(150)
|
||
ps.setStartValue(QtCore.QPoint(target_pos.x(), target_pos.y() + 15))
|
||
ps.setEndValue(target_pos)
|
||
ps.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||
self.anim_group.addAnimation(op)
|
||
self.anim_group.addAnimation(ps)
|
||
self.anim_group.start()
|
||
|
||
# 关闭方式:依赖 Qt.Popup 原生行为——点击弹窗以外任意区域即关闭(与 ModelSelectPopup 一致),
|
||
# 鼠标移出不再关闭。
|
||
|
||
|
||
# ==================== 可点击标签(附件预览用) ====================
|
||
class ClickableLabel(QtWidgets.QLabel):
|
||
"""鼠标悬停显示下划线、点击发射 clicked 信号的 QLabel"""
|
||
clicked = QtCore.pyqtSignal()
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||
self.clicked.emit()
|
||
|
||
|
||
# ==================== 🌟 自定义网页滚动条 ====================
|
||
class WebScrollBar(QtWidgets.QWidget):
|
||
"""自定义网页滚动条:完全镜像 QtWebEngine 页面滚动。
|
||
|
||
- 位置镜像:页面 scrollY ↔ 滑块位置(双向,经 QWebChannel)
|
||
- 长度镜像:滑块高度 ∝ 视口/内容比例(内容长→滑块短;内容短→滑块长)
|
||
- 交互:拖动滑块 / 点击轨道跳转 / 滚轮翻页 均回写页面 window.scrollTo
|
||
- 全程无浏览器原生滚动条参与(原生已被 CSS 隐藏),避免重绘延迟观感
|
||
"""
|
||
TRACK_MARGIN_X = 4 # 轨道左右边距
|
||
MIN_SLIDER_H = 30 # 滑块最小高度
|
||
WHEEL_STEP = 60 # 滚轮单格滚动像素
|
||
|
||
def __init__(self, bridge, parent=None):
|
||
super().__init__(parent)
|
||
self.bridge = bridge
|
||
self.setFixedWidth(16)
|
||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setMouseTracking(True)
|
||
self.setStyleSheet(self._qss())
|
||
|
||
self.track = QtWidgets.QFrame(self)
|
||
self.track.setObjectName("web_scroll_track")
|
||
self.track.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||
|
||
self.slider = QtWidgets.QFrame(self)
|
||
self.slider.setObjectName("web_scroll_slider")
|
||
self.slider.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||
|
||
self._y = 0.0 # 页面 scrollY
|
||
self._content = 1.0 # 页面 scrollHeight
|
||
self._client = 1.0 # 页面 innerHeight
|
||
self._track_h = 0
|
||
self._slider_h = 0
|
||
self._slider_y = 0
|
||
self._hover = False
|
||
self._dragging = False
|
||
self._drag_grab = 0 # 按下时指针相对滑块顶部的偏移
|
||
|
||
def _qss(self):
|
||
return """
|
||
#web_scroll_track { background: rgba(0,0,0,0.05); border-radius: 4px; }
|
||
#web_scroll_slider { background-color: rgba(0,0,0,0.16); border-radius: 4px; }
|
||
#web_scroll_slider[state="hover"] { background-color: rgba(0,0,0,0.30); }
|
||
#web_scroll_slider[state="drag"] { background-color: rgba(37,99,235,0.60); }
|
||
"""
|
||
|
||
# ---------------- 页面 → 控件(镜像方向 1) ----------------
|
||
def set_scroll_info(self, y, content, client):
|
||
"""前端上报页面滚动状态后调用,刷新滑块位置与长度。"""
|
||
self._y = float(y)
|
||
self._content = float(content) if content > 0 else 1.0
|
||
self._client = float(client)
|
||
self._layout_slider()
|
||
|
||
def _scrollable(self):
|
||
return self._content > self._client + 1
|
||
|
||
def _layout_slider(self):
|
||
self._track_h = self.height()
|
||
if self._track_h <= 0:
|
||
return
|
||
track_w = self.width() - 2 * self.TRACK_MARGIN_X
|
||
self.track.setGeometry(self.TRACK_MARGIN_X, 0, track_w, self._track_h)
|
||
|
||
if not self._scrollable():
|
||
self.slider.hide()
|
||
return
|
||
self.slider.show()
|
||
|
||
# 🌟 滑块高度 = 视口占比 × 轨道高(内容越长滑块越短),带最小高度
|
||
sh = max(self.MIN_SLIDER_H, int(self._track_h * (self._client / self._content)))
|
||
sh = min(sh, self._track_h)
|
||
span = self._track_h - sh
|
||
|
||
# 🌟 位置镜像:页面滚动比例 → 滑块位置比例
|
||
max_scroll = self._content - self._client
|
||
if max_scroll <= 0:
|
||
y_frac = 0.0
|
||
else:
|
||
y_frac = min(1.0, max(0.0, self._y / max_scroll))
|
||
|
||
self._slider_h = sh
|
||
self._slider_y = int(y_frac * span)
|
||
self.slider.setGeometry(self.TRACK_MARGIN_X, self._slider_y, track_w, sh)
|
||
|
||
# ---------------- 控件 → 页面(镜像方向 2) ----------------
|
||
def _slider_y_to_page(self, slider_y):
|
||
if not self._scrollable():
|
||
return 0.0
|
||
span = self._track_h - self._slider_h
|
||
if span <= 0:
|
||
return 0.0
|
||
frac = min(1.0, max(0.0, slider_y / span))
|
||
return frac * (self._content - self._client)
|
||
|
||
def _scroll_page_to_y(self, y):
|
||
max_scroll = self._content - self._client
|
||
if max_scroll <= 0:
|
||
y = 0.0
|
||
y = min(max_scroll, max(0.0, y))
|
||
self._y = y
|
||
# 统一走 webScrollTo(内部经 document.scrollingElement,兼容 body 内部滚动容器)
|
||
self.bridge.run_js(f"webScrollTo({int(y)});")
|
||
|
||
# ---------------- 鼠标/滚轮交互 ----------------
|
||
def mousePressEvent(self, event):
|
||
if event.button() != QtCore.Qt.MouseButton.LeftButton or not self._scrollable():
|
||
return super().mousePressEvent(event)
|
||
if self.slider.isVisible():
|
||
sr = self.slider.geometry()
|
||
if sr.contains(event.pos()):
|
||
# 拖动滑块
|
||
self._dragging = True
|
||
self._drag_grab = event.pos().y() - sr.y()
|
||
self._apply_state()
|
||
event.accept()
|
||
return
|
||
# 点击轨道:滑块跳跃到点击处
|
||
target = event.pos().y() - self._slider_h // 2
|
||
self._scroll_page_to_y(self._slider_y_to_page(target))
|
||
self._layout_slider()
|
||
event.accept()
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if self._dragging:
|
||
slider_y = event.pos().y() - self._drag_grab
|
||
self._scroll_page_to_y(self._slider_y_to_page(slider_y))
|
||
self._layout_slider()
|
||
event.accept()
|
||
return
|
||
# hover 反馈
|
||
hover = self.slider.isVisible() and self.slider.geometry().contains(event.pos())
|
||
if hover != self._hover:
|
||
self._hover = hover
|
||
self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) if hover else self.unsetCursor()
|
||
self._apply_state()
|
||
super().mouseMoveEvent(event)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
if self._dragging and event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||
self._dragging = False
|
||
self._apply_state()
|
||
event.accept()
|
||
return
|
||
super().mouseReleaseEvent(event)
|
||
|
||
def leaveEvent(self, event):
|
||
self._hover = False
|
||
self.unsetCursor()
|
||
self._apply_state()
|
||
super().leaveEvent(event)
|
||
|
||
def wheelEvent(self, event):
|
||
delta = event.angleDelta().y()
|
||
if delta == 0:
|
||
event.accept()
|
||
return
|
||
self._scroll_page_to_y(self._y + (self.WHEEL_STEP if delta > 0 else -self.WHEEL_STEP))
|
||
self._layout_slider()
|
||
event.accept()
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
self._layout_slider()
|
||
|
||
def _apply_state(self):
|
||
"""按 hover / 拖动 状态刷新滑块 QSS"""
|
||
state = "drag" if self._dragging else ("hover" if self._hover else "normal")
|
||
self.slider.setProperty("state", state)
|
||
self.slider.style().unpolish(self.slider)
|
||
self.slider.style().polish(self.slider)
|
||
|
||
|
||
# ==================== 主窗口 ====================
|
||
class MainWindow(QtWidgets.QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("haocode - 极客级协同系统")
|
||
self.resize(1100, 800)
|
||
self.setMinimumSize(900, 600)
|
||
|
||
# 🌟 侧边栏收缩状态(False=展开 260px / True=收缩为 52px 细条)
|
||
self.sidebar_collapsed = False
|
||
self._sidebar_anim = None
|
||
self._sidebar_target = False
|
||
|
||
# 🌟 v2: 恢复 Windows 11 原生边框(移除无边框自绘标题栏)。
|
||
# 原生标题栏自带 最小化/最大化/关闭、窗口拖拽、边缘吸附与圆角;
|
||
# 自绘按钮、EdgeGrip 拉伸、四分屏吸附逻辑全部移除,交给 DWM 接管。
|
||
|
||
# 🌟 调试标记:设置 HAOCODE_WINDOW_TAG=xxx 可在标题栏追加标记,
|
||
# 用于多实例(如 A/B 渲染模式对比)时区分窗口,平时不设置则无影响。
|
||
_win_title = "haocode"
|
||
_win_tag = os.environ.get("HAOCODE_WINDOW_TAG", "")
|
||
if _win_tag:
|
||
_win_title = f"{_win_title} [{_win_tag}]"
|
||
self.setWindowTitle(_win_title)
|
||
|
||
self.setup_ui()
|
||
self.setup_stylesheet()
|
||
|
||
# 🌟 初始化模型选择与发送逻辑
|
||
self.current_provider = None
|
||
self.current_model = None
|
||
self.worker = None
|
||
|
||
self.is_generating = False
|
||
self._was_cancelled = False
|
||
|
||
# 🌟 提前初始化(init_model_popup 内部会触发 update_context_display 用到)
|
||
self._active_streams = {}
|
||
self._diag_chunk_n = 0
|
||
self._diag_think_n = 0
|
||
try:
|
||
open(DIAG_LOG_PATH, "w").close()
|
||
diag_log("APP_START")
|
||
except Exception:
|
||
pass
|
||
# 🌟 渲染看门狗:若前端 rAF/定时器被浏览器节流(窗口隐藏/GPU 问题),
|
||
# 由 Qt 侧定时器每 200ms 主动 forceRenderNow,保证流式正文一定上屏
|
||
self._render_watchdog = QtCore.QTimer(self)
|
||
self._render_watchdog.setInterval(200)
|
||
self._render_watchdog.timeout.connect(self._render_watchdog_tick)
|
||
self._render_watchdog.start()
|
||
# 人肉 debug:JS console 桥抽取器(JS console.log → Python 控制台)
|
||
self._jslog_timer = QtCore.QTimer(self)
|
||
self._jslog_timer.setInterval(500)
|
||
self._jslog_timer.timeout.connect(self._jslog_drain_tick)
|
||
self._jslog_timer.start()
|
||
# 🆕 Fix E: 上下文标签防抖定时器(工具/思考/正文任一显现 → 400ms 内合并刷新一次)
|
||
self._ctx_refresh_timer = QtCore.QTimer(self)
|
||
self._ctx_refresh_timer.setSingleShot(True)
|
||
self._ctx_refresh_timer.setInterval(400)
|
||
self._ctx_refresh_timer.timeout.connect(self.update_context_display)
|
||
# 🆕 调试窗口控制:文件协议 data/debug_window.cmd (show/hide),与项目树完全独立
|
||
self.debug_window = None
|
||
self._debug_cmd_timer = QtCore.QTimer(self)
|
||
self._debug_cmd_timer.setInterval(2000)
|
||
self._debug_cmd_timer.timeout.connect(self._poll_debug_cmd)
|
||
self._debug_cmd_timer.start()
|
||
self._title_workers = {}
|
||
self.current_ai_msg_id = None
|
||
self._ghost_msg_map = {} # 🌟 新增:专门记录幽灵报错气泡与提问的映射关系
|
||
import time
|
||
self._last_click_time = 0
|
||
|
||
self.init_model_popup()
|
||
self.init_mode_popup()
|
||
self.init_chat_events()
|
||
|
||
# 🌟 长文本折叠相关
|
||
self._folded_texts = []
|
||
self._is_folding = False
|
||
self.LONG_TEXT_THRESHOLD = 500
|
||
self.LONG_TEXT_LINES = 15
|
||
|
||
self.init_browser()
|
||
|
||
# 🆕 调试窗口随程序启动(config.json debug_window_autostart,缺省 true):
|
||
# 写 "show" 到控制文件,上方 2s 轮询在事件循环启动后自动开窗
|
||
try:
|
||
from core.llm_engine import _load_config
|
||
from core.debug_log import autostart_debug_window
|
||
autostart_debug_window(_load_config())
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def setup_ui(self):
|
||
self.bg_widget = QtWidgets.QWidget(self)
|
||
self.bg_widget.setObjectName("bg_widget")
|
||
self.setCentralWidget(self.bg_widget)
|
||
|
||
|
||
self.main_layout = QtWidgets.QHBoxLayout(self.bg_widget)
|
||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.main_layout.setSpacing(0)
|
||
|
||
# ==================== 1. 左侧边栏 ====================
|
||
self.sidebar = QtWidgets.QWidget()
|
||
self.sidebar.setObjectName("sidebar")
|
||
self.sidebar.setFixedWidth(260)
|
||
self.sidebar_root = QtWidgets.QVBoxLayout(self.sidebar)
|
||
self.sidebar_root.setContentsMargins(0, 0, 0, 0)
|
||
self.sidebar_root.setSpacing(0)
|
||
|
||
# 🌟 双页面容器:展开页 / 收缩页(收缩图标用独立干净布局精确居中)
|
||
self.sidebar_stack = QtWidgets.QStackedWidget()
|
||
self.sidebar_root.addWidget(self.sidebar_stack)
|
||
|
||
# ---- 页面0:展开状态(标题行、logo、历史列表、功能按钮)----
|
||
self.expand_page = QtWidgets.QWidget()
|
||
self.sidebar_layout = QtWidgets.QVBoxLayout(self.expand_page)
|
||
self.sidebar_layout.setContentsMargins(10, 20, 10, 20)
|
||
|
||
# 🌟 顶部标题行:应用图标 + haocode + 收缩按钮(统一高度对齐)
|
||
self.sidebar_header = QtWidgets.QWidget()
|
||
self.sidebar_header.setFixedHeight(36)
|
||
self.sidebar_header_layout = QtWidgets.QHBoxLayout(self.sidebar_header)
|
||
self.sidebar_header_layout.setContentsMargins(2, 0, 2, 0)
|
||
self.sidebar_header_layout.setSpacing(8)
|
||
|
||
self.logo_icon = QtWidgets.QLabel()
|
||
self.logo_icon.setObjectName("logo_icon")
|
||
self.logo_icon.setFixedSize(22, 22)
|
||
self.logo_icon.setPixmap(QtGui.QPixmap(self.get_svg_path("main.svg"))
|
||
.scaled(22, 22, QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||
QtCore.Qt.TransformationMode.SmoothTransformation))
|
||
|
||
self.logo_label = QtWidgets.QLabel("haocode")
|
||
self.logo_label.setObjectName("logo_label")
|
||
|
||
self.collapse_btn = QtWidgets.QPushButton("")
|
||
self.collapse_btn.setObjectName("collapse_btn")
|
||
self.collapse_btn.setIcon(QtGui.QIcon(self.get_svg_path("panel.svg")))
|
||
self.collapse_btn.setIconSize(QtCore.QSize(16, 16))
|
||
self.collapse_btn.setFixedSize(28, 28)
|
||
self.collapse_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self.collapse_btn.setToolTip("收起侧边栏")
|
||
|
||
self.sidebar_header_layout.addWidget(self.logo_icon, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||
self.sidebar_header_layout.addWidget(self.logo_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||
self.sidebar_header_layout.addStretch()
|
||
self.sidebar_header_layout.addWidget(self.collapse_btn, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||
|
||
self.sidebar_layout.addWidget(self.sidebar_header)
|
||
self.sidebar_layout.addSpacing(10)
|
||
|
||
self.history_list = DraggableHistoryList()
|
||
self.history_list.setObjectName("history_list")
|
||
self.sidebar_layout.addWidget(self.history_list)
|
||
|
||
self.btn_new_chat = QtWidgets.QPushButton(" + 新建对话")
|
||
self.btn_new_chat.setObjectName("btn_new_chat")
|
||
self.sidebar_layout.addWidget(self.btn_new_chat)
|
||
self.sidebar_layout.addSpacing(20)
|
||
|
||
self.btn_skills = QtWidgets.QPushButton("Skill & Tools")
|
||
self.btn_settings = QtWidgets.QPushButton("设置")
|
||
self.btn_help = QtWidgets.QPushButton("帮助")
|
||
self.btn_about = QtWidgets.QPushButton("关于 (1.19.0)")
|
||
for btn in [self.btn_skills, self.btn_settings, self.btn_help, self.btn_about]:
|
||
btn.setObjectName("sidebar_menu_btn")
|
||
self.sidebar_layout.addWidget(btn)
|
||
|
||
self.sidebar_stack.addWidget(self.expand_page)
|
||
|
||
# ---- 页面1:收缩状态(上下 stretch 使图标精确垂直居中)----
|
||
self.collapse_page = QtWidgets.QWidget()
|
||
self.collapse_lay = QtWidgets.QVBoxLayout(self.collapse_page)
|
||
self.collapse_lay.setContentsMargins(0, 0, 0, 0)
|
||
self.collapse_lay.addStretch(1)
|
||
self.collapse_expand_btn = QtWidgets.QPushButton("")
|
||
self.collapse_expand_btn.setObjectName("collapse_btn")
|
||
self.collapse_expand_btn.setIcon(QtGui.QIcon(self.get_svg_path("panel.svg")))
|
||
self.collapse_expand_btn.setIconSize(QtCore.QSize(18, 18))
|
||
self.collapse_expand_btn.setFixedSize(34, 34)
|
||
self.collapse_expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self.collapse_expand_btn.setToolTip("展开侧边栏")
|
||
self.collapse_lay.addWidget(self.collapse_expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter)
|
||
self.collapse_lay.addStretch(1)
|
||
self.sidebar_stack.addWidget(self.collapse_page)
|
||
|
||
# ==================== 2. 右侧主聊天区 ====================
|
||
self.chat_area = QtWidgets.QWidget()
|
||
self.chat_area.setObjectName("chat_area")
|
||
self.chat_layout = QtWidgets.QVBoxLayout(self.chat_area)
|
||
self.chat_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.chat_layout.setSpacing(0)
|
||
|
||
# --- 2.1 自定义顶部标题栏 ---
|
||
self.top_bar = QtWidgets.QWidget()
|
||
self.top_bar.setObjectName("top_bar")
|
||
self.top_bar.setFixedHeight(50)
|
||
self.top_bar_layout = QtWidgets.QHBoxLayout(self.top_bar)
|
||
self.top_bar_layout.setContentsMargins(20, 0, 10, 0)
|
||
|
||
self.top_label = QtWidgets.QLabel("")
|
||
self.top_label.setStyleSheet("font-weight: bold; font-size: 16px;")
|
||
self.top_bar_layout.addWidget(self.top_label)
|
||
self.top_bar_layout.addStretch()
|
||
|
||
self.btn_history = QtWidgets.QPushButton("历史")
|
||
self.btn_history.setObjectName("top_tool_btn")
|
||
self.top_bar_layout.addWidget(self.btn_history)
|
||
|
||
# 🆕 右侧任务面板的开关【不在这里】:与左侧栏一致,开关在栏内
|
||
# (收起=栏正中按钮 / 展开=栏标题行右上角按钮)→ 永远只有一个可动按钮
|
||
|
||
# 🌟 v2: 移除自绘 最小化/最大化/关闭 按钮 —— 已由 Windows 11 原生标题栏接管。
|
||
# 历史 按钮保留,随顶部工具栏整体下移到原生标题栏下方。
|
||
self.chat_layout.addWidget(self.top_bar)
|
||
|
||
# --- 2.2 🌟 核心修复:浏览器渲染引擎防穿帮“幕布” ---
|
||
self.browser_container = QtWidgets.QWidget()
|
||
self.browser_container.setObjectName("browser_container")
|
||
self.browser_container.setStyleSheet("background-color: #ffffff;") # 纯白幕布
|
||
self.browser_layout = QtWidgets.QVBoxLayout(self.browser_container)
|
||
self.browser_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.browser_layout.setSpacing(0)
|
||
|
||
# 创建浏览器(🆕 A′ 双轨:Windows 上优先 WebView2,失败/配置指定时回落 QtWebEngine)
|
||
# P1-03:平台矩阵 —— 解析 webview_backend 配置;非法值可见警告 + 回落平台默认,不阻断启动
|
||
self._wv2_session = None
|
||
_backend_pref = "auto"
|
||
try:
|
||
from core.config_paths import load_config as _load_cfg # P0-01 统一配置入口
|
||
_backend_pref = _load_cfg().get("webview_backend", "auto")
|
||
except Exception:
|
||
pass
|
||
_backend, _backend_warn = _renderer_backend.resolve_backend(_backend_pref)
|
||
if _backend_warn:
|
||
print(_backend_warn, flush=True)
|
||
if _backend == "webview2" and _wv2mod is not None and sys.platform == "win32":
|
||
_app = QtWidgets.QApplication.instance()
|
||
_env = _wv2mod.get_environment(_app)
|
||
if _env is not None:
|
||
try:
|
||
self._wv2_session = _wv2mod.Wv2Session(_env, int(self.winId()), _app)
|
||
except Exception as _ex:
|
||
print(f"[WV2] session 创建失败 → 回落 QtWebEngine: {_ex}")
|
||
self._wv2_session = None
|
||
|
||
if self._wv2_session is not None:
|
||
# ---- WebView2 路径 ----
|
||
from ui.views.wv2_view import WebView2View
|
||
self.browser = WebView2View(self._wv2_session)
|
||
self.browser.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
|
||
QtWidgets.QSizePolicy.Policy.Expanding)
|
||
self.browser.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
|
||
# 桥接:Python→JS 走 ExecuteScript(JS 文本同构);JS→Python 走 WebMessageReceived
|
||
self.chat_bridge = ChatBridge(None, None, js_runner=self.browser.run_js)
|
||
self.browser.attach_bridge(self.chat_bridge)
|
||
self.web_channel = None
|
||
print("[System] 浏览器内核: WebView2")
|
||
else:
|
||
# ---- QtWebEngine 路径(原逻辑,保持可用) ----
|
||
# P1-03:本实例独立 profile 目录(两个并行源码实例不争用同一个 Chromium profile;
|
||
# 目录在 data/webengine/,测试经 HAOCODE_WEBENGINE_PROFILE_DIR 重定向到临时目录)
|
||
from PyQt6.QtWebEngineCore import QWebEngineProfile
|
||
_prof_name = _renderer_backend.webengine_profile_name()
|
||
_prof_dir = _renderer_backend.webengine_profile_dir(_prof_name)
|
||
self._qtwe_profile = QWebEngineProfile(_prof_name)
|
||
self._qtwe_profile.setPersistentStoragePath(os.path.join(_prof_dir, "storage"))
|
||
self._qtwe_profile.setCachePath(os.path.join(_prof_dir, "cache"))
|
||
print(f"[Renderer] QtWebEngine 独立 profile: {_prof_dir}", flush=True)
|
||
|
||
self.browser = QWebEngineView()
|
||
# 🌟 关键修改:使用自定义的 WebPage
|
||
custom_page = CustomWebPage(self._qtwe_profile, self.browser)
|
||
self.browser.setPage(custom_page)
|
||
|
||
# 🌟 v2: 不透明绘制属性 —— 告诉 Qt 无需先擦背景再等 WebEngine 纹理,
|
||
# 减少窗口缩放瞬间新暴露区域的底色闪烁(配合白底幕布 + main.py 的 GPU 光栅化 flags)
|
||
self.browser.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
|
||
self.browser.setAttribute(QtCore.Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
||
|
||
# 🌟 让 Chromium 合成器用白色作为清屏/新暴露区域的底色,而非默认黑色:
|
||
# → GPU 合成(59 FPS)+ 白底不黑闪,兼得速度与观感(配合 main.py 的合成模式)
|
||
self.browser.page().setBackgroundColor(QtGui.QColor("#ffffff"))
|
||
|
||
# 其他配置保持不变
|
||
self.browser.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
|
||
QtWidgets.QSizePolicy.Policy.Expanding)
|
||
self.browser.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
|
||
|
||
# 🌟 初始化桥接器时,传入自定义的 page
|
||
from PyQt6.QtWebChannel import QWebChannel
|
||
|
||
# 创建 QWebChannel,它是 Python ↔ JS 的桥梁
|
||
self.web_channel = QWebChannel()
|
||
|
||
# 创建 ChatBridge,传入 page 和 channel(构造函数内部会 channel.registerObject)
|
||
self.chat_bridge = ChatBridge(custom_page, self.web_channel)
|
||
|
||
# 把 channel 设置到 page 上,使 qt.webChannelTransport 生效
|
||
custom_page.setWebChannel(self.web_channel)
|
||
print("[System] 浏览器内核: QtWebEngine")
|
||
|
||
self.browser_container.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
|
||
|
||
# 将浏览器放入布局
|
||
self.browser_layout.addWidget(self.browser)
|
||
|
||
# 🌟 自定义滚动条:固定在浏览器右侧,总高度随浏览器变化(镜像网页滚动)
|
||
self.web_scrollbar = WebScrollBar(self.chat_bridge)
|
||
self.browser_row = QtWidgets.QWidget()
|
||
self.browser_row_layout = QtWidgets.QHBoxLayout(self.browser_row)
|
||
self.browser_row_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.browser_row_layout.setSpacing(0)
|
||
self.browser_row_layout.addWidget(self.browser_container, 1)
|
||
self.browser_row_layout.addWidget(self.web_scrollbar, 0)
|
||
self.chat_layout.addWidget(self.browser_row) # 浏览器 + 滚动条整行加入主布局
|
||
|
||
# --- 2.3 底部输入框容器 ---
|
||
self.input_container = QtWidgets.QFrame()
|
||
self.input_container.setObjectName("input_container")
|
||
# 💡 小优化:把最大高度从 200 调到 250,给折叠框和滚动条留出足够的呼吸空间
|
||
self.input_container.setMaximumHeight(250)
|
||
|
||
self.input_container_layout = QtWidgets.QVBoxLayout(self.input_container)
|
||
|
||
# 【第 1 行】工具栏 (图标)
|
||
self.input_tools_layout = QtWidgets.QHBoxLayout()
|
||
self.btn_upload = QtWidgets.QPushButton("") # 清空原来的 Emoji 字符
|
||
self.btn_upload.setIcon(QtGui.QIcon(self.get_svg_path("upload.svg"))) # 使用我们刚才写的安全路径工具
|
||
self.btn_upload.setIconSize(QtCore.QSize(18, 18)) # 图标设为 18x18,配合 30x30 的按钮留出绝佳边距
|
||
|
||
self.btn_screenshot = QtWidgets.QPushButton("✂️")
|
||
self.btn_web = QtWidgets.QPushButton("🌐")
|
||
self.btn_skill = QtWidgets.QPushButton("🛠️")
|
||
self.btn_server = QtWidgets.QPushButton("☁️")
|
||
for btn in [self.btn_upload, self.btn_screenshot, self.btn_web, self.btn_skill, self.btn_server]:
|
||
btn.setFixedSize(30, 30)
|
||
btn.setObjectName("icon_btn")
|
||
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self.input_tools_layout.addWidget(btn)
|
||
self.input_tools_layout.addStretch()
|
||
self.input_container_layout.addLayout(self.input_tools_layout)
|
||
|
||
# 【第 2 行】🌟 多附件标签滚动区域
|
||
self.attachment_scroll_area = QtWidgets.QScrollArea()
|
||
self.attachment_scroll_area.setVisible(False)
|
||
self.attachment_scroll_area.setWidgetResizable(True)
|
||
self.attachment_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.attachment_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
self.attachment_scroll_area.setFixedHeight(48)
|
||
self.attachment_scroll_area.setStyleSheet("""
|
||
QScrollArea { border: none; background: transparent; }
|
||
QScrollBar:horizontal { border: none; background: transparent; height: 6px; margin: 0px; }
|
||
QScrollBar::handle:horizontal { background: #c0c0c0; min-width: 30px; border-radius: 3px; }
|
||
QScrollBar::handle:horizontal:hover { background: #a0a0a0; }
|
||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; }
|
||
""")
|
||
|
||
self.attachment_area = QtWidgets.QWidget()
|
||
self.attachment_area.setStyleSheet("background: transparent;")
|
||
self.attachment_area_layout = QtWidgets.QHBoxLayout(self.attachment_area)
|
||
self.attachment_area_layout.setContentsMargins(4, 0, 4, 8)
|
||
self.attachment_area_layout.setSpacing(8)
|
||
self.attachment_area_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
|
||
|
||
self.attachment_scroll_area.setWidget(self.attachment_area)
|
||
self.input_container_layout.addWidget(self.attachment_scroll_area) # <--- 确保它被作为第2行加入
|
||
|
||
# 【第 3 行】文本输入框
|
||
self.text_input = PasteAwareTextEdit()
|
||
self.text_input.setPlaceholderText("给 haocode 发送消息...")
|
||
self.text_input.setObjectName("text_input")
|
||
self.text_input.setMaximumHeight(100)
|
||
self.input_container_layout.addWidget(self.text_input)
|
||
|
||
# 【第 4 行】底部栏 (提示文字、模型选择、发送按钮)
|
||
self.input_bottom_layout = QtWidgets.QHBoxLayout()
|
||
self.hint_label = QtWidgets.QLabel("AI 生成内容可能不准确。")
|
||
self.hint_label.setObjectName("hint_label")
|
||
|
||
self.context_label = QtWidgets.QLabel("128k / 200k")
|
||
self.context_label.setObjectName("context_label")
|
||
|
||
self.model_selector = QtWidgets.QPushButton("gemini-3-pro-preview...")
|
||
self.model_selector.setObjectName("model_selector")
|
||
# 🆕 圆角改造:与模式按钮(mode_btn)完全一致的白底 13px 圆角,同高 26
|
||
self.model_selector.setFixedHeight(26)
|
||
self.model_selector.setMinimumWidth(96)
|
||
|
||
self.btn_send = QtWidgets.QPushButton("") # 1. 传入空字符串,去除原本的文字
|
||
self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("send.svg"))) # 2. 加载 SVG 图标(用冻结安全路径)
|
||
self.btn_send.setIconSize(QtCore.QSize(20, 20)) # 3. 设置图标大小 (稍微比 32x32 的按钮小一点,留出边距更好看)
|
||
self.btn_send.setObjectName("btn_send")
|
||
self.btn_send.setFixedSize(32, 32)
|
||
|
||
# ============ 🆕 模式切换按钮(chat / worker) ============
|
||
# 按按钮 → 上方浮动窗缓慢弹出(SessionModePopup,与模型选择弹窗同款实现);
|
||
# 选择后发送第一条消息 → 该会话锁定此模式
|
||
self._mode_panel_selected = "chat"
|
||
|
||
self.btn_mode = QtWidgets.QToolButton()
|
||
self.btn_mode.setObjectName("mode_btn")
|
||
self.btn_mode.setFixedSize(96, 26)
|
||
self.btn_mode.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
self.btn_mode.setToolTip("切换聊天模式(发送第一条消息后锁定)")
|
||
self.btn_mode.clicked.connect(self.show_mode_popup)
|
||
|
||
self.input_bottom_layout.addWidget(self.hint_label)
|
||
self.input_bottom_layout.addStretch()
|
||
self.input_bottom_layout.addWidget(self.context_label)
|
||
self.input_bottom_layout.addWidget(self.model_selector)
|
||
self.input_bottom_layout.addWidget(self.btn_mode)
|
||
self.input_bottom_layout.addWidget(self.btn_send)
|
||
self.input_container_layout.addLayout(self.input_bottom_layout)
|
||
|
||
# 打包装入主聊天区域
|
||
self.input_wrapper = QtWidgets.QHBoxLayout()
|
||
self.input_wrapper.setContentsMargins(20, 0, 20, 20)
|
||
self.input_wrapper.addWidget(self.input_container)
|
||
self.chat_layout.addLayout(self.input_wrapper)
|
||
|
||
self.main_layout.addWidget(self.sidebar)
|
||
self.main_layout.addWidget(self.chat_area)
|
||
# 🆕 右侧任务面板(默认收起 52px;不参与 resizeEvent 自动收缩,只能手动切换)
|
||
self.bash_panel = BashPanel(self)
|
||
self.main_layout.addWidget(self.bash_panel)
|
||
self.browser.loadFinished.connect(self.on_web_load_finished)
|
||
|
||
# ==========================================
|
||
# 🌟 数据库与历史记录初始化逻辑 (放在 UI 控件全建好之后)
|
||
# ==========================================
|
||
self.db = DBManager()
|
||
|
||
if self.db.is_first_run:
|
||
print("[System] 欢迎!检测到初次运行,已自动创建本地数据库和初始对话。")
|
||
else:
|
||
print("[System] 欢迎回来!成功读取本地数据库。")
|
||
|
||
# 1. 获取所有会话并在侧边栏渲染
|
||
sessions = self.db.get_all_sessions()
|
||
|
||
# 清空 UI 上可能残留的占位符
|
||
self.history_list.clear()
|
||
|
||
# 🟢 改为:
|
||
self.current_session_id = None # 先初始化
|
||
self.pending_mode = "chat" # 🆕 锁定前选中的模式(默认 chat)
|
||
self.rebuild_sidebar()
|
||
|
||
# 2. 确定启动时默认加载的对话 (默认最新的一条)
|
||
if sessions:
|
||
self.current_session_id = sessions[0]["id"]
|
||
# 让 UI 列表默认选中第一项
|
||
self.history_list.setCurrentRow(0)
|
||
|
||
# ⚠️ 注意:不要在这里直接调用 load_messages_to_web!
|
||
# 因为此时 QWebEngineView 的网页还没加载完,直接执行 JS 会报错。
|
||
# 我们只需记住 current_session_id,等网页加载完毕后再灌入数据。
|
||
else:
|
||
self.current_session_id = None
|
||
self.pending_load_session_id = None
|
||
|
||
|
||
# ==================== 🌟 核心新增:初始化浏览器 ====================
|
||
# 把它放在 def setup_ui(self): 的正上方
|
||
def get_svg_path(self, filename):
|
||
"""🌟 自动获取 svg 文件的绝对路径,并检测文件是否存在"""
|
||
import os
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
project_root = os.path.abspath(os.path.join(base_dir, "..", ".."))
|
||
full_path = os.path.join(project_root, "svg", filename)
|
||
|
||
# 🛠️ 杀手锏:帮你自动检查文件到底在不在!
|
||
if not os.path.exists(full_path):
|
||
print(f"\n❌ [严重警告]: 找不到图标文件!系统去这里找了,但是没找到: \n -> {full_path}\n")
|
||
else:
|
||
print(f"\n✅ [成功]: 找到了图标文件: {full_path}")
|
||
|
||
return full_path
|
||
|
||
def init_browser(self):
|
||
"""初始化浏览器设置、信号绑定及页面加载"""
|
||
|
||
# 🆕 P1-01 渲染窗口:配置(10/40/200,非法值静默回落 40)+ 代次管理
|
||
from core import config_paths as _cp
|
||
_rw_cfg = _cp.render_window_settings()
|
||
self._rw_mode = _rw_cfg["mode"]
|
||
self._rw_size = _rw_cfg["size"]
|
||
# 页大小 = 半窗:保证向上换页时“首个可见消息”锚点留在窗口内(锚点恢复前提)
|
||
self._rw_page_size = max(1, self._rw_size // 2)
|
||
self._rw_generation = 0 # 每次加载/切会话/分支/删除/重答/新建 +1
|
||
self._rw_config_pushed = False
|
||
self._rw_page_inflight = False
|
||
|
||
# 1. 信号绑定 (ChatBridge 已在 setup_ui 中通过 QWebChannel 注册)
|
||
# 重新回答信号
|
||
self.chat_bridge.regenerate_clicked.connect(self._on_regenerate_clicked)
|
||
|
||
# 分支切换信号
|
||
self.chat_bridge.branch_switch_clicked.connect(self._on_branch_switch)
|
||
|
||
# 删除消息信号
|
||
self.chat_bridge.delete_message_requested.connect(self._on_delete_message)
|
||
# 🌟 绑定附件点击信号
|
||
self.chat_bridge.attachment_clicked.connect(self._on_attachment_clicked)
|
||
# 🆕 P1-01 渲染窗口换页请求(fire-and-forget,响应经 rwPageResponse 推送)
|
||
self.chat_bridge.window_page_requested.connect(self._on_window_page_request)
|
||
# 2. 计算本地 HTML 路径
|
||
# 假设 web 文件夹位于当前脚本目录的上一级
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
html_path = os.path.normpath(os.path.join(base_dir, '..', 'web', 'index.html'))
|
||
|
||
# 3. 加载页面
|
||
# 注意:确保在加载 URL 之前,QWebChannel 已经正确设置到了 self.browser.page() 上
|
||
# 🛡 防缓存:file:// URL 追加时间戳查询串 —— 每次启动保证加载最新 web/ 代码,
|
||
# 避免 QtWebEngine 残留旧 JS/CSS 导致“改了前端没生效”
|
||
_u = QUrl.fromLocalFile(html_path)
|
||
# 🆕 立即设按钮初始文本:WebView2 冷启动链路慢(~10s+),不能等页面加载后才填按钮
|
||
try:
|
||
self._refresh_mode_button()
|
||
except Exception:
|
||
pass
|
||
self.browser.setUrl(QUrl(_u.toString() + "?hocode_v=" + str(int(_diag_time.time() * 1000))))
|
||
def _on_attachment_clicked(self, meta_json_str):
|
||
"""🌟 弹出原生附件预览窗口"""
|
||
import json
|
||
try:
|
||
meta = json.loads(meta_json_str)
|
||
# self.bg_widget 是铺满整个主窗口的底层容器,挂在它上面就能完美覆盖全屏
|
||
# PDF 图片卡:用勾选的图片开可翻页预览(抄弹窗预览那套,页码显示在顶部标题)
|
||
if meta.get("type") == "pdf" and meta.get("mode") == "image" and meta.get("selected_images"):
|
||
base_name = meta.get("name", "")
|
||
siblings = [{
|
||
"type": "image",
|
||
"name": f'{base_name} · 第 {x["page"]} 页 no.{x["index"]}',
|
||
"local_path": x["local_path"],
|
||
"mime": x.get("mime", "image/png"),
|
||
"size_kb": x.get("size_kb", 0),
|
||
} for x in meta["selected_images"]]
|
||
overlay = AttachmentPreviewOverlay(siblings[0], self.bg_widget,
|
||
siblings=siblings, index=0)
|
||
else:
|
||
overlay = AttachmentPreviewOverlay(meta, self.bg_widget)
|
||
except Exception as e:
|
||
print(f"[Warn]: 打开附件预览失败: {e}")
|
||
|
||
def setup_stylesheet(self):
|
||
self.setStyleSheet("""
|
||
* { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", "Segoe UI", sans-serif; }
|
||
|
||
#bg_widget { background-color: #ffffff; }
|
||
#chat_area { background-color: #ffffff; }
|
||
|
||
/* ---- 左侧边栏 / 右侧任务面板(共用一套淡蓝设计语言)---- */
|
||
#sidebar { background-color: #f7f8fa; border-right: 1px solid #ececec; }
|
||
#right_sidebar { background-color: #f7f8fa; border-left: 1px solid #ececec; }
|
||
#logo_icon { background: transparent; }
|
||
#logo_label { font-size: 20px; font-weight: 900; color: #1a2332; letter-spacing: 0.5px; background: transparent; }
|
||
#collapse_btn { background: transparent; border: none; border-radius: 8px; }
|
||
#collapse_btn:hover { background-color: #e6e8ee; }
|
||
|
||
/* ---- 侧边栏历史列表(🐛 用 objectName 限定作用域:旧版是 QListWidget 类型选择器,
|
||
会级联泄漏进模型选择弹窗——选中行 3px 蓝竖条/padding/margin 全部漏进去,
|
||
造成“调参工具与实际不一致”。现在只作用于 #history_list) ---- */
|
||
#history_list { border: none; background: transparent; outline: none; }
|
||
#history_list::item { padding: 10px 12px; border-radius: 8px; margin: 2px 4px; color: #444; }
|
||
#history_list::item:hover { background-color: #eceef2; }
|
||
#history_list::item:selected { background-color: #e4edfa; color: #1a3a63; font-weight: 600; border-left: 3px solid #3b82f6; }
|
||
|
||
#btn_new_chat { background-color: #ffffff; border: 1px solid #e2e5ea; border-radius: 10px; padding: 10px; font-weight: 600; color: #1f2937; }
|
||
#btn_new_chat:hover { background-color: #eef2f7; border-color: #cfd6e0; }
|
||
#sidebar_menu_btn { text-align: left; border: none; padding: 9px 12px; margin: 0 4px; color: #5b6472; font-size: 13px; border-radius: 8px; }
|
||
#sidebar_menu_btn:hover { background-color: #eceef2; color: #111827; }
|
||
|
||
/* ---- 🆕 右侧任务面板(继承左侧栏配色:#f7f8fa / #ececec / 淡蓝 #e4edfa)---- */
|
||
#bl_panel_head { background: transparent; }
|
||
#bl_panel_title { font-size: 13px; font-weight: 700; color: #1a2332; }
|
||
#bl_panel_total { font-size: 11px; color: #8a94a6; }
|
||
#bl_splitter::handle { background: #e9ecf3; }
|
||
#bl_splitter::handle:hover { background: #d7deea; }
|
||
#bl_sect_head { background: transparent; }
|
||
#bl_sect_head:hover { background-color: #eceef2; }
|
||
#bl_sect_title { font-size: 12px; font-weight: 600; color: #33415c; }
|
||
#bl_sect_count { font-size: 11px; color: #7c8698; }
|
||
#bl_sect_chev { color: #8a94a6; font-size: 10px; }
|
||
#bl_scroll, #bl_host { background: transparent; border: none; }
|
||
#bl_hint { color: #9aa4b5; font-size: 11px; padding: 2px 10px 8px 10px; }
|
||
#bl_layer { background-color: #ffffff; border: 1px solid #e6eaf2; border-radius: 9px; }
|
||
#bl_layer:hover { border-color: #cfe0f7; }
|
||
#bl_head { background: transparent; border-radius: 9px; }
|
||
#bl_head:hover { background-color: #f2f7fe; }
|
||
#bl_dot { font-size: 10px; color: #9aa4b5; }
|
||
#bl_dot[state="run"] { color: #3b82f6; }
|
||
#bl_dot[state="ok"] { color: #16a34a; }
|
||
#bl_dot[state="bad"] { color: #dc2626; }
|
||
#bl_name { font-size: 11px; font-weight: 700; color: #1a3a63; }
|
||
#bl_meta { font-size: 10px; color: #7c8698; }
|
||
#bl_cmd { font-size: 10px; color: #5b6472; }
|
||
#bl_chev { color: #9aa4b5; font-size: 10px; }
|
||
#bl_tag { font-size: 9px; border-radius: 6px; padding: 1px 5px;
|
||
color: #4b5563; background-color: #eef0f4; }
|
||
#bl_tag[kind="run"] { color: #1d4ed8; background-color: #e4edfa; }
|
||
#bl_tag[kind="ok"] { color: #15803d; background-color: #e7f6ec; }
|
||
#bl_tag[kind="bad"] { color: #b91c1c; background-color: #fdeaea; }
|
||
#bl_tag[kind="out"] { color: #6b7280; background-color: #eef0f4; }
|
||
#bl_sect_label { font-size: 10px; font-weight: 600; color: #7c8698; }
|
||
#bl_code { background-color: #fbfcfe; border: 1px solid #e6eaf2; border-radius: 7px;
|
||
padding: 4px; font-family: Consolas, "Courier New", monospace;
|
||
font-size: 11px; color: #243043; }
|
||
/* ---- 🆕 P2-02:右侧任务面板滚动条 + 横纵交汇角(全部选择器限定在
|
||
#bl_code / #bl_scroll,绝不添加无作用域的 QScrollBar/QAbstractScrollArea
|
||
全局规则,不污染模型弹窗/会话列表/附件预览等其他控件) ----
|
||
口径:厚 8px、隐藏箭头(add-line/sub-line 置 0)、handle 可见 + hover、
|
||
corner 用 Qt 支持的 QAbstractScrollArea::corner 子控件(不写 QScrollBar::corner),
|
||
与代码框背景 #fbfcfe 一致(消除原生亮色 corner 方块) */
|
||
QPlainTextEdit#bl_code QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; }
|
||
QPlainTextEdit#bl_code QScrollBar::handle:vertical { background: #d0d0d0; min-height: 30px; border-radius: 4px; }
|
||
QPlainTextEdit#bl_code QScrollBar::handle:vertical:hover { background: #a0a0a0; }
|
||
QPlainTextEdit#bl_code QScrollBar::add-line:vertical, QPlainTextEdit#bl_code QScrollBar::sub-line:vertical { height: 0px; border: none; background: transparent; }
|
||
QPlainTextEdit#bl_code QScrollBar::add-page:vertical, QPlainTextEdit#bl_code QScrollBar::sub-page:vertical { background: transparent; }
|
||
QPlainTextEdit#bl_code QScrollBar:horizontal { border: none; background: transparent; height: 8px; margin: 0px; }
|
||
QPlainTextEdit#bl_code QScrollBar::handle:horizontal { background: #d0d0d0; min-width: 30px; border-radius: 4px; }
|
||
QPlainTextEdit#bl_code QScrollBar::handle:horizontal:hover { background: #a0a0a0; }
|
||
QPlainTextEdit#bl_code QScrollBar::add-line:horizontal, QPlainTextEdit#bl_code QScrollBar::sub-line:horizontal { width: 0px; border: none; background: transparent; }
|
||
QPlainTextEdit#bl_code QScrollBar::add-page:horizontal, QPlainTextEdit#bl_code QScrollBar::sub-page:horizontal { background: transparent; }
|
||
QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; }
|
||
QScrollArea#bl_scroll QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; }
|
||
QScrollArea#bl_scroll QScrollBar::handle:vertical { background: #d0d0d0; min-height: 30px; border-radius: 4px; }
|
||
QScrollArea#bl_scroll QScrollBar::handle:vertical:hover { background: #a0a0a0; }
|
||
QScrollArea#bl_scroll QScrollBar::add-line:vertical, QScrollArea#bl_scroll QScrollBar::sub-line:vertical { height: 0px; border: none; background: transparent; }
|
||
QScrollArea#bl_scroll QScrollBar::add-page:vertical, QScrollArea#bl_scroll QScrollBar::sub-page:vertical { background: transparent; }
|
||
QScrollArea#bl_scroll::corner { background: transparent; }
|
||
#bl_body { background: transparent; }
|
||
|
||
/* ---- 顶部栏 ---- */
|
||
#top_bar { background-color: #ffffff; border-bottom: 1px solid #eeeeee; }
|
||
#top_tool_btn { background: transparent; border: 1px solid #e2e5ea; border-radius: 8px; padding: 5px 14px; color: #4b5563; font-size: 12px; }
|
||
#top_tool_btn:hover { background-color: #f3f4f6; border-color: #d1d5db; color: #111827; }
|
||
|
||
/* ---- 输入区 ---- */
|
||
#input_container { background-color: #f6f7f9; border: 1px solid #e4e6ea; border-radius: 20px; padding: 10px; }
|
||
#icon_btn { background: transparent; border: none; border-radius: 15px; }
|
||
#icon_btn:hover { background-color: #e6e8ec; }
|
||
#text_input { border: none; background: transparent; font-size: 15px; color: #1f2937; selection-background-color: #bfdbfe; }
|
||
#hint_label { color: #9ca3af; font-size: 11px; }
|
||
#context_label { color: #6b7280; font-size: 12px; margin-right: 10px; }
|
||
/* 🆕 模型选择按钮:与模式按钮(mode_btn)完全一致的圆角样式 */
|
||
#model_selector { background: #ffffff; border: 1px solid #e2e5ea; border-radius: 13px; font-size: 12px; color: #4b5563; padding: 2px 8px; }
|
||
#model_selector:hover { background-color: #eef1f5; border-color: #cbd5e1; color: #111827; }
|
||
|
||
/* 🆕 模式切换按钮(选择窗为 SessionModePopup 浮动窗,配色由其自己注入) */
|
||
#mode_btn { background: #ffffff; border: 1px solid #e2e5ea; border-radius: 13px; font-size: 12px; color: #4b5563; padding: 2px 8px; }
|
||
#mode_btn:hover { background-color: #eef1f5; border-color: #cbd5e1; color: #111827; }
|
||
|
||
/* ---- 发送按钮(实际配色由 set_send_button_state 动态控制) ---- */
|
||
#btn_send { background-color: #2563eb; border: none; border-radius: 16px; font-weight: 700; }
|
||
#btn_send:hover { background-color: #1d4ed8; }
|
||
#btn_send:pressed { background-color: #1e40af; }
|
||
""")
|
||
|
||
def set_send_button_state(self, is_generating: bool):
|
||
"""🌟 统一管理发送/停止按钮的 UI 状态"""
|
||
if is_generating:
|
||
# 正在生成:红色停止按钮(白色图标)
|
||
self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("stop_white.svg")))
|
||
self.btn_send.setStyleSheet("""
|
||
QPushButton { background-color: #ef4444; border: none; border-radius: 16px; font-weight: 700; }
|
||
QPushButton:hover { background-color: #dc2626; }
|
||
QPushButton:pressed { background-color: #b91c1c; }
|
||
""")
|
||
else:
|
||
# 空闲状态:蓝色发送按钮(白色图标)
|
||
self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("send_white.svg")))
|
||
self.btn_send.setStyleSheet("""
|
||
QPushButton { background-color: #2563eb; border: none; border-radius: 16px; font-weight: 700; }
|
||
QPushButton:hover { background-color: #1d4ed8; }
|
||
QPushButton:pressed { background-color: #1e40af; }
|
||
""")
|
||
|
||
# ==================== 🌟 侧边栏收缩 ====================
|
||
@QtCore.pyqtProperty(float)
|
||
def sidebarWidth(self):
|
||
"""侧边栏宽度属性:供 QPropertyAnimation 平滑插值收缩/展开"""
|
||
return float(self.sidebar.width())
|
||
|
||
@sidebarWidth.setter
|
||
def sidebarWidth(self, w):
|
||
self.sidebar.setFixedWidth(int(w))
|
||
|
||
def _effective_collapsed(self):
|
||
"""当前生效的收缩状态:动画播放中按其目标方向,否则按已落定状态。"""
|
||
if self._sidebar_anim is not None:
|
||
return self._sidebar_target
|
||
return self.sidebar_collapsed
|
||
|
||
def toggle_sidebar(self):
|
||
"""手动切换侧边栏展开/收缩"""
|
||
self._set_sidebar_collapsed(not self._effective_collapsed())
|
||
|
||
def _set_sidebar_collapsed(self, collapsed: bool):
|
||
"""收缩/展开侧边栏:从右到左平滑滑动宽度动画。
|
||
展开/收缩分别使用独立干净页面(QStackedWidget),
|
||
收缩图标由上下 stretch 精确垂直居中,彻底规避残留间距导致的偏移。
|
||
"""
|
||
# 若已有动画正在播放,先停止并重定向到新目标(保证 resize 时也能正常工作)
|
||
if self._sidebar_anim is not None:
|
||
self._sidebar_anim.stop()
|
||
self._sidebar_anim.deleteLater()
|
||
self._sidebar_anim = None
|
||
|
||
self._sidebar_target = collapsed
|
||
|
||
# 🌟 切换页面:收缩页(图标精确居中) / 展开页(完整内容)
|
||
self.sidebar_stack.setCurrentWidget(
|
||
self.collapse_page if collapsed else self.expand_page)
|
||
|
||
# 🌟 从右到左 / 从左到右 宽度滑动动画
|
||
start_w = self.sidebar.width()
|
||
end_w = 52 if collapsed else 260
|
||
self._sidebar_anim = QtCore.QPropertyAnimation(self, b"sidebarWidth")
|
||
self._sidebar_anim.setDuration(260)
|
||
self._sidebar_anim.setStartValue(float(start_w))
|
||
self._sidebar_anim.setEndValue(float(end_w))
|
||
self._sidebar_anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic)
|
||
self._sidebar_anim.finished.connect(self._on_sidebar_anim_finished)
|
||
self._sidebar_anim.start()
|
||
|
||
def _on_sidebar_anim_finished(self):
|
||
"""动画结束:锁定最终宽度并落定状态。"""
|
||
self.sidebar.setFixedWidth(52 if self._sidebar_target else 260)
|
||
self.sidebar_collapsed = self._sidebar_target
|
||
self._sidebar_anim.deleteLater()
|
||
self._sidebar_anim = None
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
# 🌟 侧边栏自动收缩:带回滞,且动画播放中也能正确重定向(resize 下同样正常工作)
|
||
eff = self._effective_collapsed()
|
||
if eff:
|
||
# 当前处于(或正趋向)收缩态:仅当宽度明显变大才展开
|
||
if self.width() > 1150:
|
||
self._set_sidebar_collapsed(False)
|
||
else:
|
||
# 当前处于(或正趋向)展开态:仅当宽度明显变小才收缩
|
||
if self.width() < 1050:
|
||
self._set_sidebar_collapsed(True)
|
||
|
||
# 🌟 v2: 原生边框自带拉伸,EdgeGrip 隐形拉伸块已移除,无需手动几何计算。
|
||
|
||
# 设置窗口跟随主窗口自适应
|
||
if hasattr(self, '_settings_win') and self._settings_win is not None:
|
||
self._settings_win.resize_to_parent()
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
# 🌟 v2: 每次显示都重新声明圆角偏好(Windows 11 22000+)。
|
||
# 最大化时 Windows 会按系统规则自动切换为直角,属正常行为。
|
||
self._apply_win11_rounded_corners()
|
||
|
||
def _apply_win11_rounded_corners(self):
|
||
"""通过 DWM 设置窗口圆角偏好(Windows 11 原生圆角)。"""
|
||
try:
|
||
import ctypes
|
||
if not hasattr(ctypes, 'windll'):
|
||
return # 非 Windows 平台无需处理
|
||
# DWMWA_WINDOW_CORNER_PREFERENCE = 33, DWMWCP_ROUND = 2
|
||
val = ctypes.c_int(2)
|
||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||
ctypes.c_void_p(int(self.winId())),
|
||
33,
|
||
ctypes.byref(val),
|
||
ctypes.sizeof(val)
|
||
)
|
||
except Exception as e:
|
||
print(f"[Warn]: 设置窗口圆角失败: {e}")
|
||
|
||
|
||
def _try_remove_file(self, path: str, absolute: bool = False) -> None:
|
||
"""尽力物理删除一个附件缓存文件, 失败仅打印告警。
|
||
|
||
Args:
|
||
path: 相对项目根目录的路径, 或 absolute=True 时的绝对路径。
|
||
absolute: path 是否已是绝对路径。
|
||
"""
|
||
import os
|
||
if not path:
|
||
return
|
||
if absolute:
|
||
abs_path = path
|
||
else:
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
abs_path = os.path.join(root_dir, path)
|
||
try:
|
||
if os.path.exists(abs_path):
|
||
os.remove(abs_path)
|
||
print(f"[System]: 已清理废弃附件缓存 -> {path}")
|
||
except Exception as e:
|
||
print(f"[Warn]: 清理废弃附件失败: {e}")
|
||
|
||
def remove_attachment(self, tag_widget, index):
|
||
"""删除指定的附件标签,如果是未发送的图片,同时清理本地缓存文件"""
|
||
import os
|
||
|
||
# 将对应位置标记为 None(不直接 pop,防止索引错乱)
|
||
if index < len(self._folded_texts):
|
||
att_data = self._folded_texts[index]
|
||
|
||
# 🌟 核心新增:垃圾回收机制 (物理删除废弃附件缓存)
|
||
if att_data and att_data.get("type") in ("image", "pdf"):
|
||
# 附件本体文件 (图片 / PDF 拷贝)
|
||
if att_data.get("local_path"):
|
||
self._try_remove_file(att_data["local_path"])
|
||
# PDF 图片解析模式提取出的所有图片
|
||
for im in att_data.get("images", []):
|
||
abs_im = im.get("abs_path")
|
||
if abs_im:
|
||
self._try_remove_file(abs_im, absolute=True)
|
||
|
||
# 标记数据为废弃
|
||
self._folded_texts[index] = None
|
||
|
||
# 从布局中移除并销毁 widget
|
||
self.attachment_area_layout.removeWidget(tag_widget)
|
||
tag_widget.deleteLater()
|
||
|
||
# 如果所有附件都被删除了,隐藏容器并恢复提示语
|
||
if all(t is None for t in self._folded_texts):
|
||
self._folded_texts.clear()
|
||
self.attachment_scroll_area.setVisible(False)
|
||
self.text_input.setPlaceholderText("给 haocode 发送消息...")
|
||
|
||
print(f"[UI]: 已移除附件 {index + 1}")
|
||
|
||
|
||
|
||
|
||
def reset_input_ui(self):
|
||
"""恢复输入框和按钮的初始状态"""
|
||
self.set_send_button_state(False)
|
||
|
||
|
||
# ==================== 事件拦截(P0-02:MainWindow 唯一 eventFilter) ====================
|
||
def eventFilter(self, obj, event):
|
||
"""统一事件过滤器:只处理输入框的 Enter / Shift+Enter,其余对象与事件全部交给父类。
|
||
「是否允许发送」的规则是 send_message(from_enter=True) 里的单一实现,
|
||
与发送按钮点击路径共用;一次按键至多触发一次 send_message 调用。"""
|
||
if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress:
|
||
if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
|
||
if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier:
|
||
return False # Shift+Enter:放行换行
|
||
self.send_message(from_enter=True)
|
||
return True # 事件已消费:不往输入框插入换行
|
||
return super().eventFilter(obj, event)
|
||
# ==================== 🌟 核心新增:发送与线程控制 ====================
|
||
def init_chat_events(self):
|
||
# 1. 绑定发送按钮
|
||
self.btn_send.clicked.connect(self.send_message)
|
||
|
||
# 2. 为输入框安装事件过滤器 (拦截 Enter 键)
|
||
self.text_input.installEventFilter(self)
|
||
self.text_input.long_text_pasted.connect(self._on_long_text_pasted)
|
||
self.text_input.image_pasted.connect(self._on_image_pasted)
|
||
self.text_input.files_pasted.connect(self._on_files_dropped) # 🌟 文本/代码文件拖拽/粘贴
|
||
# 3. 绑定左侧边栏按钮 (占位)
|
||
self.btn_new_chat.clicked.connect(self.on_new_chat_clicked)
|
||
self.collapse_btn.clicked.connect(self.toggle_sidebar)
|
||
self.collapse_expand_btn.clicked.connect(self.toggle_sidebar)
|
||
|
||
# 🌟 自定义滚动条:前端页面滚动 → 滑块位置/长度镜像
|
||
self.chat_bridge.scroll_changed.connect(self.web_scrollbar.set_scroll_info)
|
||
self.btn_skills.clicked.connect(lambda: print("\n[UI]: 点击了 -> Skill & Tools"))
|
||
self.btn_settings.clicked.connect(self.show_settings) # 或者你原来的写法
|
||
self.btn_help.clicked.connect(lambda: print("\n[UI]: 点击了 -> 帮助"))
|
||
self.btn_about.clicked.connect(lambda: print("\n[UI]: 点击了 -> 关于"))
|
||
|
||
# 4. 绑定顶部工具栏按钮 (占位)
|
||
self.btn_history.clicked.connect(lambda: print("\n[UI]: 点击了 -> 历史"))
|
||
|
||
# 5. 绑定输入框下方的工具按钮 (占位)
|
||
self.btn_upload.clicked.connect(self._on_upload_clicked)
|
||
|
||
# 6. 截图功能 (Alt+S 全局热键 + 按钮)
|
||
self.screenshot_overlay = ScreenCaptureOverlay()
|
||
self.screenshot_overlay.screenshot_captured.connect(self._on_image_pasted)
|
||
self.btn_screenshot.clicked.connect(self._start_screenshot)
|
||
# P1-04:平台路由 —— Windows RegisterHotKey / X11 XGrabKey / Wayland+offscreen 无全局热键
|
||
_sess_kind = desktop_session.session_kind()
|
||
_hk_factory, _hk_msg = desktop_session.hotkey_plan(_sess_kind)
|
||
print(_hk_msg, flush=True)
|
||
self.hotkey_screenshot = _hk_factory() if _hk_factory else None
|
||
if self.hotkey_screenshot is not None:
|
||
self.hotkey_screenshot.triggered.connect(self._start_screenshot)
|
||
self.hotkey_screenshot.start()
|
||
# 非 Windows 平台回退到应用级快捷键(Wayland/offscreen 下窗口获焦时仍可用)
|
||
if sys.platform != "win32":
|
||
self.shortcut_screenshot = QtGui.QShortcut(QtGui.QKeySequence("Alt+S"), self)
|
||
self.shortcut_screenshot.activated.connect(self._start_screenshot)
|
||
|
||
self.btn_web.clicked.connect(lambda: print("\n[UI]: 点击了 -> 联网搜索 🌐"))
|
||
self.btn_skill.clicked.connect(lambda: print("\n[UI]: 点击了 -> 动态技能 🛠️"))
|
||
self.btn_server.clicked.connect(lambda: print("\n[UI]: 点击了 -> 后台服务 ☁️"))
|
||
self.history_list.itemClicked.connect(self.on_sidebar_item_clicked)
|
||
self.history_list.order_changed.connect(self.on_session_order_changed)
|
||
|
||
|
||
def on_session_order_changed(self, ordered_ids):
|
||
self.db.update_session_order(ordered_ids)
|
||
print(f"[UI]: 会话顺序已更新 ({len(ordered_ids)} 条)")
|
||
|
||
|
||
def _on_upload_clicked(self):
|
||
"""点击上传按钮:支持图片 + 文本/代码文件多选,按扩展名自动分类入库。"""
|
||
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(
|
||
self, "选择文件(图片 / 文本 / 代码)", "",
|
||
"所有文件 (*);;图片 (*.png *.jpg *.jpeg *.webp *.bmp *.gif)"
|
||
)
|
||
if not paths:
|
||
return
|
||
|
||
# 按扩展名分类:图片走图片管线,其余交给文件管线(内部做二进制/编码探测)
|
||
image_paths, other_paths = [], []
|
||
for path in paths:
|
||
ext = os.path.splitext(path)[1].lower()
|
||
(image_paths if ext in PasteAwareTextEdit.IMAGE_EXTS else other_paths).append(path)
|
||
|
||
if image_paths:
|
||
self._on_image_pasted(image_paths) # 复用:拷贝到 data/attachments 并加图片附件
|
||
if other_paths:
|
||
self._on_files_dropped(other_paths) # 复用:读取文本内容并加文件附件
|
||
|
||
|
||
def _on_long_text_pasted(self, text: str):
|
||
"""长文本粘贴,转为通用附件格式,然后显示在输入框上方"""
|
||
size_kb = round(len(text.encode('utf-8')) / 1024, 2)
|
||
line_count = text.count('\n') + 1
|
||
att_data = {
|
||
"type": "text",
|
||
"content": text,
|
||
"size_kb": size_kb,
|
||
"lines": line_count
|
||
}
|
||
self._add_attachment_tag(att_data)
|
||
|
||
|
||
def _on_image_pasted(self, image_data):
|
||
"""处理粘贴或拖拽的图片:保存到本地 data/attachments 并添加为附件"""
|
||
import os
|
||
import shutil
|
||
import uuid
|
||
|
||
# 定位到项目根目录的 data/attachments
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
attach_dir = os.path.join(root_dir, "data", "attachments")
|
||
os.makedirs(attach_dir, exist_ok=True)
|
||
|
||
# 情况1:image_data 是 QImage 对象(剪切板截图 / 直接拖入的图片数据)
|
||
if isinstance(image_data, QtGui.QImage):
|
||
new_filename = f"img_{uuid.uuid4().hex[:8]}.png"
|
||
target_path = os.path.join(attach_dir, new_filename)
|
||
image_data.save(target_path, "PNG")
|
||
|
||
size_kb = round(os.path.getsize(target_path) / 1024, 2)
|
||
att_data = {
|
||
"type": "image",
|
||
"name": f"clipboard_{new_filename}",
|
||
"size_kb": size_kb,
|
||
"mime": "image/png",
|
||
"local_path": f"data/attachments/{new_filename}"
|
||
}
|
||
self._add_attachment_tag(att_data)
|
||
|
||
# 情况2:image_data 是文件路径列表(拖拽图片文件 / 剪切板文件 URL)
|
||
elif isinstance(image_data, list):
|
||
for file_path in image_data:
|
||
ext = os.path.splitext(file_path)[1].lower()
|
||
if ext == '.jpg':
|
||
ext = '.jpeg'
|
||
new_filename = f"img_{uuid.uuid4().hex[:8]}{ext}"
|
||
target_path = os.path.join(attach_dir, new_filename)
|
||
shutil.copy2(file_path, target_path)
|
||
|
||
size_kb = round(os.path.getsize(target_path) / 1024, 2)
|
||
mime_type = f"image/{ext[1:]}" if ext else "image/jpeg"
|
||
att_data = {
|
||
"type": "image",
|
||
"name": os.path.basename(file_path),
|
||
"size_kb": size_kb,
|
||
"mime": mime_type,
|
||
"local_path": f"data/attachments/{new_filename}"
|
||
}
|
||
self._add_attachment_tag(att_data)
|
||
|
||
# 截图确认后,若主窗口原处于后台(Alt+S 触发),拉回前端显示
|
||
if getattr(self, '_screenshot_from_background', False):
|
||
self._screenshot_from_background = False
|
||
self.showNormal() # 从最小化恢复
|
||
self.raise_() # 提到 Z 序顶层
|
||
self.activateWindow() # 请求系统焦点
|
||
|
||
def _on_files_dropped(self, paths):
|
||
"""处理拖拽/粘贴/选择的文件:PDF 走专用解析,其余按文本读取转为附件。
|
||
不支持的类型 / 二进制 / 超大文件静默跳过,不打扰用户。"""
|
||
for path in paths:
|
||
ext = os.path.splitext(path)[1].lower()
|
||
if ext == '.pdf': # 🌟 PDF 交给专用工具解析(文本/图片双模式)
|
||
self._on_pdf_dropped(path)
|
||
continue
|
||
if ext in BINARY_EXTS:
|
||
continue
|
||
try:
|
||
content, enc, size_kb, lines = read_text_file(path)
|
||
except (ValueError, OSError):
|
||
continue
|
||
self._add_attachment_tag({
|
||
"type": "text",
|
||
"name": os.path.basename(path),
|
||
"content": content,
|
||
"size_kb": size_kb,
|
||
"lines": lines,
|
||
"encoding": enc,
|
||
})
|
||
|
||
def _on_pdf_dropped(self, path):
|
||
"""PDF 专用导入:拷贝落地 + 用 pdf_reader 解析逐页文本,生成默认文本模式附件。
|
||
加密/损坏/超大 PDF 静默跳过。"""
|
||
import shutil
|
||
# 1. 拷贝到 data/attachments(像图片一样落地;图片模式需要原文件再次提取)
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
attach_dir = os.path.join(root_dir, "data", "attachments")
|
||
os.makedirs(attach_dir, exist_ok=True)
|
||
new_filename = f"pdf_{uuid.uuid4().hex[:8]}.pdf"
|
||
target_path = os.path.join(attach_dir, new_filename)
|
||
try:
|
||
shutil.copy2(path, target_path)
|
||
# 2. 解析逐页结构化文本 + 总页数
|
||
text_content, pages = extract_pdf_text(target_path)
|
||
except (ValueError, OSError):
|
||
return
|
||
self._add_attachment_tag({
|
||
"type": "pdf",
|
||
"name": os.path.basename(path),
|
||
"size_kb": round(os.path.getsize(target_path) / 1024, 2),
|
||
"pages": pages,
|
||
"mode": "text", # 默认文本模式;候选栏里可切 "image"
|
||
"text_content": text_content,
|
||
"local_path": f"data/attachments/{new_filename}",
|
||
"images": [], # 切到图片模式时懒提取并缓存
|
||
"selected_images": [], # 用户勾选的图片(图片模式发送用)
|
||
})
|
||
|
||
def _set_pdf_tag_style(self, tag_label: "ClickableLabel", mode: str) -> None:
|
||
"""按当前模式给 PDF 附件标签设置外框颜色。
|
||
|
||
text 模式: 灰框, 悬停变蓝(与图片附件一致); image 模式: 持久红框。
|
||
|
||
Args:
|
||
tag_label: PDF 附件的标签控件。
|
||
mode: "text" 或 "image"。
|
||
"""
|
||
if mode == "image":
|
||
# 图片解析模式: 持久红框(悬停仅微调边框色, 不变蓝)
|
||
tag_label.setStyleSheet("""
|
||
QLabel { background-color: #fff1f0; color: #cf1322; font-size: 13px;
|
||
padding: 8px 14px; border-radius: 6px; border: 1px solid #ff4d4f; }
|
||
QLabel:hover { border: 1px solid #ff7875; }
|
||
""")
|
||
else:
|
||
# 文本模式: 与图片/文本附件标签完全一致, 悬停变蓝
|
||
tag_label.setStyleSheet("""
|
||
QLabel { background-color: #f5f5f5; color: #555555; font-size: 13px;
|
||
padding: 8px 14px; border-radius: 6px; border: 1px solid #e5e5e5; }
|
||
QLabel:hover { color: #0066cc; border: 1px solid #b3d9ff; text-decoration: underline; }
|
||
""")
|
||
|
||
def _show_pdf_popup(self, att_data: dict) -> None:
|
||
"""在 PDF 附件标签上方弹出模式选择弹窗。
|
||
|
||
Args:
|
||
att_data: PDF 附件数据字典(含 _tag_label / images / mode 等)。
|
||
"""
|
||
# 关闭上一个弹窗
|
||
if getattr(self, "_pdf_popup", None) is not None:
|
||
try:
|
||
self._pdf_popup.close()
|
||
self._pdf_popup.deleteLater()
|
||
except Exception:
|
||
pass
|
||
self._pdf_popup = None
|
||
|
||
tag_label = att_data.get("_tag_label")
|
||
if tag_label is None:
|
||
return
|
||
|
||
popup = PdfModePopup(att_data, self.bg_widget)
|
||
popup.mode_changed.connect(lambda m, d=att_data: self._set_pdf_tag_style(d["_tag_label"], m))
|
||
popup.preview_requested.connect(self._on_pdf_image_preview)
|
||
self._pdf_popup = popup
|
||
|
||
# 弹窗高度由 setFixedHeight 固定, 可直接用来算标签上方的落点
|
||
g = tag_label.mapToGlobal(QtCore.QPoint(0, 0))
|
||
x = g.x()
|
||
y = g.y() - popup.height() - 5
|
||
if y < 0:
|
||
y = g.y() + tag_label.height() + 5
|
||
popup.show_with_animation(QtCore.QPoint(x, y))
|
||
|
||
def _on_pdf_image_preview(self, meta: dict, siblings: list, index: int, att_data: dict) -> None:
|
||
"""预览 PDF 提取出的图片(复用附件图片预览覆盖层),支持同组左右翻页与选择。
|
||
|
||
Args:
|
||
meta: 当前图片元信息, 含 local_path / name / mime。
|
||
siblings: 同组全部图片的 meta 列表(供翻页)。
|
||
index: 当前图片在 siblings 中的下标。
|
||
att_data: PDF 附件字典(供“选择”按钮回写 selected_images)。
|
||
"""
|
||
overlay = AttachmentPreviewOverlay(meta, self.bg_widget, siblings=siblings,
|
||
index=index, att_data=att_data)
|
||
overlay.show()
|
||
|
||
def _start_screenshot(self):
|
||
"""启动截图(P1-04 平台路由:win32/x11 → 覆盖层;wayland → portal;unknown → 明确告知)"""
|
||
# 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发)
|
||
self._screenshot_from_background = not self.isActiveWindow()
|
||
_kind = desktop_session.session_kind()
|
||
_mode, _msg = desktop_session.capture_plan(_kind)
|
||
if _msg:
|
||
print(_msg, flush=True)
|
||
return
|
||
if _mode == "portal":
|
||
self._start_portal_screenshot()
|
||
return
|
||
self.screenshot_overlay.start()
|
||
|
||
def _start_portal_screenshot(self):
|
||
"""Wayland:经 xdg-desktop-portal 交互截图(用户授权,不绕过 compositor)。"""
|
||
from ui.views.system_tools import portal_capture
|
||
if not hasattr(self, "_portal_worker"):
|
||
self._portal_worker = None
|
||
self._portal_worker = portal_capture.PortalScreenshotWorker(parent=self)
|
||
self._portal_worker.done.connect(self._on_portal_screenshot_done)
|
||
self._portal_worker.start()
|
||
|
||
def _on_portal_screenshot_done(self, ok, path_or_reason):
|
||
"""portal 截图结果回主线程:成功 → 进现有图片附件流程;失败 → 明确日志。"""
|
||
if ok:
|
||
self._on_image_pasted([path_or_reason])
|
||
else:
|
||
print(f"[Screenshot] portal 截图未完成:{path_or_reason} → 当前环境截图能力不可用/已取消;"
|
||
f"聊天与其他功能不受影响", flush=True)
|
||
|
||
|
||
def _add_attachment_tag(self, att_data: dict):
|
||
"""🌟 通用附件 UI 渲染(图片和长文本共用这一个精美样式)"""
|
||
index = len(self._folded_texts)
|
||
self._folded_texts.append(att_data)
|
||
|
||
tag_widget = QtWidgets.QWidget()
|
||
tag_widget.setObjectName(f"attachment_tag_{index}")
|
||
tag_layout = QtWidgets.QHBoxLayout(tag_widget)
|
||
tag_layout.setContentsMargins(0, 0, 0, 0)
|
||
tag_layout.setSpacing(6)
|
||
|
||
# 根据类型设置不同的文字
|
||
if att_data["type"] == "text":
|
||
display_name = att_data.get("name", "长文本")
|
||
label_text = f"📄 附件 {index + 1}: {display_name} ({att_data['size_kb']} KB · {att_data['lines']} 行)"
|
||
elif att_data["type"] == "pdf":
|
||
label_text = f"📕 附件 {index + 1}: {att_data['name']} ({att_data.get('pages', 0)} 页 · {att_data['size_kb']} KB)"
|
||
else:
|
||
label_text = f"🖼️ 附件 {index + 1}: 图片 ({att_data['name']} · {att_data['size_kb']} KB)"
|
||
|
||
label = ClickableLabel(label_text)
|
||
label.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
|
||
if att_data["type"] == "pdf":
|
||
# 🌟 PDF:点击弹出解析模式候选栏;外框颜色随模式切换(文本=灰 / 图片=红)
|
||
att_data["_tag_label"] = label
|
||
self._set_pdf_tag_style(label, att_data.get("mode", "text"))
|
||
label.clicked.connect(lambda checked=False, d=att_data: self._show_pdf_popup(d))
|
||
else:
|
||
label.setStyleSheet("""
|
||
QLabel { background-color: #f5f5f5; color: #555555; font-size: 13px;
|
||
padding: 8px 14px; border-radius: 6px; border: 1px solid #e5e5e5; }
|
||
QLabel:hover { color: #0066cc; border: 1px solid #b3d9ff; text-decoration: underline; }
|
||
""")
|
||
label.clicked.connect(lambda checked=False, d=att_data: AttachmentPreviewOverlay(d, self.bg_widget))
|
||
|
||
remove_btn = QtWidgets.QPushButton("✕")
|
||
remove_btn.setFixedSize(26, 26)
|
||
remove_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
remove_btn.setStyleSheet("""
|
||
QPushButton { background: #eeeeee; border: none; border-radius: 13px; color: #888888; font-size: 12px; font-weight: bold; }
|
||
QPushButton:hover { background: #dddddd; color: #333333; }
|
||
""")
|
||
remove_btn.clicked.connect(lambda checked, w=tag_widget, i=index: self.remove_attachment(w, i))
|
||
|
||
tag_layout.addWidget(label)
|
||
tag_layout.addWidget(remove_btn)
|
||
|
||
self.attachment_area_layout.addWidget(tag_widget)
|
||
self.attachment_scroll_area.setVisible(True)
|
||
self.text_input.setPlaceholderText("可继续添加文件或输入补充说明...")
|
||
|
||
# ==================== 重新回答发送逻辑 ====================
|
||
def _on_regenerate_clicked(self, msg_id):
|
||
"""前端点击“重新回答”按钮时触发 (完美兼容 User框、正常AI框、幽灵报错框)"""
|
||
if self.current_session_id in self._active_streams:
|
||
return
|
||
|
||
# 获取当前时间线上的所有消息
|
||
messages = self.db.get_message_chain(self.current_session_id)
|
||
target_msg = next((m for m in messages if m["id"] == msg_id), None)
|
||
|
||
# 🌟 核心修复:精准路由指针!
|
||
if not target_msg:
|
||
# 如果在数据库找不到,说明点的是报错的幽灵气泡
|
||
# 查小本本,拿到绝对正确的提问指针!
|
||
user_msg_id = self._ghost_msg_map.get(msg_id)
|
||
if not user_msg_id:
|
||
print(f"[UI]: 未知气泡 {msg_id},拒绝重试操作")
|
||
return # 万一没查到,安全退出,绝不乱指
|
||
else:
|
||
# 如果是正常的历史气泡
|
||
if target_msg["role"] == "user":
|
||
user_msg_id = target_msg["id"]
|
||
else:
|
||
user_msg_id = target_msg["parent_id"]
|
||
|
||
# 备份当前指针 (时光倒流用,这部分你之前的修改已经做得很好了)
|
||
original_leaf_id = self.db.get_session_leaf(self.current_session_id)
|
||
|
||
# 截断时间线,重新拉取 UI,并启动生成
|
||
self.db.update_session_leaf(self.current_session_id, user_msg_id)
|
||
self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 内部刷新,不触发加载层
|
||
self._regenerate_from_user_msg(user_msg_id, original_leaf_id)
|
||
|
||
|
||
# 🌟 参数增加 original_leaf_id
|
||
def _regenerate_from_user_msg(self, user_msg_id, original_leaf_id=None):
|
||
import uuid
|
||
session_id = self.current_session_id
|
||
api_messages = self.build_api_context(session_id)
|
||
self._dbg(f"[send-regen] model={self.current_model} "
|
||
f"mode={self._get_current_mode()}")
|
||
|
||
ai_msg_id = f"msg-{uuid.uuid4().hex}"
|
||
|
||
siblings = self.db.get_branch_info(user_msg_id)
|
||
total_branches = len(siblings) + 1
|
||
branch_info = {"current": total_branches, "total": total_branches}
|
||
|
||
self.chat_bridge.create_message(
|
||
ai_msg_id, "assistant", "",
|
||
self.current_model or "Assistant",
|
||
branch_info=branch_info
|
||
)
|
||
# 🆕 P1-01:重新回答占位进渲染窗口
|
||
self._rw_note_live(ai_msg_id, session_id)
|
||
self._ghost_msg_map[ai_msg_id] = user_msg_id
|
||
self._active_streams[session_id] = {
|
||
"msg_id": ai_msg_id,
|
||
"content": "",
|
||
"reasoning": "",
|
||
"timeline": [], # 🌟 agent 时间线(思考/文本/工具 按事件顺序)
|
||
"tl_kind": None, # 时间线当前段类型(think/text/tool)
|
||
"parent_id": user_msg_id,
|
||
"branch_info": branch_info,
|
||
"worker": None,
|
||
"usage": None, # 🆕 P1: 本轮已收到的精确 usage(显示锚定)
|
||
"previous_leaf_id": original_leaf_id or user_msg_id # 🌟 核心:存入时光倒流锚点!
|
||
}
|
||
|
||
# 🆕 模式分派:重新回答跟随会话已锁定的模式(老会话未锁定 → chat 普通聊天)
|
||
worker = self._create_stream_worker(api_messages, self._get_current_mode() or "chat")
|
||
self._active_streams[session_id]["worker"] = worker
|
||
|
||
worker.chunk_received.connect(lambda token: self.on_chunk_received(session_id, token))
|
||
worker.reasoning_received.connect(lambda token: self.on_reasoning_received(session_id, token))
|
||
worker.error_occurred.connect(lambda err: self.on_error(session_id, err))
|
||
worker.finished.connect(lambda: self.on_reply_finished(session_id))
|
||
# 🆕 P1: 本轮精确 usage(chat/worker 都有此信号)→ 显示锚定
|
||
worker.usage_updated.connect(lambda u: self._on_usage_updated(session_id, u))
|
||
if isinstance(worker, AgentWorker):
|
||
worker.tool_execution_started.connect(lambda cid, name, args: self._on_tool_started(session_id, cid, name, args))
|
||
worker.tool_execution_updated.connect(lambda cid, text: self._on_tool_updated(session_id, cid, text))
|
||
worker.tool_execution_timed.connect(lambda cid, el, to: self._on_tool_timed(session_id, cid, el, to))
|
||
worker.tool_execution_finished.connect(lambda cid, name, ok, text: self._on_tool_finished(session_id, cid, name, ok, text))
|
||
worker.context_compacted.connect(lambda p: self._on_context_compacted(session_id, p))
|
||
# 🆕 压缩开始 → 前端「执行中」动态气泡
|
||
worker.compaction_started.connect(lambda p: self._on_compaction_started(session_id, p))
|
||
# 🆕 M3: 重试提示(仅 agent 循环有重试)
|
||
worker.retry_scheduled.connect(lambda a, m, d, r: self._on_retry_scheduled(session_id, a, m, d, r))
|
||
worker.start()
|
||
|
||
self.set_send_button_state(True)
|
||
self.update_context_display()
|
||
|
||
|
||
def clear_fold_state(self):
|
||
"""发送后清除所有附件"""
|
||
self._folded_texts.clear()
|
||
# 清空附件容器里的所有子 widget
|
||
while self.attachment_area_layout.count():
|
||
item = self.attachment_area_layout.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
self.attachment_scroll_area.setVisible(False)
|
||
self.text_input.setPlaceholderText("给 haocode 发送消息...")
|
||
|
||
|
||
# ==================== 核心发送逻辑 ====================
|
||
|
||
# ==================== 🆕 模式切换(chat / worker,首条消息锁定) ====================
|
||
def _get_current_mode(self):
|
||
# 读取当前会话的模式(chat/worker),未发送过返回 None
|
||
sid = self.current_session_id
|
||
if not sid:
|
||
return None
|
||
try:
|
||
return self.db.get_session_mode(sid)
|
||
except Exception:
|
||
return None
|
||
|
||
def _mode_switch_enabled(self):
|
||
# 🆕 config.json "mode_switch"(默认 false):是否允许会话中途切换 chat↔worker
|
||
# false = 现状(首条消息锁定,不可再改);true = 随时可切,选择即生效
|
||
try:
|
||
return bool(self.config_data.get("mode_switch", False))
|
||
except Exception:
|
||
return False
|
||
|
||
def _refresh_mode_button(self):
|
||
# 切换会话后刷新模式按钮(显示锁定状态或待选状态)
|
||
if getattr(self, "mode_popup", None) is not None and self.mode_popup.isVisible():
|
||
self.mode_popup.close()
|
||
cur = self._get_current_mode()
|
||
if cur and not self._mode_switch_enabled():
|
||
# 开关关(默认)且会话已有模式 → 锁定态
|
||
self.btn_mode.setText("🔒 " + ("Chat" if cur == "chat" else "Worker"))
|
||
self.btn_mode.setToolTip("本会话已锁定模式(首条消息时锁定),新开会话可换")
|
||
self._mode_panel_selected = cur
|
||
else:
|
||
# 开关开(无锁)或新会话:会话已有模式时显示并同步该模式
|
||
mode = cur or self.pending_mode
|
||
if cur and self._mode_switch_enabled():
|
||
self.pending_mode = cur
|
||
self.btn_mode.setText(("💬 Chat" if mode == "chat" else "⚡ Worker") + " ▾")
|
||
self.btn_mode.setToolTip("切换聊天模式(随时可切换)" if self._mode_switch_enabled()
|
||
else "切换聊天模式(发送第一条消息后锁定)")
|
||
self._mode_panel_selected = mode
|
||
if getattr(self, "mode_popup", None) is not None:
|
||
self.mode_popup.mark_selected(self._mode_panel_selected)
|
||
|
||
def init_mode_popup(self):
|
||
# 🆕 实例化模式选择浮动弹窗(与模型选择弹窗同款实现,先不显示)
|
||
self.mode_popup = SessionModePopup(self)
|
||
self.mode_popup.mode_selected.connect(self._select_mode)
|
||
|
||
def show_mode_popup(self):
|
||
'''计算位置并执行动画弹出(模式按钮正上方右对齐)'''
|
||
if self._get_current_mode() is not None and not self._mode_switch_enabled():
|
||
# 已锁定:在提示行闪一下
|
||
self.hint_label.setText("本会话已锁定模式,新开会话可换")
|
||
QtCore.QTimer.singleShot(2500, self._restore_hint_label)
|
||
return
|
||
btn_pos = self.btn_mode.mapToGlobal(QtCore.QPoint(0, 0))
|
||
x = btn_pos.x() + self.btn_mode.width() - self.mode_popup.width()
|
||
y = btn_pos.y() - self.mode_popup.height() - 5
|
||
self.mode_popup.mark_selected(self._mode_panel_selected)
|
||
self.mode_popup.show_with_animation(QtCore.QPoint(x, y))
|
||
|
||
def _restore_hint_label(self):
|
||
self.hint_label.setText("AI 生成内容可能不准确。")
|
||
|
||
def _select_mode(self, mode):
|
||
if self._get_current_mode() is not None and not self._mode_switch_enabled():
|
||
return # 开关关且会话已锁定:不可改
|
||
self.pending_mode = mode
|
||
self._mode_panel_selected = mode
|
||
self.btn_mode.setText(("💬 Chat" if mode == "chat" else "⚡ Worker") + " ▾")
|
||
self.mode_popup.mark_selected(mode)
|
||
self.mode_popup.close()
|
||
# 🆕 开关开:选择即落库,下一次发送/重新回答立即生效
|
||
if self._mode_switch_enabled():
|
||
sid = self.current_session_id
|
||
if sid:
|
||
try:
|
||
self.db.set_session_mode(sid, mode)
|
||
print(f"[UI]: 会话 {sid[:8]} 模式切换 -> {mode}")
|
||
except Exception as e:
|
||
print(f"[UI]: 模式切换落库失败: {e}")
|
||
|
||
def _lock_session_mode(self):
|
||
# 首条消息发送时,记录当前会话模式(开关关=锁定不可再改;开关开=仅记录,之后仍可切换)
|
||
sid = self.current_session_id
|
||
if not sid or self._get_current_mode() is not None:
|
||
return
|
||
mode = self.pending_mode or "chat"
|
||
try:
|
||
self.db.set_session_mode(sid, mode)
|
||
if self._mode_switch_enabled():
|
||
print(f"[UI]: 会话 {sid[:8]} 模式记录 -> {mode}")
|
||
else:
|
||
print(f"[UI]: 会话 {sid[:8]} 模式锁定 -> {mode}")
|
||
except Exception as e:
|
||
print(f"[UI]: 模式锁定失败: {e}")
|
||
self._refresh_mode_button()
|
||
|
||
def _create_stream_worker(self, messages, mode):
|
||
# 按模式创建 worker:worker→AgentWorker(agent 循环) / chat→ChatWorker(普通聊天)
|
||
if mode == "worker":
|
||
return AgentWorker(self.current_provider, self.current_model, messages)
|
||
return ChatWorker(self.current_provider, self.current_model, messages)
|
||
|
||
def _persist_interrupted_stream(self, session_id, stream_state):
|
||
"""🆕 Fix B: 持久化进行中的部分回复(发送中断 / 关窗共用)。
|
||
- 有内容/思考/时间线 → 以 assistant 消息挂到链表(parent 指向本轮提问)
|
||
- 完全为空 → 时光倒流:会话叶子回退到发送前
|
||
"""
|
||
import json as _json
|
||
try:
|
||
if (stream_state.get("content") or stream_state.get("reasoning")
|
||
or stream_state.get("timeline")):
|
||
tl_json = (_json.dumps(stream_state["timeline"], ensure_ascii=False)
|
||
if stream_state.get("timeline") else None)
|
||
# 🆕 P1: 中断回复也保存精确 usage(供后续显示/压缩估算锚定)
|
||
_usage_json = None
|
||
try:
|
||
from core.agent import calculate_context_tokens as _calc_usage
|
||
if _calc_usage(stream_state.get("usage") or {}) > 0:
|
||
_usage_json = _json.dumps(stream_state.get("usage"))
|
||
except Exception:
|
||
pass
|
||
self.db.add_message(
|
||
session_id=session_id,
|
||
role="assistant",
|
||
content=stream_state.get("content", ""),
|
||
parent_id=stream_state.get("parent_id"),
|
||
reasoning=stream_state.get("reasoning", ""),
|
||
msg_id=stream_state.get("msg_id"),
|
||
timeline=tl_json,
|
||
usage=_usage_json
|
||
)
|
||
print(f"[DB]: 中断内容已保存 "
|
||
f"({len(stream_state.get('content') or '')} 字) session={session_id[:8]}", flush=True)
|
||
else:
|
||
prev_leaf = stream_state.get("previous_leaf_id")
|
||
if prev_leaf:
|
||
self.db.update_session_leaf(session_id, prev_leaf)
|
||
print(f"[DB]: 中断时未生成内容,叶子节点已回退至: {prev_leaf}", flush=True)
|
||
except Exception as e:
|
||
print(f"[DB]: 中断内容保存失败: {e}", flush=True)
|
||
|
||
def send_message(self, from_enter: bool = False):
|
||
import time
|
||
import uuid
|
||
import json
|
||
|
||
# 🌟 P0-02:发送规则的单一实现(Enter 与按钮点击路径共用):
|
||
# 发送按钮禁用 → 一律不得发送;
|
||
# 当前会话正在流式生成 → Enter 路径直接 no-op(不参与停止/中断语义,防误触);
|
||
# 按钮点击路径保留原有中断行为(红色停止按钮),继续落入下方中断逻辑。
|
||
if not self.btn_send.isEnabled():
|
||
return
|
||
if from_enter and self.current_session_id in self._active_streams:
|
||
return
|
||
|
||
current_time = time.time()
|
||
if current_time - self._last_click_time < 0.3:
|
||
return
|
||
self._last_click_time = current_time
|
||
|
||
# 检查当前会话是否正在生成(处理中断请求)
|
||
if self.current_session_id in self._active_streams:
|
||
stream_state = self._active_streams[self.current_session_id]
|
||
worker = stream_state.get("worker")
|
||
if worker:
|
||
try:
|
||
worker.chunk_received.disconnect()
|
||
worker.error_occurred.disconnect()
|
||
worker.reasoning_received.disconnect()
|
||
worker.finished.disconnect()
|
||
if hasattr(worker, "tool_execution_started"):
|
||
worker.tool_execution_started.disconnect()
|
||
worker.tool_execution_updated.disconnect()
|
||
worker.tool_execution_finished.disconnect()
|
||
worker.context_compacted.disconnect()
|
||
worker.usage_updated.disconnect()
|
||
if hasattr(worker, "retry_scheduled"):
|
||
worker.retry_scheduled.disconnect()
|
||
except Exception:
|
||
pass
|
||
worker.abort()
|
||
worker.finished.connect(worker.deleteLater)
|
||
|
||
msg_id = stream_state["msg_id"]
|
||
self.chat_bridge.show_error(msg_id, "🛑 已中断生成")
|
||
self.chat_bridge.finish_message(msg_id)
|
||
|
||
# 🆕 Fix B: 中断部分内容的持久化收拢到公共方法(与 closeEvent 共用)
|
||
self._persist_interrupted_stream(self.current_session_id, stream_state)
|
||
|
||
del self._active_streams[self.current_session_id]
|
||
self.set_send_button_state(False)
|
||
self.update_context_display()
|
||
|
||
print("\n[系统]: 🛑 已中断。")
|
||
# 🆕 Fix C: 输入框里若有新提问(用户是边中断边点发送的),
|
||
# 不再 return 吞掉 —— 直接落入下方正常发送流程(中断 + 发送一步完成)
|
||
if not (self.text_input.toPlainText().strip()
|
||
or [t for t in self._folded_texts if t is not None]):
|
||
return
|
||
|
||
# 正常发送消息流程
|
||
extra_text = self.text_input.toPlainText().strip()
|
||
valid_attachments = [t for t in self._folded_texts if t is not None]
|
||
|
||
if not extra_text and not valid_attachments:
|
||
return
|
||
|
||
self.text_input.clear()
|
||
self.clear_fold_state()
|
||
self.set_send_button_state(True)
|
||
|
||
user_msg_id = f"msg-{uuid.uuid4().hex}"
|
||
attachment_metadata_json = None
|
||
|
||
# 🌟 分离文本附件和图片附件 (多模态支持)
|
||
text_attachments_content = []
|
||
api_attachments_meta = []
|
||
|
||
if valid_attachments:
|
||
for att in valid_attachments:
|
||
if att["type"] == "text":
|
||
# 带文件名的附件在 prompt 里包一层来源标记,方便模型区分多文件
|
||
if att.get("name"):
|
||
text_attachments_content.append(f"[文件: {att['name']}]\n{att['content']}")
|
||
else:
|
||
text_attachments_content.append(att["content"])
|
||
meta_item = {
|
||
"type": "text",
|
||
"size_kb": att["size_kb"],
|
||
"lines": att["lines"],
|
||
"content": att["content"] # 🌟 补回 content 字段,防止前端 JS 截取预览时报错
|
||
}
|
||
if att.get("name"):
|
||
meta_item["name"] = att["name"] # 🌟 文件名存入元数据,前端卡片与历史重载用
|
||
api_attachments_meta.append(meta_item)
|
||
elif att["type"] == "image":
|
||
api_attachments_meta.append({
|
||
"type": "image",
|
||
"name": att["name"],
|
||
"mime": att["mime"],
|
||
"local_path": att["local_path"],
|
||
"size_kb": att.get("size_kb", 0), # 🌟 补上大小,防止 JS 出现 undefined KB
|
||
"lines": 1, # 🌟 补上行数,防止 JS 出现 undefined 行
|
||
"content": f"[图片文件: {att['name']}]" # 假 content 防报错
|
||
})
|
||
elif att["type"] == "pdf":
|
||
mode = att.get("mode", "text")
|
||
text_content = att.get("text_content", "")
|
||
if mode == "image":
|
||
# 图片解析模式: 文本与勾选的图片一起发送
|
||
if text_content:
|
||
text_attachments_content.append(f"[文件: {att['name']}]\n{text_content}")
|
||
# 卡片①:图片(用户勾选的)
|
||
api_attachments_meta.append({
|
||
"type": "pdf",
|
||
"mode": "image",
|
||
"name": att["name"],
|
||
"size_kb": att.get("size_kb", 0),
|
||
"pages": att.get("pages", 0),
|
||
"selected_images": att.get("selected_images", []),
|
||
"images": att.get("images", []), # 全部提取图片, 供清理物理文件
|
||
"local_path": att.get("local_path"), # PDF 本体拷贝, 供清理
|
||
"lines": len(att.get("selected_images", [])),
|
||
"content": f"[PDF 图片解析: {att['name']}]"
|
||
})
|
||
# 卡片②:文本(与图片分开成独立气泡显示)
|
||
if text_content:
|
||
api_attachments_meta.append({
|
||
"type": "pdf",
|
||
"mode": "text",
|
||
"name": att["name"],
|
||
"size_kb": att.get("size_kb", 0),
|
||
"pages": att.get("pages", 0),
|
||
"images": [],
|
||
"lines": text_content.count("\n") + 1,
|
||
"content": text_content
|
||
})
|
||
else:
|
||
# 文本模式: 结构化文本作为文本附件注入 prompt
|
||
text_attachments_content.append(f"[文件: {att['name']}]\n{text_content}")
|
||
api_attachments_meta.append({
|
||
"type": "pdf",
|
||
"mode": "text",
|
||
"name": att["name"],
|
||
"size_kb": att.get("size_kb", 0),
|
||
"pages": att.get("pages", 0),
|
||
"images": att.get("images", []), # 文本模式一般为空, 供清理
|
||
"local_path": att.get("local_path"),
|
||
"lines": text_content.count("\n") + 1,
|
||
"content": text_content
|
||
})
|
||
|
||
# 前端渲染展示 (传 meta 过去)
|
||
self.chat_bridge.create_user_message_with_attachments(user_msg_id, extra_text, api_attachments_meta)
|
||
|
||
attachment_metadata_json = json.dumps({
|
||
"user_text": extra_text,
|
||
"attachments": api_attachments_meta
|
||
}, ensure_ascii=False)
|
||
|
||
# 数据库里的 content 依然只存纯文本
|
||
all_text_attachments = "\n\n---\n\n".join(text_attachments_content)
|
||
if extra_text and all_text_attachments:
|
||
llm_text = extra_text + "\n\n" + all_text_attachments
|
||
elif all_text_attachments:
|
||
llm_text = all_text_attachments
|
||
else:
|
||
llm_text = extra_text
|
||
else:
|
||
self.chat_bridge.create_message(user_msg_id, "user", extra_text, "You")
|
||
llm_text = extra_text
|
||
|
||
# 🚀 1. 获取当前对话的尾巴节点
|
||
current_leaf_id = self.db.get_session_leaf(self.current_session_id)
|
||
|
||
# 🚀 2. 插入用户消息(它的父亲是旧的尾巴节点),插入后这只 User 成了新尾巴
|
||
self.db.add_message(
|
||
session_id=self.current_session_id,
|
||
role="user",
|
||
content=llm_text, # 纯文本部分
|
||
parent_id=current_leaf_id, # <--- 链表指针!
|
||
msg_id=user_msg_id,
|
||
attachment_metadata=attachment_metadata_json # 图片路径和文本元数据存入这里
|
||
)
|
||
self.db.mark_session_has_messages(self.current_session_id)
|
||
# 🆕 P1-01:用户消息进渲染窗口(已持久化,带真实链内下标)
|
||
self._rw_note_live(user_msg_id, self.current_session_id)
|
||
self._dbg(f"[send] model={self.current_model} mode={self._get_current_mode()}")
|
||
|
||
# 组装给大模型的 API 上下文(build_api_context 内部会去解析图片 local_path 并转为 base64 发给 API)
|
||
messages = self.build_api_context(self.current_session_id)
|
||
|
||
ai_msg_id = f"msg-{uuid.uuid4().hex}"
|
||
|
||
# 🚀 正常发送产生的新回答,必定是这句提问的第 1 个分支
|
||
branch_info = {"current": 1, "total": 1}
|
||
|
||
self.chat_bridge.create_message(
|
||
ai_msg_id, "assistant", "",
|
||
self.current_model or "Assistant",
|
||
branch_info=branch_info # 🚀 传给前端
|
||
)
|
||
# 🆕 P1-01:助手占位进渲染窗口(未持久化 -1 降级;流式保护由 streaming 类自动生效)
|
||
self._rw_note_live(ai_msg_id, self.current_session_id)
|
||
self._ghost_msg_map[ai_msg_id] = user_msg_id
|
||
session_id = self.current_session_id
|
||
self._active_streams[session_id] = {
|
||
"msg_id": ai_msg_id,
|
||
"content": "",
|
||
"reasoning": "",
|
||
"timeline": [], # 🌟 agent 时间线(思考/文本/工具 按事件顺序)
|
||
"tl_kind": None, # 时间线当前段类型(think/text/tool)
|
||
"parent_id": user_msg_id,
|
||
"branch_info": branch_info,
|
||
"worker": None,
|
||
"usage": None, # 🆕 P1: 本轮已收到的精确 usage(显示锚定)
|
||
"previous_leaf_id": user_msg_id # 🌟 核心修复:新提问的锚点就是提问本身
|
||
}
|
||
|
||
# 创建 worker(只创建一次)
|
||
# 🆕 模式分派:worker → agent 循环(工具/重试/压缩);chat → 普通聊天
|
||
mode = self._get_current_mode() or self.pending_mode
|
||
self._lock_session_mode() # 首条消息即锁定
|
||
worker = self._create_stream_worker(messages, mode)
|
||
self._active_streams[session_id]["worker"] = worker
|
||
|
||
# 用 lambda 绑定 session_id,让回调函数知道是哪个会话的数据
|
||
worker.chunk_received.connect(lambda token: self.on_chunk_received(session_id, token))
|
||
worker.reasoning_received.connect(lambda token: self.on_reasoning_received(session_id, token))
|
||
worker.error_occurred.connect(lambda err: self.on_error(session_id, err))
|
||
worker.finished.connect(lambda: self.on_reply_finished(session_id))
|
||
# 🆕 P1: 本轮精确 usage(chat/worker 都有此信号)→ 显示锚定
|
||
worker.usage_updated.connect(lambda u: self._on_usage_updated(session_id, u))
|
||
if isinstance(worker, AgentWorker):
|
||
# 工具执行事件只有 agent 循环才有
|
||
worker.tool_execution_started.connect(lambda cid, name, args: self._on_tool_started(session_id, cid, name, args))
|
||
worker.tool_execution_updated.connect(lambda cid, text: self._on_tool_updated(session_id, cid, text))
|
||
worker.tool_execution_timed.connect(lambda cid, el, to: self._on_tool_timed(session_id, cid, el, to))
|
||
worker.tool_execution_finished.connect(lambda cid, name, ok, text: self._on_tool_finished(session_id, cid, name, ok, text))
|
||
worker.context_compacted.connect(lambda p: self._on_context_compacted(session_id, p))
|
||
# 🆕 压缩开始 → 前端「执行中」动态气泡
|
||
worker.compaction_started.connect(lambda p: self._on_compaction_started(session_id, p))
|
||
# 🆕 M3: 重试提示(仅 agent 循环有重试)
|
||
worker.retry_scheduled.connect(lambda a, m, d, r: self._on_retry_scheduled(session_id, a, m, d, r))
|
||
print(f"[生命周期] 发送 → session={session_id[:8]} ai_msg={ai_msg_id} 模式={mode} 用户文本={len(extra_text)}c", flush=True)
|
||
worker.start()
|
||
|
||
# 🌟 新增:发送时刷新上下文显示
|
||
self.update_context_display()
|
||
|
||
|
||
|
||
|
||
def on_reply_finished(self, session_id):
|
||
"""回复完成"""
|
||
if session_id not in self._active_streams: return
|
||
|
||
stream_state = self._active_streams[session_id]
|
||
msg_id = stream_state["msg_id"]
|
||
parent_id = stream_state["parent_id"] # 取出父亲是谁
|
||
|
||
if session_id == self.current_session_id:
|
||
self.chat_bridge.finish_message(msg_id)
|
||
self.browser.page().runJavaScript("dumpDiag()", self._on_diag_dumped)
|
||
print("\n[系统]:✅ 回复完毕。")
|
||
else:
|
||
print(f"\n[系统]: ✅ 会话 {session_id[:8]} 回复完毕(后台)")
|
||
|
||
# 人肉 debug:完成时刻的生命周期快照
|
||
_tl0 = stream_state.get("timeline") or []
|
||
print(f"[生命周期] 回复完成 session={session_id[:8]} mid={msg_id} "
|
||
f"正文={len(stream_state['content'])}c 思考={len(stream_state['reasoning'])}c 时间线={len(_tl0)}条", flush=True)
|
||
# 🆕 调试日志:完成时刻的精确 usage + 时间线条数
|
||
try:
|
||
_du = stream_state.get("usage") or {}
|
||
self._dbg(f"[finish] usage_input={_du.get('input')} "
|
||
f"usage_output={_du.get('output')} timeline={len(_tl0)}条")
|
||
except Exception:
|
||
pass
|
||
# 完成时刻屏幕快照 + DOM 体检
|
||
try:
|
||
self._shot_n = getattr(self, "_shot_n", 0) + 1
|
||
import os as _os
|
||
_shot_path = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
|
||
f"diag_shot_{self._shot_n:02d}_FINISH.png")
|
||
self.browser.grab().save(_shot_path)
|
||
print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True)
|
||
except Exception as _e:
|
||
print(f"[画面] 完成截图失败: {_e}", flush=True)
|
||
def _finish_dom_probe(_res):
|
||
diag_log(f"FINISH_DOM {_res}")
|
||
print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True)
|
||
self.browser.page().runJavaScript(f"probeStream('{msg_id}')", _finish_dom_probe)
|
||
# 🚀 入库,自动成为时间线新叶子!
|
||
if (stream_state["content"] or stream_state["reasoning"]
|
||
or stream_state.get("timeline")):
|
||
tl_json = (json.dumps(stream_state["timeline"], ensure_ascii=False)
|
||
if stream_state.get("timeline") else None)
|
||
# 🆕 P1: 保存本轮精确 usage(供下次显示/压缩估算做 usage 锚定)
|
||
_usage_json = None
|
||
try:
|
||
from core.agent import calculate_context_tokens as _calc_usage
|
||
if _calc_usage(stream_state.get("usage") or {}) > 0:
|
||
_usage_json = json.dumps(stream_state.get("usage"))
|
||
except Exception:
|
||
pass
|
||
self.db.add_message(
|
||
session_id=session_id,
|
||
role="assistant",
|
||
content=stream_state["content"],
|
||
parent_id=parent_id, # <--- 链表指针!
|
||
reasoning=stream_state["reasoning"],
|
||
msg_id=msg_id,
|
||
timeline=tl_json, # 🌟 时间线持久化(思考/文本/工具 按序)
|
||
usage=_usage_json # 🆕 P1: usage 锚点
|
||
)
|
||
print(f"[生命周期] DB 写入完成 mid={msg_id} timeline={len(_tl0)}条", flush=True)
|
||
# 🆕 P1-01:入库后刷新链内下标(仅当前会话推送;JS 侧同名校验)
|
||
self._rw_note_live(msg_id, session_id)
|
||
|
||
if self.db.check_session_needs_title(session_id):
|
||
self._generate_session_title(session_id)
|
||
|
||
del self._active_streams[session_id]
|
||
|
||
if session_id == self.current_session_id:
|
||
self.set_send_button_state(False)
|
||
self.update_context_display()
|
||
|
||
def _generate_session_title(self, session_id):
|
||
"""调用 LLM 生成会话标题"""
|
||
# 🌟 如果这个会话已经在生成标题了,跳过
|
||
if session_id in self._title_workers:
|
||
return
|
||
|
||
# 🚀 修复 Bug:使用新的链表查询方法 get_message_chain!
|
||
messages = self.db.get_message_chain(session_id)
|
||
|
||
user_msg = next((m for m in messages if m["role"] == "user"), None)
|
||
assistant_msg = next((m for m in messages if m["role"] == "assistant"), None)
|
||
|
||
if not user_msg or not assistant_msg:
|
||
return
|
||
|
||
title_prompt = [
|
||
{"role": "system", "content": "你是一个标题生成助手。根据对话内容,生成一个6个字以内的简洁标题。只返回标题文本,不要其他内容。"},
|
||
{"role": "user", "content": f"用户:{user_msg['content'][:100]}\n助手:{assistant_msg['content'][:100]}"}
|
||
]
|
||
|
||
worker = TitleWorker(self.current_provider, self.current_model, title_prompt)
|
||
|
||
# 🌟 用字典存储,以 session_id 为 key
|
||
self._title_workers[session_id] = {
|
||
"worker": worker,
|
||
"accumulator": ""
|
||
}
|
||
|
||
# 🌟 用 lambda 绑定 session_id
|
||
worker.chunk_received.connect(lambda chunk: self._on_title_chunk(session_id, chunk))
|
||
worker.finished.connect(lambda: self._on_title_finished(session_id))
|
||
worker.start()
|
||
def _on_title_chunk(self, session_id, chunk):
|
||
"""累积标题 token"""
|
||
if session_id in self._title_workers:
|
||
self._title_workers[session_id]["accumulator"] += chunk
|
||
|
||
def _on_title_finished(self, session_id):
|
||
if session_id not in self._title_workers:
|
||
return
|
||
|
||
title_state = self._title_workers[session_id]
|
||
new_title = title_state["accumulator"].strip()[:20]
|
||
|
||
if new_title:
|
||
self.db.update_session_title(session_id, new_title)
|
||
|
||
# 🔴 原来: item.setText(new_title)
|
||
# 🟢 改为: 通过 widget 更新
|
||
for i in range(self.history_list.count()):
|
||
item = self.history_list.item(i)
|
||
if item.data(QtCore.Qt.ItemDataRole.UserRole) == session_id:
|
||
w = self.history_list.itemWidget(item)
|
||
if w and hasattr(w, 'set_title'):
|
||
w.set_title(new_title)
|
||
break
|
||
|
||
print(f"[System]: ✅ 会话标题已生成 -> {new_title}")
|
||
|
||
worker = title_state["worker"]
|
||
worker.deleteLater()
|
||
del self._title_workers[session_id]
|
||
|
||
|
||
def _on_title_generated(self, worker):
|
||
"""标题生成完成的回调"""
|
||
# 从 worker 中提取生成的标题
|
||
if hasattr(worker, '_accumulated_content'):
|
||
new_title = worker._accumulated_content.strip()[:20] # 最多 20 个字
|
||
if new_title:
|
||
# 更新数据库
|
||
self.db.update_session_title(self.current_session_id, new_title)
|
||
|
||
# 更新侧边栏 UI
|
||
for i in range(self.history_list.count()):
|
||
item = self.history_list.item(i)
|
||
if item.data(QtCore.Qt.ItemDataRole.UserRole) == self.current_session_id:
|
||
item.setText(new_title)
|
||
break
|
||
|
||
print(f"[System]: ✅ 会话标题已生成 -> {new_title}")
|
||
|
||
worker.deleteLater()
|
||
|
||
# ==================== 工具执行事件(pi tool_execution_*) ====================
|
||
|
||
def _on_tool_started(self, session_id, call_id, name, args):
|
||
"""工具开始执行 → 前端在时间线当前位置插入执行 chip"""
|
||
print(f"[工具] 开始 name={name} call={call_id} session={session_id[:8]}", flush=True)
|
||
# 🆕 右侧任务面板(bash 才登记;只跟当前会话)
|
||
if session_id == self.current_session_id:
|
||
try:
|
||
self.bash_panel.on_started(call_id, name, args)
|
||
except Exception as _e:
|
||
print(f"[Warn]: 任务面板 on_started 失败: {_e}")
|
||
st = self._active_streams.get(session_id)
|
||
if not st:
|
||
return
|
||
if session_id == self.current_session_id:
|
||
self._schedule_context_refresh() # 🆕 Fix E: 工具显现 → 刷新上下文标签
|
||
# 🌟 时间线:工具条目(跨会话也记录,供持久化)
|
||
st["tl_kind"] = "tool"
|
||
st["timeline"].append({"t": "tool", "id": call_id, "name": name,
|
||
"args": args, "ok": None, "result": ""})
|
||
if session_id == self.current_session_id:
|
||
self.chat_bridge.tool_execution_started(st["msg_id"], call_id, name, args)
|
||
|
||
def _on_tool_updated(self, session_id, call_id, text):
|
||
"""工具执行中的增量输出"""
|
||
# 🆕 右侧任务面板:实时输出(独立缓冲,不受上游 4000 字符上限影响)
|
||
if session_id == self.current_session_id:
|
||
try:
|
||
self.bash_panel.on_output(call_id, text)
|
||
except Exception:
|
||
pass
|
||
st = self._active_streams.get(session_id)
|
||
if not st:
|
||
return
|
||
if session_id == self.current_session_id:
|
||
self._schedule_context_refresh() # 🆕 Fix E: 工具输出显现 → 刷新
|
||
# 🌟 时间线:按 call_id 累积工具输出(上限 4000 字符)
|
||
for e in reversed(st["timeline"]):
|
||
if e.get("t") == "tool" and e.get("id") == call_id:
|
||
e["result"] = (e.get("result", "") + text)[-4000:]
|
||
break
|
||
if session_id == self.current_session_id:
|
||
self.chat_bridge.tool_execution_updated(st["msg_id"], call_id, text)
|
||
|
||
def _on_tool_timed(self, session_id, call_id, elapsed, timeout):
|
||
"""🆕 bash 运行中每秒读秒 → 前端气泡刷新 N/Ts(0/10s, 1/10s, ...)"""
|
||
# 🆕 右侧任务面板:读秒
|
||
if session_id == self.current_session_id:
|
||
try:
|
||
self.bash_panel.on_timed(call_id, elapsed, timeout)
|
||
except Exception:
|
||
pass
|
||
if session_id == self.current_session_id:
|
||
st = self._active_streams.get(session_id)
|
||
if st:
|
||
self.chat_bridge.tool_execution_timed(
|
||
st["msg_id"], call_id, int(elapsed), int(timeout))
|
||
|
||
def _on_tool_finished(self, session_id, call_id, name, ok, text):
|
||
"""工具执行结束 → chip 按 call_id 标记结果"""
|
||
_flat = " ".join((text or "").split())
|
||
print(f"[工具] 完成 name={name} ok={ok} 结果={len(text or '')}c call={call_id} {_flat[:80]}", flush=True)
|
||
# 🆕 右侧任务面板:完成后原位转为「已完成」(输出 = 进入上下文的原文)
|
||
if session_id == self.current_session_id:
|
||
try:
|
||
self.bash_panel.on_finished(call_id, name, ok, text)
|
||
except Exception as _e:
|
||
print(f"[Warn]: 任务面板 on_finished 失败: {_e}")
|
||
st = self._active_streams.get(session_id)
|
||
if not st:
|
||
return
|
||
if session_id == self.current_session_id:
|
||
self._schedule_context_refresh() # 🆕 Fix E: 工具结果定格 → 刷新
|
||
# 🌟 时间线:按 call_id 定格工具条目
|
||
for e in reversed(st["timeline"]):
|
||
if e.get("t") == "tool" and e.get("id") == call_id:
|
||
e["ok"] = bool(ok)
|
||
e["result"] = text or e.get("result", "")
|
||
break
|
||
if session_id == self.current_session_id:
|
||
self.chat_bridge.tool_execution_finished(st["msg_id"], call_id, name, ok, text)
|
||
|
||
def _on_compaction_started(self, session_id, path):
|
||
"""🆕 压缩开始(摘要 LLM 调用中)→ 当前消息时间线内显示「上下文压缩」思考气泡"""
|
||
print(f"[压缩] 开始 session={session_id[:8]} path={path}", flush=True)
|
||
if session_id == self.current_session_id:
|
||
st = self._active_streams.get(session_id)
|
||
if st:
|
||
self.chat_bridge.compaction_started(st["msg_id"], str(path))
|
||
|
||
def _on_context_compacted(self, session_id, payload=None):
|
||
"""agent 自动压缩了上下文(pi compaction)→ 同一气泡原地更新(含摘要全文)"""
|
||
payload = payload or {}
|
||
print(f"[压缩] 完成 session={session_id[:8]} path={payload.get('path')} "
|
||
f"before={payload.get('before')} after={payload.get('after')} "
|
||
f"duration_ms={payload.get('duration_ms')}", flush=True)
|
||
self._dbg(f"[compact] path={payload.get('path')} "
|
||
f"before={payload.get('before')} after={payload.get('after')}")
|
||
# 🆕 压缩持久化:切点齐备 → DB 链上插标记行(历史全保留,API 在标记处截断)
|
||
try:
|
||
_cb = payload.get("cut_before_id")
|
||
_fr = payload.get("first_retained_id")
|
||
if _cb and _fr:
|
||
_meta = json.dumps({"path": payload.get("path"),
|
||
"before": payload.get("before"),
|
||
"after": payload.get("after")}, ensure_ascii=False)
|
||
self.db.insert_compaction_mark(
|
||
session_id, payload.get("summary") or "", _cb, _fr, _meta)
|
||
else:
|
||
print("[压缩] 切点不完整(保留尾巴无 DB 行)→ 本轮仅轮内生效,不插标记", flush=True)
|
||
except Exception as _e:
|
||
print(f"[压缩] 标记入库失败: {_e}", flush=True)
|
||
if session_id == self.current_session_id:
|
||
st = self._active_streams.get(session_id)
|
||
if st:
|
||
self.chat_bridge.compaction_finished(st["msg_id"], payload)
|
||
self.update_context_display()
|
||
|
||
def _on_usage_updated(self, session_id, usage):
|
||
"""🆕 P1: 收到本轮精确 usage → 记录供显示锚定 + 防抖刷新"""
|
||
st = self._active_streams.get(session_id)
|
||
if st is not None:
|
||
st["usage"] = usage
|
||
self._dbg(f"[usage] input={usage.get('input')} output={usage.get('output')}")
|
||
if session_id == self.current_session_id:
|
||
self._schedule_context_refresh()
|
||
|
||
# ==================== 🆕 调试窗口(独立于项目树;文件协议) ====================
|
||
def _poll_debug_cmd(self):
|
||
"""2s 轮询 data/debug_window.cmd:show/hide → 开/关独立调试窗口"""
|
||
try:
|
||
action = poll_debug_cmd()
|
||
if not action:
|
||
return
|
||
if action == "show":
|
||
if self.debug_window is None:
|
||
from ui.views.debug_window import DebugWindow
|
||
self.debug_window = DebugWindow()
|
||
self.debug_window.show()
|
||
self.debug_window.raise_()
|
||
self.debug_window.activateWindow()
|
||
elif action == "hide":
|
||
if self.debug_window is not None:
|
||
self.debug_window.hide()
|
||
except Exception:
|
||
pass
|
||
|
||
def _dbg(self, msg):
|
||
"""向调试会话日志注入一条 APP 事件(吞一切异常,绝不影响主流程)"""
|
||
try:
|
||
debug_log(f"sid={(self.current_session_id or '?')[:8]} {msg}", "APP")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_retry_scheduled(self, session_id, attempt, max_attempts, delay_ms, reason):
|
||
"""🆕 M3: 重试已调度(对照 pi onRetryScheduled)——UI 提示(同压缩提示风格)"""
|
||
if session_id == self.current_session_id:
|
||
self.chat_bridge.show_note(
|
||
f"请求异常,正在自动重试({attempt}/{max_attempts})…")
|
||
|
||
def on_error(self, session_id, error_text: str):
|
||
"""发生错误"""
|
||
if session_id not in self._active_streams:
|
||
return
|
||
|
||
stream_state = self._active_streams[session_id]
|
||
msg_id = stream_state["msg_id"]
|
||
self._dbg(f"[error] {str(error_text)[:150]}")
|
||
|
||
# 🆕 失败轮次持久化(对照 pi:出错也写会话)——
|
||
# 不再"时光倒流"丢弃整轮:已跑完的工具结果 / 正文全部入库,叶子前进,
|
||
# 下次提问能接着干(回放取舍在 build_api_context 里定)。
|
||
self._persist_failed_stream(session_id, stream_state, error_text)
|
||
|
||
# 错误信息始终显示(即使不在当前会话)
|
||
if session_id == self.current_session_id:
|
||
self.chat_bridge.show_error(msg_id, f"❌ 请求失败: {error_text}")
|
||
self.chat_bridge.finish_message(msg_id)
|
||
# 清理该会话的流式状态
|
||
del self._active_streams[session_id]
|
||
# 只有当前会话出错时,才更新按钮状态
|
||
if session_id == self.current_session_id:
|
||
self.set_send_button_state(False)
|
||
self.update_context_display()
|
||
|
||
|
||
def _persist_failed_stream(self, session_id, stream_state, error_text):
|
||
"""🆕 失败轮次持久化(对照 pi:message_end 无条件入库 → 出错也写会话)。
|
||
|
||
与「用户中断」的**持久化策略一致**(本轮已完成的工具结果 + 正文 + 思考全入库),
|
||
只在语义上标注 stop_reason="error";**不再回退叶子("时光倒流")**:
|
||
否则 agent 跑了 200 个工具后第 201 步出错,整轮产出会被丢掉,
|
||
下次提问模型完全不知情 → 只能从头重做。
|
||
|
||
回放取舍(见 build_api_context):
|
||
· 有正文/工具 → is_ignored=0,正常进上下文
|
||
· 完全空 → is_ignored=1,只在库里留痕,不回放(避免空 assistant 触发 400)
|
||
"""
|
||
import json as _json
|
||
try:
|
||
err = " ".join(str(error_text or "").split())[:500]
|
||
body = stream_state.get("content") or ""
|
||
# 用 Markdown 引用块 → 前端 marked 直接渲染成醒目提示条(无需改 JS)
|
||
note = f"> ⚠️ [本轮中断] {err}" if err else "> ⚠️ [本轮中断]"
|
||
content = (body + "\n\n" + note) if body.strip() else note
|
||
timeline = list(stream_state.get("timeline") or [])
|
||
# ⚠️ 关键:带 timeline 的行,回放走 timeline 分支(**不读 content**)
|
||
# → 中断说明必须额外作为一条 text 条目,否则模型看不到「上轮为何断」
|
||
if timeline:
|
||
timeline.append({"t": "text", "text": "\n\n" + note})
|
||
tools = [e for e in timeline if e.get("t") == "tool"]
|
||
has_real = (bool(body.strip()) or bool(tools)
|
||
or bool((stream_state.get("reasoning") or "").strip()))
|
||
tl_json = _json.dumps(timeline, ensure_ascii=False) if timeline else None
|
||
_usage_json = None
|
||
try:
|
||
from core.agent import calculate_context_tokens as _calc_usage
|
||
if _calc_usage(stream_state.get("usage") or {}) > 0:
|
||
_usage_json = _json.dumps(stream_state.get("usage"))
|
||
except Exception:
|
||
pass
|
||
self.db.add_message(
|
||
session_id=session_id,
|
||
role="assistant",
|
||
content=content,
|
||
parent_id=stream_state.get("parent_id"),
|
||
reasoning=stream_state.get("reasoning", ""),
|
||
msg_id=stream_state.get("msg_id"),
|
||
timeline=tl_json,
|
||
usage=_usage_json,
|
||
is_ignored=(not has_real),
|
||
stop_reason="error",
|
||
error_message=err,
|
||
)
|
||
print(f"[DB]: 失败轮次已入库 正文={len(body)}c 工具={len(tools)}个 "
|
||
f"回放={'是' if has_real else '否(全空留痕)'} "
|
||
f"session={session_id[:8]} err={err[:60]}", flush=True)
|
||
except Exception as e:
|
||
print(f"[DB]: 失败轮次入库失败: {e}", flush=True)
|
||
prev_leaf = stream_state.get("previous_leaf_id")
|
||
if prev_leaf:
|
||
try:
|
||
self.db.update_session_leaf(session_id, prev_leaf)
|
||
except Exception:
|
||
pass
|
||
|
||
def _render_watchdog_tick(self):
|
||
"""渲染看门狗:当前会话有活跃流且已有内容 → 强制前端渲染一帧。
|
||
每 2s 附带一次 DOM 体检(PROBE)写入 stream_diag.log。"""
|
||
try:
|
||
st = self._active_streams.get(self.current_session_id)
|
||
if not st:
|
||
return
|
||
if not (st.get("timeline") or st.get("content") or st.get("reasoning")):
|
||
return
|
||
self.chat_bridge.run_js(
|
||
f"forceRenderNow('{st['msg_id']}');")
|
||
import time as _t
|
||
now = _t.time()
|
||
if not hasattr(self, "_last_probe_at"):
|
||
self._last_probe_at = 0
|
||
if now - self._last_probe_at >= 2.0:
|
||
self._last_probe_at = now
|
||
self.browser.page().runJavaScript(
|
||
f"probeStream('{st['msg_id']}')", self._on_probe_result)
|
||
# 屏幕快照:默认关闭(grab() 强制出帧会阻塞渲染器主线程 1-2s,
|
||
# 与正文 token 处理互相干扰);需要时设环境变量 HAOCODE_SHOT=1
|
||
if os.environ.get("HAOCODE_SHOT") == "1":
|
||
_tot = len(st.get("content") or "") + len(st.get("reasoning") or "")
|
||
if not hasattr(self, "_last_shot_len"):
|
||
self._last_shot_len = 0
|
||
if _tot != getattr(self, "_last_shot_len", 0):
|
||
self._last_shot_len = _tot
|
||
try:
|
||
if not hasattr(self, "_shot_n"):
|
||
self._shot_n = 0
|
||
self._shot_n += 1
|
||
import os as _os
|
||
_shot_path = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
|
||
f"diag_shot_{self._shot_n:02d}.png")
|
||
self.browser.grab().save(_shot_path)
|
||
print(f"[画面] 流式快照 #{self._shot_n} 总长={_tot}c -> {_os.path.basename(_shot_path)}", flush=True)
|
||
except Exception as _e:
|
||
print(f"[画面] 截图失败: {_e}", flush=True)
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_probe_result(self, res):
|
||
diag_log(f"PROBE {str(res)[:800]}")
|
||
|
||
def _jslog_drain_tick(self):
|
||
"""每 500ms 抽取 JS console 缓冲 → Python 控制台"""
|
||
try:
|
||
self.browser.page().runJavaScript(
|
||
"window.__jslogDrain ? window.__jslogDrain() : ''", self._jslog_on_drain)
|
||
except Exception:
|
||
pass
|
||
|
||
def _jslog_on_drain(self, res):
|
||
if not res:
|
||
return
|
||
for _line in str(res).split("\n"):
|
||
if _line.strip():
|
||
print(_line, flush=True) # 行内已含标签
|
||
|
||
def on_chunk_received(self, session_id, chunk: str):
|
||
"""接收到 Token"""
|
||
self._diag_chunk_n += 1
|
||
_match = session_id == self.current_session_id
|
||
if self._diag_chunk_n <= 3 or self._diag_chunk_n % 50 == 0 or not _match:
|
||
_st0 = self._active_streams.get(session_id)
|
||
diag_log(f"CHUNK n={self._diag_chunk_n} match={_match} "
|
||
f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c")
|
||
if session_id not in self._active_streams:
|
||
return
|
||
|
||
# 🌟 累积内容到该会话的状态中(不再用全局变量)
|
||
self._active_streams[session_id]["content"] += chunk
|
||
if session_id == self.current_session_id:
|
||
self._schedule_context_refresh() # 🆕 Fix E: 正文显现 → 刷新上下文标签
|
||
|
||
# 🌟 时间线:文本段(与上一段不同则新开)
|
||
st = self._active_streams[session_id]
|
||
if st["tl_kind"] != "text":
|
||
st["tl_kind"] = "text"
|
||
st["timeline"].append({"t": "text", "text": chunk})
|
||
else:
|
||
st["timeline"][-1]["text"] += chunk
|
||
|
||
# 人肉 debug:每个正文 token 控制台打印
|
||
try:
|
||
_mid = self._active_streams[session_id]["msg_id"]
|
||
_flat = " ".join(chunk.split())
|
||
print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True)
|
||
except Exception:
|
||
pass
|
||
|
||
# 只有当前激活的会话才推送到前端显示
|
||
if session_id == self.current_session_id:
|
||
msg_id = self._active_streams[session_id]["msg_id"]
|
||
self.chat_bridge.append_token(msg_id, chunk)
|
||
|
||
|
||
|
||
from PyQt6 import QtWidgets, QtCore
|
||
|
||
def add_session_item_to_sidebar(self, sess_data, at_top=False):
|
||
"""渲染一条会话到侧边栏(使用自定义 widget)"""
|
||
item = QtWidgets.QListWidgetItem()
|
||
item.setData(QtCore.Qt.ItemDataRole.UserRole, sess_data["id"])
|
||
item.setSizeHint(QtCore.QSize(0, 44))
|
||
|
||
widget = SessionItemWidget(sess_data["id"], sess_data["title"])
|
||
widget.menu_requested.connect(self.show_session_context_menu)
|
||
|
||
is_starred = sess_data.get("is_starred", 0)
|
||
|
||
if at_top:
|
||
if is_starred:
|
||
self.history_list.insertItem(0, item)
|
||
else:
|
||
# 插到分隔线下方第一个位置
|
||
sep_row = self.history_list._find_separator_row()
|
||
insert_row = (sep_row + 1) if sep_row >= 0 else 0
|
||
self.history_list.insertItem(insert_row, item)
|
||
self.history_list.setItemWidget(item, widget)
|
||
self.history_list.setCurrentItem(item)
|
||
else:
|
||
self.history_list.addItem(item)
|
||
self.history_list.setItemWidget(item, widget)
|
||
|
||
|
||
def _rw_visible_chain(self, session_id):
|
||
"""🆕 P1-01:会话的可见消息链(过滤 system/compaction),旧→新。"""
|
||
chain = self.db.get_message_chain(session_id)
|
||
return [m for m in chain if m["role"] not in ("system", "compaction")]
|
||
|
||
def _rw_note_live(self, msg_id, expected_session=None):
|
||
"""🆕 P1-01:把新生成的消息推进渲染窗口。
|
||
已持久化 → 带真实链内下标 + 链长;未持久化/查询失败 → -1(安全降级)。
|
||
非当前会话的后台完成不推送(JS 侧状态属于当前会话)。"""
|
||
session = self.current_session_id
|
||
gen = self._rw_generation
|
||
if not session or gen <= 0:
|
||
return
|
||
if expected_session and expected_session != session:
|
||
return
|
||
chain_index, chain_len = -1, -1
|
||
try:
|
||
visible = self._rw_visible_chain(session)
|
||
chain_len = len(visible)
|
||
for i, m in enumerate(visible):
|
||
if m["id"] == msg_id:
|
||
chain_index = i
|
||
break
|
||
except Exception:
|
||
pass
|
||
self.chat_bridge.rw_note_live(session, gen, msg_id, chain_index, chain_len)
|
||
|
||
def _render_history_one(self, msg):
|
||
"""🆕 P1-01:渲染单条历史消息(自 load_messages_to_web 抽取;分页复用)。
|
||
渲染分发逻辑与原版完全一致(附件补齐/时间线/降级)。"""
|
||
import json
|
||
branch_info = None
|
||
if msg["role"] == "assistant" and msg.get("parent_id"):
|
||
siblings = self.db.get_branch_info(msg["parent_id"])
|
||
if len(siblings) > 1:
|
||
try:
|
||
sibling_ids = [s["id"] for s in siblings]
|
||
idx = sibling_ids.index(msg["id"]) + 1
|
||
branch_info = {"current": idx, "total": len(siblings)}
|
||
except (ValueError, KeyError):
|
||
branch_info = None
|
||
|
||
if msg["role"] == "user" and msg.get("attachment_metadata"):
|
||
try:
|
||
metadata = json.loads(msg["attachment_metadata"])
|
||
user_text = metadata.get("user_text", "")
|
||
attachments = metadata.get("attachments", [])
|
||
for att in attachments:
|
||
if "content" not in att:
|
||
if att.get("type") == "image":
|
||
att["content"] = f"[图片文件: {att.get('name', '未知图片')}]"
|
||
else:
|
||
att["content"] = "[附件内容]"
|
||
if "size_kb" not in att:
|
||
att["size_kb"] = 0
|
||
if "lines" not in att:
|
||
att["lines"] = 1
|
||
self.chat_bridge.create_user_message_with_attachments(
|
||
msg["id"], user_text, attachments)
|
||
except (json.JSONDecodeError, TypeError):
|
||
self.chat_bridge.render_history_message(
|
||
msg_id=msg["id"],
|
||
role=msg["role"],
|
||
content=msg["content"],
|
||
reasoning=msg.get("reasoning", ""),
|
||
branch_info=branch_info)
|
||
else:
|
||
tl_json = msg.get("timeline") if msg["role"] == "assistant" else None
|
||
if tl_json:
|
||
self.chat_bridge.create_message(
|
||
msg_id=msg["id"], role=msg["role"],
|
||
text=msg["content"],
|
||
sender_name=self.current_model or "Assistant",
|
||
branch_info=branch_info)
|
||
self.chat_bridge.render_timeline_history(msg["id"], tl_json)
|
||
self.chat_bridge.finish_message(msg["id"])
|
||
else:
|
||
self.chat_bridge.render_history_message(
|
||
msg_id=msg["id"],
|
||
role=msg["role"],
|
||
content=msg["content"],
|
||
reasoning=msg.get("reasoning", ""),
|
||
branch_info=branch_info)
|
||
|
||
def _on_window_page_request(self, session_id, direction,
|
||
boundary_msg_id, generation):
|
||
"""🆕 P1-01:前端换页请求 → 按边界消息在 DB 切片 → 渲染页 → 推送响应。
|
||
边界失效(并发数据变更)→ 空页安全降级,计数仍正确收敛。"""
|
||
if self._rw_page_inflight:
|
||
return # 最多一页在途(JS 侧 pending 同效,双保险)
|
||
if session_id != self.current_session_id or generation != self._rw_generation:
|
||
return # 会话已切 / 代次已推进 → 丢弃
|
||
if direction not in ("older", "newer"):
|
||
return
|
||
self._rw_page_inflight = True
|
||
try:
|
||
visible = self._rw_visible_chain(session_id)
|
||
chain_len = len(visible)
|
||
ids = [m["id"] for m in visible]
|
||
try:
|
||
bi = ids.index(boundary_msg_id)
|
||
except ValueError:
|
||
bi = -1
|
||
|
||
size = self._rw_page_size # 半窗页(非整窗):锚点消息必须留在窗口内
|
||
if bi < 0:
|
||
page, page_indices = [], []
|
||
elif direction == "older":
|
||
start = max(0, bi - size)
|
||
page = visible[start:bi]
|
||
page_indices = list(range(start, bi))
|
||
else:
|
||
start = bi + 1
|
||
page = visible[start:start + size]
|
||
page_indices = list(range(start, start + len(page)))
|
||
|
||
# 批次渲染期间抑制 createMessage/finishMessage 的滚动副作用
|
||
self.chat_bridge.run_js("window.__rwPageRendering = true;")
|
||
for m in page:
|
||
self._render_history_one(m)
|
||
self.chat_bridge.rw_page_response({
|
||
"sessionId": session_id,
|
||
"generation": generation,
|
||
"boundaryId": boundary_msg_id,
|
||
"direction": direction,
|
||
"chainLen": chain_len,
|
||
"items": [{"id": m["id"], "chainIndex": ix}
|
||
for m, ix in zip(page, page_indices)],
|
||
})
|
||
self.chat_bridge.run_js("window.__rwPageRendering = false;")
|
||
except Exception as e:
|
||
print(f"[RW] 分页处理异常: {e}")
|
||
self.chat_bridge.run_js("window.__rwPageRendering = false;")
|
||
finally:
|
||
self._rw_page_inflight = False
|
||
|
||
def load_messages_to_web(self, session_id, show_loading=True):
|
||
"""加载消息链到 WebView(🆕 P1-01:窗口化——初始仅渲染最新 size 条)"""
|
||
import json
|
||
# 🆕 P1-01:每次加载递增代次,旧会话的在途分页响应一律 stale
|
||
self._rw_generation += 1
|
||
gen = self._rw_generation
|
||
self.current_session_id = session_id
|
||
self._refresh_mode_button() # 🆕 会话切换 → 刷新模式按钮
|
||
# 🆕 右侧任务面板:跟随会话切换(范围永远是当前会话)
|
||
try:
|
||
self.bash_panel.set_session(
|
||
session_id, self.db, self._active_streams.get(session_id))
|
||
except Exception as _e:
|
||
print(f"[Warn]: 任务面板刷新失败: {_e}")
|
||
# 🌟 统一加载界面:切换会话/启动时盖加载层;内部刷新(重新回答/分支/删除)不触发
|
||
if show_loading:
|
||
self.chat_bridge.show_loading()
|
||
# 🆕 P1-01:rwBegin 内部执行 clearChat + (session, generation) 重同步
|
||
visible = self._rw_visible_chain(session_id)
|
||
total = len(visible)
|
||
self.chat_bridge.rw_begin(session_id, gen, total)
|
||
|
||
# 2. 处理空对话状态
|
||
if not visible:
|
||
self.chat_bridge.show_welcome()
|
||
self._update_send_button_state()
|
||
if show_loading:
|
||
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
|
||
return
|
||
|
||
# 3. 窗口化渲染:仅最新 size 条(🆕 P1-01);更早消息经换页按需加载
|
||
window_items = visible[-self._rw_size:] if self._rw_size > 0 else visible
|
||
# 批次渲染期间抑制逐条滚底(初始窗口完成后由前端统一对齐底部)
|
||
self.chat_bridge.run_js("window.__rwPageRendering = true;")
|
||
for msg in window_items:
|
||
self._render_history_one(msg)
|
||
# 链内下标 = 窗口在链尾的偏移 + 窗口内序号(不是局部下标!)
|
||
offset = total - len(window_items)
|
||
self.chat_bridge.rw_init_window(
|
||
session_id, gen, total,
|
||
[(m["id"], offset + i) for i, m in enumerate(window_items)])
|
||
self.chat_bridge.run_js("window.__rwPageRendering = false;")
|
||
|
||
# 5. 恢复流式输出状态(处理页面刷新或切换回正在生成的会话)
|
||
#🌟 如果该会话有正在进行的流式输出,每次都重新推送
|
||
if session_id in self._active_streams:
|
||
stream_state = self._active_streams[session_id]
|
||
msg_id = stream_state["msg_id"]
|
||
|
||
# 🚀 修复点:恢复时也要带上缓存的 branch_info
|
||
branch_info = stream_state.get("branch_info")
|
||
|
||
self.chat_bridge.create_message(
|
||
msg_id, "assistant", "",
|
||
self.current_model or "Assistant",
|
||
branch_info=branch_info # 🚀 传给前端
|
||
)
|
||
# 🆕 P1-01:切回复活的流式消息进渲染窗口(已入库则带真实下标)
|
||
self._rw_note_live(msg_id, session_id)
|
||
# 🌟 有 agent 时间线 → 按序恢复 思考/文本/工具 块(后续 token 无缝续流)
|
||
if stream_state.get("timeline"):
|
||
diag_log(f"RESTORE mid={msg_id} entries={len(stream_state['timeline'])} "
|
||
f"text={len(stream_state.get('content',''))}c think={len(stream_state.get('reasoning',''))}c")
|
||
self.chat_bridge.restore_streaming_timeline(
|
||
msg_id, json.dumps(stream_state["timeline"], ensure_ascii=False))
|
||
else:
|
||
# 恢复已生成的推理内容
|
||
if stream_state.get("reasoning"):
|
||
reasoning_text = stream_state["reasoning"]
|
||
for i in range(0, len(reasoning_text), 500):
|
||
self.chat_bridge.append_reasoning(msg_id, reasoning_text[i:i+500])
|
||
|
||
# 恢复已生成的正文内容
|
||
if stream_state.get("content"):
|
||
content_text = stream_state["content"]
|
||
for i in range(0, len(content_text), 500):
|
||
self.chat_bridge.append_token(msg_id, content_text[i:i+500])
|
||
|
||
# 6. 更新 UI 状态
|
||
self._update_send_button_state()
|
||
self.update_context_display()
|
||
|
||
# 🌟 绘制完成后,渐变退场露出会话(图标扫描条 → 渐隐)
|
||
if show_loading:
|
||
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
|
||
|
||
|
||
def _update_send_button_state(self):
|
||
"""根据当前会话是否在生成,更新发送按钮状态"""
|
||
is_generating = self.current_session_id in self._active_streams
|
||
self.set_send_button_state(is_generating)
|
||
|
||
|
||
def on_sidebar_item_clicked(self, item):
|
||
# 🌟 点击分隔线无效
|
||
if self.history_list._is_separator(item):
|
||
return
|
||
session_id = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||
if not session_id:
|
||
return
|
||
print(f"[UI]: 切换到历史对话 -> {session_id}")
|
||
self.load_messages_to_web(session_id)
|
||
|
||
|
||
def init_model_popup(self):
|
||
# 1. 读取配置(P0-01 统一入口:缺失/损坏 → 可见警告 + 安全默认值)
|
||
from core.config_paths import load_config as _load_cfg
|
||
self.config_data = _load_cfg() or {"providers": {}}
|
||
|
||
# 2. 实例化我们写的自定义弹窗 (先不显示)
|
||
self.model_popup = ModelSelectPopup(self, self.config_data)
|
||
|
||
# 3. 绑定弹窗的选中信号
|
||
self.model_popup.model_selected.connect(self.on_model_selected)
|
||
|
||
# 4. 把原来的 model_selector 按钮点击事件绑定到显示弹窗的方法上
|
||
# 注意:需要把按钮的 setMenu 去掉(如果你在 setup_ui 里写了的话)
|
||
self.model_selector.clicked.connect(self.show_model_popup)
|
||
self.model_selector.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||
|
||
# 5. 设置默认选中
|
||
default_p = self.config_data.get("default_provider", "GPTBest")
|
||
default_m = self.config_data.get("default_model", "gemini-3.1-pro-preview-thinking-high")
|
||
self.on_model_selected(default_p, default_m)
|
||
|
||
def show_model_popup(self):
|
||
"""计算位置并执行动画弹出"""
|
||
# 获取按钮在屏幕上的全局坐标
|
||
btn_pos = self.model_selector.mapToGlobal(QtCore.QPoint(0, 0))
|
||
|
||
# 计算弹窗最终应该停留的目标坐标
|
||
x = btn_pos.x() + self.model_selector.width() - self.model_popup.width()
|
||
y = btn_pos.y() - self.model_popup.height() - 5
|
||
|
||
# 🌟 调用动画显示方法,传入目标坐标
|
||
self.model_popup.show_with_animation(QtCore.QPoint(x, y))
|
||
|
||
|
||
def on_model_selected(self, provider, model):
|
||
"""当用户在弹窗中选择模型时触发"""
|
||
self.current_provider = provider
|
||
self.current_model = model
|
||
|
||
# 截断过长的模型名字显示在按钮上
|
||
display_name = model if len(model) < 20 else model[:18] + "..."
|
||
self.model_selector.setText(display_name)
|
||
|
||
print(f"\n[系统]: 已切换引擎 -> 供应商: {provider} | 模型: {model}")
|
||
# 🌟 新增:处理思考过程的槽函数
|
||
#新增:切换模型后刷新上下文显示(因为不同模型上限不同)
|
||
self.update_context_display()
|
||
|
||
def on_reasoning_received(self, session_id, chunk: str):
|
||
"""接收到思考过程"""
|
||
self._diag_think_n += 1
|
||
_match = session_id == self.current_session_id
|
||
if self._diag_think_n <= 3 or self._diag_think_n % 50 == 0 or not _match:
|
||
_st0 = self._active_streams.get(session_id)
|
||
diag_log(f"THINK n={self._diag_think_n} match={_match} "
|
||
f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c")
|
||
if session_id not in self._active_streams:
|
||
return
|
||
|
||
# 🌟 累积思考过程到该会话的状态中
|
||
self._active_streams[session_id]["reasoning"] += chunk
|
||
if session_id == self.current_session_id:
|
||
self._schedule_context_refresh() # 🆕 Fix E: 思考显现 → 刷新上下文标签
|
||
|
||
# 🌟 时间线:思考段(与上一段不同则新开)
|
||
st = self._active_streams[session_id]
|
||
if st["tl_kind"] != "think":
|
||
st["tl_kind"] = "think"
|
||
st["timeline"].append({"t": "think", "text": chunk})
|
||
else:
|
||
st["timeline"][-1]["text"] += chunk
|
||
|
||
# 人肉 debug:每个思考 token 控制台打印
|
||
try:
|
||
_mid = self._active_streams[session_id]["msg_id"]
|
||
_flat = " ".join(chunk.split())
|
||
print(f"[思考] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:60]}", flush=True)
|
||
except Exception:
|
||
pass
|
||
|
||
# 只有当前激活的会话才推送到前端显示
|
||
if session_id == self.current_session_id:
|
||
msg_id = self._active_streams[session_id]["msg_id"]
|
||
self.chat_bridge.append_reasoning(msg_id, chunk)
|
||
|
||
def _on_diag_dumped(self, res):
|
||
diag_log(f"JS_DIAG {str(res)[:3000]}")
|
||
|
||
def on_web_load_finished(self, ok):
|
||
"""网页加载完成后的回调"""
|
||
if not ok:
|
||
print("[Error]: 网页加载失败")
|
||
return
|
||
|
||
try:
|
||
self.browser.page().runJavaScript(
|
||
"String(window.__APP_VER || 'unknown')",
|
||
lambda v: diag_log(f"FRONTEND_VER {v}"))
|
||
except Exception:
|
||
pass
|
||
print("[System]: 网页 HTML 加载完毕,等待 JS 引擎就绪...")
|
||
|
||
# 🌟 开始轮询检测 JS 是否就绪
|
||
if self.current_session_id:
|
||
self._check_js_ready()
|
||
|
||
def _check_js_ready(self, retry_count=0):
|
||
"""
|
||
递归检测 JS 引擎是否就绪
|
||
最多重试 20 次(2 秒),每次间隔 100ms
|
||
"""
|
||
if retry_count > 20:
|
||
print("[Error]: JS 引擎超时未就绪")
|
||
return
|
||
|
||
# 检测 window.jsReady 标志
|
||
self.browser.page().runJavaScript(
|
||
"typeof window.jsReady !== 'undefined' && window.jsReady === true",
|
||
lambda is_ready: self._on_js_ready_checked(is_ready, retry_count)
|
||
)
|
||
|
||
def _on_js_ready_checked(self, is_ready, retry_count):
|
||
"""JS 就绪检测的回调"""
|
||
if is_ready:
|
||
print("[System]:✅ JS 引擎已就绪,正在加载历史记录...")
|
||
# 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载
|
||
if not self._rw_config_pushed:
|
||
self._rw_config_pushed = True
|
||
self.chat_bridge.rw_config(self._rw_mode, self._rw_size)
|
||
self.load_messages_to_web(self.current_session_id)
|
||
# 🌟 触发前端上报一次初始滚动状态,同步自定义滚动条
|
||
self.chat_bridge.run_js("setTimeout(reportWebScroll, 60);")
|
||
else:
|
||
# 还没就绪,100ms 后重试
|
||
QtCore.QTimer.singleShot(100, lambda: self._check_js_ready(retry_count + 1))
|
||
|
||
|
||
def on_new_chat_clicked(self):
|
||
# 🆕 P1-01:代次 +1(旧会话的在途分页响应失效)
|
||
self._rw_generation += 1
|
||
new_sess = self.db.create_session(title="新对话")
|
||
print(f"[UI]: 新建对话 -> {new_sess['id']}")
|
||
|
||
self.current_session_id = new_sess["id"]
|
||
self._refresh_mode_button() # 🆕 新建会话 → 未锁定,恢复可选状态
|
||
|
||
# 🌟 统一加载界面:新建会话同样走 加载 → 渐变 → 欢迎页 流程
|
||
self.chat_bridge.show_loading()
|
||
|
||
# 重建侧边栏(自动处理分隔线和插入位置)
|
||
self.rebuild_sidebar()
|
||
|
||
self.chat_bridge.clear_chat()
|
||
self.chat_bridge.show_welcome()
|
||
self._update_send_button_state()
|
||
self.update_context_display()
|
||
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
|
||
|
||
|
||
def build_api_context(self, session_id):
|
||
"""
|
||
🚀 构建上下文:读取本地图片文件,实时转为 Base64 发给大模型,彻底解放数据库!
|
||
"""
|
||
import json
|
||
import os
|
||
import base64
|
||
|
||
messages = self.db.get_message_chain(session_id)
|
||
api_messages = []
|
||
|
||
# 🆕 压缩持久化:链上最后一个 compaction 标记 = 切点。
|
||
# 它之前的一切只以摘要形式发送(作为一条 user 消息,与轮内压缩
|
||
# 的 to_openai_messages 渲染一致);标记保留在库中,UI 渲染照常。
|
||
_mark_idx = -1
|
||
for _i, _m in enumerate(messages):
|
||
if _m["role"] == "compaction":
|
||
_mark_idx = _i
|
||
if _mark_idx >= 0:
|
||
_mark = messages[_mark_idx]
|
||
api_messages.append({
|
||
"role": "user",
|
||
"content": _mark["content"],
|
||
"_kind": "compaction_summary",
|
||
"_db_msg_id": _mark["id"],
|
||
# 🆕 Fix F: 摘要条目必须带时间戳(标记插入时刻,晚于全部保留行)。
|
||
# 缺了它 P0 锚点时效校验失效:保留行里 assistant 的入库 usage 是
|
||
# 压缩前快照(如 92.7k),显示/压缩判定会锚到过期值 → 标签虚高,
|
||
# 小窗口模型还会误触发二次压缩(摘要套摘要)。
|
||
"timestamp": int(_mark.get("created_at", 0) or 0) * 1000,
|
||
})
|
||
messages = messages[_mark_idx + 1:]
|
||
# 获取项目根目录
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
|
||
for msg in messages:
|
||
if msg["is_ignored"]:
|
||
continue
|
||
|
||
# 🆕 失败轮次·全空行:入库留痕但**不回放**(对照 pi transform-messages
|
||
# 跳过 error/aborted 的 assistant;空 assistant 内容会让个别服务商 400)
|
||
if msg.get("role") == "assistant":
|
||
_tl = (msg.get("timeline") or "").strip()
|
||
_has_tl = _tl not in ("", "[]", "null")
|
||
if (not (msg.get("content") or "").strip() and not _has_tl
|
||
and not (msg.get("reasoning") or "").strip()):
|
||
continue
|
||
|
||
content_payload = msg["content"]
|
||
|
||
# 🌟 核心:如果是用户消息且带有附件元数据,检查是否有图片
|
||
if msg["role"] == "user" and msg.get("attachment_metadata"):
|
||
try:
|
||
meta = json.loads(msg["attachment_metadata"])
|
||
# 收集普通图片附件 + PDF 图片解析模式下用户勾选的图片
|
||
images = []
|
||
for a in meta.get("attachments", []):
|
||
if a.get("type") == "image":
|
||
images.append(a)
|
||
elif a.get("type") == "pdf" and a.get("mode") == "image":
|
||
for sel in a.get("selected_images", []):
|
||
if sel.get("local_path"):
|
||
images.append({
|
||
"local_path": sel["local_path"],
|
||
"mime": sel.get("mime", "image/png"),
|
||
})
|
||
|
||
if images:
|
||
# 发现图片!将 content 转换为 OpenAI 视觉接口要求的 List 格式
|
||
content_payload = [{"type": "text", "text": msg["content"]}]
|
||
for img in images:
|
||
local_path = img.get('local_path')
|
||
if not local_path: continue
|
||
|
||
# 拼接出图片的绝对路径并实时读取转码
|
||
# 拼接出图片的绝对路径并实时读取转码
|
||
abs_path = os.path.join(root_dir, local_path)
|
||
|
||
if os.path.exists(abs_path):
|
||
with open(abs_path, "rb") as f:
|
||
b64_data = base64.b64encode(f.read()).decode('utf-8')
|
||
|
||
content_payload.append({
|
||
"type": "image_url",
|
||
"image_url": {
|
||
"url": f"data:{img['mime']};base64,{b64_data}"
|
||
}
|
||
})
|
||
else:
|
||
print(f"[Warn]: 找不到图片文件,可能已被物理删除: {abs_path}")
|
||
except Exception as e:
|
||
print(f"[Warn]: 解析图片附件失败: {e}")
|
||
|
||
# 🌟 AI 消息带 agent 时间线 → 重建完整上下文(含 tool_calls 与工具结果),
|
||
# 让模型跨轮次看得到自己用过的工具和输出(对照 pi 全量上下文语义)
|
||
if msg["role"] == "assistant" and msg.get("timeline"):
|
||
try:
|
||
tl = json.loads(msg["timeline"])
|
||
except Exception:
|
||
tl = None
|
||
if isinstance(tl, list) and tl:
|
||
# 🆕 P0/P1: 回放本行时间戳(created_at 秒→毫秒)与 usage(锚点)
|
||
row_ts = int(msg.get("created_at", 0) or 0) * 1000
|
||
row_usage = None
|
||
if msg.get("usage"):
|
||
try:
|
||
row_usage = json.loads(msg["usage"])
|
||
except Exception:
|
||
row_usage = None
|
||
for e in tl:
|
||
et = e.get("t")
|
||
if et == "think":
|
||
continue
|
||
if et == "text":
|
||
api_messages.append(
|
||
{"role": "assistant", "content": e.get("text", ""),
|
||
"timestamp": row_ts, "_db_msg_id": msg["id"]})
|
||
elif et == "tool":
|
||
tcid = e.get("id") or f"tl{len(api_messages)}"
|
||
if api_messages and api_messages[-1].get("role") == "assistant":
|
||
base = api_messages[-1]
|
||
else:
|
||
base = {"role": "assistant", "content": None}
|
||
api_messages.append(base)
|
||
base["timestamp"] = row_ts
|
||
base["_db_msg_id"] = msg["id"]
|
||
base.setdefault("tool_calls", []).append({
|
||
"id": tcid,
|
||
"type": "function",
|
||
"function": {"name": e.get("name", ""),
|
||
"arguments": e.get("args") or "{}"},
|
||
})
|
||
_res = e.get("result", "")
|
||
if e.get("ok") is None:
|
||
# 🆕 孤儿工具调用(开始了但没拿到结果 = 本轮异常中断):
|
||
# 对照 pi insertSyntheticToolResults —— 补一条合成结果,
|
||
# 保证 tool_call/tool_result 成对,否则服务商直接 400
|
||
_res = "(本轮请求异常中断,未收到该工具的结果)"
|
||
api_messages.append({
|
||
"role": "tool", "tool_call_id": tcid,
|
||
# 长结果 UI 存 2 万字供展开;回灌模型限 4000 字防上下文爆炸
|
||
"content": _res[:4000],
|
||
"timestamp": row_ts, "_db_msg_id": msg["id"],
|
||
})
|
||
# 🆕 P1: 本行 usage 挂到本行最后一条 assistant 上(usage 锚点)
|
||
if row_usage:
|
||
for _entry in reversed(api_messages):
|
||
if _entry.get("role") == "assistant":
|
||
_entry["usage"] = row_usage
|
||
break
|
||
continue
|
||
|
||
# P0 修复: 不再向 API 注入 reasoning 字段
|
||
# (OpenAI 兼容接口不认该字段,部分服务端直接 400;
|
||
# 思考过程仅用于本地展示与入库)
|
||
entry = {"role": msg["role"], "content": content_payload,
|
||
# 🆕 P0: 回放本行时间戳(供 usage 锚点时效校验)
|
||
"timestamp": int(msg.get("created_at", 0) or 0) * 1000,
|
||
"_db_msg_id": msg["id"]}
|
||
# 🆕 P1: assistant 行有入库 usage → 回放(显示/agent 做 usage 锚定)
|
||
if msg["role"] == "assistant" and msg.get("usage"):
|
||
try:
|
||
entry["usage"] = json.loads(msg["usage"])
|
||
except Exception:
|
||
pass
|
||
api_messages.append(entry)
|
||
|
||
return api_messages
|
||
|
||
|
||
def closeEvent(self, event):
|
||
"""窗口关闭前,清理所有正在运行的 worker 线程"""
|
||
print("[System]: 正在清理后台任务...")
|
||
|
||
# 1. 停止所有正在运行的会话生成任务
|
||
for session_id in list(self._active_streams.keys()):
|
||
stream_state = self._active_streams[session_id]
|
||
# 🆕 Fix B: 关窗 ≈ 中断,先把进行中的部分内容落库(防止蒸发)
|
||
self._persist_interrupted_stream(session_id, stream_state)
|
||
worker = stream_state.get("worker")
|
||
if worker:
|
||
try:
|
||
worker.chunk_received.disconnect()
|
||
worker.error_occurred.disconnect()
|
||
worker.reasoning_received.disconnect()
|
||
worker.finished.disconnect()
|
||
if hasattr(worker, "tool_execution_started"):
|
||
worker.tool_execution_started.disconnect()
|
||
worker.tool_execution_updated.disconnect()
|
||
worker.tool_execution_finished.disconnect()
|
||
worker.context_compacted.disconnect()
|
||
worker.usage_updated.disconnect()
|
||
if hasattr(worker, "retry_scheduled"):
|
||
worker.retry_scheduled.disconnect()
|
||
except Exception:
|
||
pass
|
||
worker.abort()
|
||
worker.wait(1000)
|
||
if worker.isRunning():
|
||
worker.terminate()
|
||
worker.wait()
|
||
self._active_streams.clear()
|
||
|
||
# 🌟 2. 停止所有标题生成任务
|
||
for session_id in list(self._title_workers.keys()):
|
||
title_state = self._title_workers[session_id]
|
||
worker = title_state.get("worker")
|
||
if worker:
|
||
try:
|
||
worker.chunk_received.disconnect()
|
||
worker.finished.disconnect()
|
||
except Exception:
|
||
pass
|
||
worker.cancel()
|
||
worker.wait(1000)
|
||
if worker.isRunning():
|
||
worker.terminate()
|
||
worker.wait()
|
||
self._title_workers.clear()
|
||
|
||
# 🌟 停止全局热键监听线程(注销 Alt+S)
|
||
if hasattr(self, "hotkey_screenshot"):
|
||
self.hotkey_screenshot.stop()
|
||
|
||
print("[System]: ✅ 清理完成")
|
||
event.accept()
|
||
def _get_current_max_context(self):
|
||
"""获取当前选中模型的最大上下文 (token 数)"""
|
||
try:
|
||
provider_info = self.config_data["providers"][self.current_provider]
|
||
return provider_info.get("model_contexts", {}).get(self.current_model, 128000)
|
||
except (KeyError, TypeError):
|
||
return 128000
|
||
def update_context_display(self):
|
||
max_tokens = self._get_current_max_context()
|
||
max_k = max_tokens // 1000
|
||
|
||
if not self.current_session_id:
|
||
self.context_label.setText(f"0 / {max_k}k")
|
||
self.context_label.setStyleSheet("color: #888; font-size: 12px; margin-right: 10px;")
|
||
return
|
||
|
||
messages = self.build_api_context(self.current_session_id)
|
||
# 🆕 G3(替代 Fix F):统一「下一请求」口径 —— 与压缩判定(should_compact)
|
||
# 同一公式:
|
||
# - 有有效锚点(轮中内存 / 纯文本行)→ provider 实测值(含 system+tools)
|
||
# - 工具行的入库 usage 是内存快照(未截断)≠ 下一请求(4k 截断回放)
|
||
# → G1 自动失效其锚点 → 全量公式
|
||
# - 全量公式计入 system 提示词 + 工具 schema(worker 模式),
|
||
# 度量下一请求真实载荷(旧 Fix F 漏了这 ~10k,标签 34.1k vs 真实 44.3k)
|
||
try:
|
||
from core.agent import (from_openai_messages as _fom,
|
||
estimate_context_tokens as _ect)
|
||
_sp, _tools = "", None
|
||
if self._get_current_mode() == "worker":
|
||
from core.llm_engine import load_system_prompt as _lsp
|
||
from core.agent.tools import default_tools as _dt
|
||
_sp, _tools = _lsp(), _dt()
|
||
tokens = _ect(_fom(messages), system_prompt=_sp,
|
||
tools=_tools).tokens
|
||
except Exception:
|
||
tokens = self._estimate_token_count(messages)
|
||
|
||
if self.current_session_id in self._active_streams:
|
||
s = self._active_streams[self.current_session_id]
|
||
# 🆕 P1: 在飞请求的精确 usage 已到达 → usage 锚定(对照 pi 显示端:
|
||
# usage 精确覆盖 system+tools+历史+在飞输出,比字符估算准)
|
||
try:
|
||
from core.agent import calculate_context_tokens as _calc_usage
|
||
_u_tokens = _calc_usage(s.get("usage") or {})
|
||
except Exception:
|
||
_u_tokens = 0
|
||
if _u_tokens > 0:
|
||
tokens = _u_tokens
|
||
else:
|
||
tokens += int((len(s["content"]) + len(s["reasoning"])) / 2.5)
|
||
# 🆕 Fix E: 在飞未入库的工具输出也计入显示,标签随工具结果显现实时上涨
|
||
for _e in (s.get("timeline") or []):
|
||
if _e.get("t") == "tool":
|
||
tokens += int(len(_e.get("result", "")) / 2.5)
|
||
|
||
used_str = f"{tokens / 1000:.1f}k" if tokens >= 1000 else str(tokens)
|
||
self.context_label.setText(f"{used_str} / {max_k}k")
|
||
# 🆕 调试日志:标签值变化才记(避免 400ms 防抖每次刷新都写)
|
||
_ctx_dbg = f"{used_str} / {max_k}k"
|
||
if getattr(self, "_last_ctx_dbg", None) != _ctx_dbg:
|
||
self._last_ctx_dbg = _ctx_dbg
|
||
self._dbg(f"[ctx] {_ctx_dbg}")
|
||
|
||
ratio = tokens / max_tokens if max_tokens > 0 else 0
|
||
if ratio > 0.9:
|
||
color = "#e81123"
|
||
elif ratio > 0.7:
|
||
color = "#f5a623"
|
||
else:
|
||
color = "#888"
|
||
self.context_label.setStyleSheet(f"color: {color}; font-size: 12px; margin-right: 10px;")
|
||
def _schedule_context_refresh(self):
|
||
"""🆕 Fix E: 上下文标签防抖刷新(400ms 内的高频事件合并为一次)"""
|
||
try:
|
||
if not self._ctx_refresh_timer.isActive():
|
||
self._ctx_refresh_timer.start()
|
||
except Exception:
|
||
pass
|
||
|
||
def _estimate_token_count(self, messages):
|
||
"""
|
||
CJK 感知 token 估算(core.agent.context 统一口径):
|
||
中日韩字符 ×1,其余字符 /4,每条消息 +4 开销,每张图片 1600。
|
||
与 agent 内部钳制/压缩用同一估算器。
|
||
"""
|
||
try:
|
||
from core.agent import from_openai_messages, estimate_context_tokens
|
||
agent_msgs = from_openai_messages(messages)
|
||
tokens = estimate_context_tokens(agent_msgs).tokens
|
||
return max(1, int(tokens))
|
||
except Exception:
|
||
# 兜底:旧口径
|
||
total_chars = sum(
|
||
(len(m.get("content", "")) if isinstance(m.get("content", ""), str) else 0)
|
||
+ len(m.get("reasoning", "") or "")
|
||
for m in messages
|
||
)
|
||
return int(total_chars / 2.5)
|
||
def rebuild_sidebar(self):
|
||
"""完整重建侧边栏列表(含分隔线)"""
|
||
self.history_list.clear()
|
||
sessions = self.db.get_all_sessions()
|
||
|
||
starred = [s for s in sessions if s.get("is_starred")]
|
||
normal = [s for s in sessions if not s.get("is_starred")]
|
||
|
||
for sess in starred:
|
||
self.add_session_item_to_sidebar(sess)
|
||
|
||
# 有星标才加分隔线
|
||
if starred and normal:
|
||
sep_item = QtWidgets.QListWidgetItem()
|
||
sep_item.setData(DraggableHistoryList.SEPARATOR_ROLE, "separator")
|
||
sep_item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags)
|
||
sep_item.setSizeHint(QtCore.QSize(0, 20))
|
||
self.history_list.addItem(sep_item)
|
||
|
||
sep_widget = QtWidgets.QFrame()
|
||
sep_widget.setFixedHeight(1)
|
||
sep_widget.setStyleSheet("background-color: #e0e0e0; margin: 0 16px;")
|
||
self.history_list.setItemWidget(sep_item, sep_widget)
|
||
|
||
for sess in normal:
|
||
self.add_session_item_to_sidebar(sess)
|
||
|
||
# 恢复选中态
|
||
if self.current_session_id:
|
||
for i in range(self.history_list.count()):
|
||
it = self.history_list.item(i)
|
||
if it.data(QtCore.Qt.ItemDataRole.UserRole) == self.current_session_id:
|
||
self.history_list.setCurrentItem(it)
|
||
break
|
||
def show_session_context_menu(self, session_id, global_pos):
|
||
"""显示会话右键菜单"""
|
||
is_starred = self.db.is_session_starred(session_id)
|
||
popup = SessionContextPopup(session_id, is_starred, self)
|
||
popup.action_triggered.connect(self.on_session_action)
|
||
popup.show_at(global_pos)
|
||
self._ctx_popup = popup # 防 GC
|
||
|
||
def on_session_action(self, action, session_id):
|
||
"""处理菜单动作"""
|
||
if action == "edit":
|
||
self._rename_session(session_id)
|
||
elif action == "star":
|
||
is_starred = self.db.is_session_starred(session_id)
|
||
self.db.update_session_star(session_id, not is_starred)
|
||
self.rebuild_sidebar()
|
||
elif action == "copy":
|
||
self._copy_session(session_id)
|
||
elif action == "delete":
|
||
self._delete_session(session_id)
|
||
|
||
def _rename_session(self, session_id):
|
||
current_title = ""
|
||
for i in range(self.history_list.count()):
|
||
it = self.history_list.item(i)
|
||
if it.data(QtCore.Qt.ItemDataRole.UserRole) == session_id:
|
||
w = self.history_list.itemWidget(it)
|
||
if w and hasattr(w, 'title_label'):
|
||
current_title = w.title_label.text()
|
||
break
|
||
|
||
overlay = RenameOverlay(current_title, self.bg_widget)
|
||
overlay.renamed.connect(lambda new_title: self._apply_rename(session_id, new_title))
|
||
|
||
def _apply_rename(self, session_id, new_title):
|
||
self.db.update_session_title(session_id, new_title)
|
||
for i in range(self.history_list.count()):
|
||
it = self.history_list.item(i)
|
||
if it.data(QtCore.Qt.ItemDataRole.UserRole) == session_id:
|
||
w = self.history_list.itemWidget(it)
|
||
if w and hasattr(w, 'set_title'):
|
||
w.set_title(new_title)
|
||
break
|
||
print(f"[UI]: 会话已重命名 -> {new_title}")
|
||
|
||
def _copy_session(self, session_id):
|
||
"""📋 复制会话:DB 层深度克隆(全部支线 + 压缩标记 + 附件文件),完成后切到副本。"""
|
||
# 生成中不允许复制:副本只会拿到半截记录,且切走后原会话的流回调会失效
|
||
if session_id in self._active_streams:
|
||
try:
|
||
QtWidgets.QToolTip.showText(QtGui.QCursor.pos(),
|
||
"该会话正在生成中,请稍后再复制")
|
||
except Exception:
|
||
pass
|
||
print("[UI]: 复制被拒绝(会话正在生成中)")
|
||
return
|
||
|
||
try:
|
||
new_sess = self.db.copy_session(session_id)
|
||
except Exception as e:
|
||
print(f"[UI]: 复制会话失败 -> {e}")
|
||
return
|
||
if not new_sess:
|
||
print(f"[UI]: 复制失败,源会话不存在 -> {session_id}")
|
||
return
|
||
|
||
new_id = new_sess["id"]
|
||
print(f"[UI]: 会话已复制 -> {new_id} ({new_sess.get('title')})")
|
||
|
||
# 重建侧边栏(副本 sort_order 在顶部),选中并进入副本
|
||
self.rebuild_sidebar()
|
||
for i in range(self.history_list.count()):
|
||
it = self.history_list.item(i)
|
||
if it.data(QtCore.Qt.ItemDataRole.UserRole) == new_id:
|
||
self.history_list.setCurrentItem(it)
|
||
break
|
||
self.load_messages_to_web(new_id)
|
||
|
||
def _delete_session(self, session_id):
|
||
# P0 修复: 删除前必须停掉该会话的活动流,
|
||
# 否则后台 worker 会在删除后继续 append 到已不存在的会话。
|
||
stream = self._active_streams.get(session_id)
|
||
if stream and stream.get("worker"):
|
||
try:
|
||
w = stream["worker"]
|
||
w.abort()
|
||
w.wait(3000)
|
||
except Exception:
|
||
pass
|
||
self._active_streams.pop(session_id, None)
|
||
title_state = self._title_workers.get(session_id)
|
||
if title_state and title_state.get("worker"):
|
||
try:
|
||
title_state["worker"].cancel()
|
||
title_state["worker"].wait(2000)
|
||
except Exception:
|
||
pass
|
||
self._title_workers.pop(session_id, None)
|
||
|
||
# 1. 在数据库彻底删除前,先查出该会话下所有的图片元数据
|
||
with self.db.get_connection() as conn:
|
||
rows = conn.execute("SELECT attachment_metadata FROM messages WHERE session_id = ?", (session_id,)).fetchall()
|
||
metadata_list = [r['attachment_metadata'] for r in rows if r['attachment_metadata']]
|
||
|
||
# 2. 从数据库物理删除会话(SQLite 的 CASCADE 会自动删掉 messages 表的数据)
|
||
self.db.delete_session(session_id)
|
||
|
||
# 3. 🌟 启动物理粉碎机,清理这个会话产生的所有本地图片
|
||
if metadata_list:
|
||
self._cleanup_physical_files(metadata_list)
|
||
|
||
# 从列表移除
|
||
for i in range(self.history_list.count()):
|
||
it = self.history_list.item(i)
|
||
if it.data(QtCore.Qt.ItemDataRole.UserRole) == session_id:
|
||
self.history_list.takeItem(i)
|
||
break
|
||
|
||
# 如果删的是当前会话,切到第一个可用的
|
||
if session_id == self.current_session_id:
|
||
for i in range(self.history_list.count()):
|
||
it = self.history_list.item(i)
|
||
if not self.history_list._is_separator(it):
|
||
sid = it.data(QtCore.Qt.ItemDataRole.UserRole)
|
||
if sid:
|
||
self.history_list.setCurrentItem(it)
|
||
self.load_messages_to_web(sid)
|
||
return
|
||
# 没有任何会话了,新建一个
|
||
self.on_new_chat_clicked()
|
||
|
||
# 检查分隔线是否还需要
|
||
self.rebuild_sidebar()
|
||
def _create_session_widget(self, session_id):
|
||
"""为指定 session_id 创建 SessionItemWidget(供拖拽重排后重建用)"""
|
||
# 从 DB 拿标题
|
||
sessions = self.db.get_all_sessions()
|
||
title = "新对话"
|
||
for s in sessions:
|
||
if s["id"] == session_id:
|
||
title = s["title"]
|
||
break
|
||
widget = SessionItemWidget(session_id, title)
|
||
widget.menu_requested.connect(self.show_session_context_menu)
|
||
return widget
|
||
|
||
def show_settings(self):
|
||
if hasattr(self, '_settings_win') and self._settings_win is not None:
|
||
self._settings_win.deleteLater()
|
||
self._settings_win = None
|
||
|
||
self._settings_win = SettingsWindow(
|
||
parent=self,
|
||
config_data=self.config_data
|
||
)
|
||
self._settings_win.show_with_animation()
|
||
def _on_branch_switch(self, msg_id, direction):
|
||
"""🌟 核心:处理用户点击左右箭头切换分支"""
|
||
# 如果当前有正在生成的消息,禁止乱跳时空,以免导致数据冲突
|
||
if self.current_session_id in self._active_streams:
|
||
return
|
||
|
||
with self.db.get_connection() as conn:
|
||
# 1. 查出这兄弟的父亲是谁
|
||
msg = conn.execute("SELECT parent_id FROM messages WHERE id = ?", (msg_id,)).fetchone()
|
||
if not msg or not msg['parent_id']:
|
||
return
|
||
|
||
parent_id = msg['parent_id']
|
||
|
||
# 2. 查出这父亲下面的所有子分支,按时间正序排
|
||
siblings = conn.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at ASC", (parent_id,)).fetchall()
|
||
sibling_ids = [s[0] for s in siblings]
|
||
|
||
if len(sibling_ids) <= 1:
|
||
return
|
||
|
||
try:
|
||
current_idx = sibling_ids.index(msg_id)
|
||
except ValueError:
|
||
return
|
||
|
||
# 3. 计算目标分支索引,做边界保护
|
||
target_idx = current_idx + direction
|
||
if target_idx < 0 or target_idx >= len(sibling_ids):
|
||
return
|
||
|
||
target_msg_id = sibling_ids[target_idx]
|
||
|
||
# 4. 🚀 关键魔法:找到这个目标分支一直延续下去的最末端叶子节点!
|
||
target_leaf_id = self.db.get_branch_leaf(target_msg_id)
|
||
|
||
# 5. 更新会话,直接扭转时间线指针
|
||
self.db.update_session_leaf(self.current_session_id, target_leaf_id)
|
||
|
||
# 6. 重新渲染!暴力清除原页面,拉取新时间线,前端毫无闪烁,干脆利落
|
||
self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 分支切换不触发加载层
|
||
def _on_delete_message(self, msg_id):
|
||
"""处理剪枝请求"""
|
||
# 防止用户在生成文本时手滑点击删除,引发并发灾难
|
||
if self.current_session_id in self._active_streams:
|
||
print("[UI]: 正在生成中,拒绝截断时间线")
|
||
return
|
||
|
||
print(f"[UI]: 执行时空剪枝,原点 -> {msg_id}")
|
||
|
||
# 1. 执行底层物理删除,并拿到被删掉的消息的附件元数据
|
||
deleted_metadata = self.db.delete_message_branch(self.current_session_id, msg_id)
|
||
|
||
# 2. 🌟 启动物理粉碎机,清理硬盘上的图片缓存
|
||
if deleted_metadata:
|
||
self._cleanup_physical_files(deleted_metadata)
|
||
|
||
# 3. 暴力美学:瞬间重灌整个页面的数据,坏死的树枝自动消失!
|
||
self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 删除消息不触发加载层
|
||
|
||
def _cleanup_physical_files(self, metadata_list):
|
||
"""🌟 物理粉碎机:传入元数据列表,自动清理硬盘上的图片"""
|
||
import json
|
||
import os
|
||
|
||
# 定位到项目根目录
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
|
||
|
||
for meta_str in metadata_list:
|
||
if not meta_str:
|
||
continue
|
||
try:
|
||
meta = json.loads(meta_str)
|
||
for att in meta.get("attachments", []):
|
||
att_type = att.get("type")
|
||
# 图片 / PDF 本体缓存
|
||
if att_type in ("image", "pdf") and att.get("local_path"):
|
||
abs_path = os.path.join(root_dir, att["local_path"])
|
||
if os.path.exists(abs_path):
|
||
os.remove(abs_path)
|
||
print(f"[System]: 随聊天记录清理关联附件 -> {att['local_path']}")
|
||
# PDF 图片解析模式提取出的图片
|
||
if att_type == "pdf":
|
||
for im in att.get("images", []):
|
||
im_abs = im.get("abs_path")
|
||
if not im_abs and im.get("local_path"):
|
||
im_abs = os.path.join(root_dir, im["local_path"])
|
||
if im_abs and os.path.exists(im_abs):
|
||
os.remove(im_abs)
|
||
print(f"[System]: 随聊天记录清理 PDF 提取图片 -> {im_abs}")
|
||
except Exception as e:
|
||
print(f"[Warn]: 解析/清理历史附件失败: {e}")
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = QtWidgets.QApplication(sys.argv)
|
||
window = MainWindow()
|
||
window.show()
|
||
sys.exit(app.exec())
|