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 # 🌟 新增:引入桥接器 # 🆕 WebView2 后端(A′ 双轨:Windows 首选,任何失败自动回落 QtWebEngine) try: from core import webview2 as _wv2mod except Exception as _wv2_imp_err: # pythonnet 缺失 / 非 Windows 等 _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 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): """无边框重命名 — 主窗口内模态覆盖层""" renamed = QtCore.pyqtSignal(str) # 发射新标题 def __init__(self, current_title, parent=None): super().__init__(parent) # 覆盖整个主窗口 self.setGeometry(parent.rect()) self.setStyleSheet("background: transparent;") # 半透明灰色遮罩 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; } """) self._center_form() 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.show() self.input.setFocus() self._animate_in() 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): self._opacity_effect = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self._opacity_effect) self._anim = QtCore.QPropertyAnimation(self._opacity_effect, b"opacity") 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() # ---- 表单拖拽(限制在主窗口内)---- 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 # ---- 主窗口 resize 时跟随 ---- def resizeEvent(self, event): self.setGeometry(self.parentWidget().rect()) self._center_form() def confirm(self): title = self.input.text().strip() if title: self._result = title self.renamed.emit(title) self.close_overlay() def close_overlay(self): 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) self._wv2_session = None if _wv2mod is not None and sys.platform == "win32": _backend_pref = "auto" try: _root_cfg = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) with open(os.path.join(_root_cfg, "data", "config.json"), "r", encoding="utf-8") as _f: _backend_pref = json.load(_f).get("webview_backend", "auto") except Exception: pass if _backend_pref in ("auto", "webview2"): _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 路径(原逻辑,保持可用) ---- self.browser = QWebEngineView() # 🌟 关键修改:使用自定义的 WebPage custom_page = CustomWebPage(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): """初始化浏览器设置、信号绑定及页面加载""" # 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) # 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; } #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) # ==================== 事件拦截 ==================== def eventFilter(self, obj, event): 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 else: # 🌟 修改:只有在非生成状态下,按回车才会发送;生成时按回车无效,防止误触 # 回车发送检查改为: if self.current_session_id not in self._active_streams: self.send_message() 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) # Alt+S 全局热键:独立线程注册系统级热键,窗口失焦也能触发 self.hotkey_screenshot = GlobalHotkeyThread() self.hotkey_screenshot.triggered.connect(self._start_screenshot) self.hotkey_screenshot.start() # 非 Windows 平台回退到应用级快捷键 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)} 条)") # ==================== 🌟 核心新增:键盘事件拦截 (Enter 发送) ==================== def eventFilter(self, obj, event): """全局事件过滤器""" if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress: # 捕获回车键 (主键盘 Enter 或 数字键盘 Enter) if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): # 如果按住了 Shift 键,则放行 (实现换行) if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier: return False else: # 如果没按 Shift,则拦截该事件,并触发发送逻辑 # 注意:只有在发送按钮处于激活状态时才允许发送 if self.btn_send.isEnabled(): self.send_message() return True # 告诉 PyQt: 这个事件我处理过了,不要再往输入框里加回车符了 # 其他事件交给父类正常处理 return super().eventFilter(obj, event) # ==================== 🌟 核心新增:长文本折叠检测 ==================== # ==================== 🌟 核心升级:统一附件 UI 与本地存储处理 ==================== 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): """启动截图覆盖层""" # 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发) self._screenshot_from_background = not self.isActiveWindow() self.screenshot_overlay.start() 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 ) 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): import time import uuid import json 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) 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 # 🚀 传给前端 ) 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) 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 load_messages_to_web(self, session_id, show_loading=True): import json 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() self.chat_bridge.clear_chat() # 1. 获取当前时间线的消息链 # 🚀 调用链表查询方法:永远只渲染当前选中的时间线分支 messages = self.db.get_message_chain(session_id) visible_messages = [msg for msg in messages if msg["role"] not in ("system", "compaction")] # 2. 处理空对话状态 if not visible_messages: 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. 遍历渲染消息 for msg in visible_messages: branch_info = None # 🌟 核心逻辑:为 AI 消息计算多分支切换信息 if msg["role"] == "assistant" and msg.get("parent_id"): # 获取该父节点下的所有兄弟回复 siblings = self.db.get_branch_info(msg["parent_id"]) if len(siblings) > 1: try: # 找到当前消息在所有兄弟分支里的排序 (1-based index) 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 # 4. 渲染逻辑分发 # 情况 A: 带有附件的用户消息 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", []) # 🌟 核心修复:清洗历史脏数据,强制补齐 content 字段,彻底绝杀 JS 崩溃! # 🌟 核心修复:清洗历史脏数据,强制补齐所有前端需要的字段! for att in attachments: # 1. 补齐 content 防崩溃 if "content" not in att: if att.get("type") == "image": att["content"] = f"[图片文件: {att.get('name', '未知图片')}]" else: att["content"] = "[附件内容]" # 2. 补齐 size_kb 和 lines 防 undefined 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 ) # 情况 B: 普通消息或 AI 消息 else: tl_json = msg.get("timeline") if msg["role"] == "assistant" else None if tl_json: # 🌟 带 agent 时间线的 AI 消息:按序渲染 思考/文本/工具 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 ) # 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 # 🚀 传给前端 ) # 🌟 有 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. 读取配置 config_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'config.json') try: with open(config_path, 'r', encoding='utf-8') as f: self.config_data = json.load(f) except Exception as e: print(f"加载配置失败: {e}") self.config_data = {"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 引擎已就绪,正在加载历史记录...") 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): 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())