Files
Haocode/ui/views/main_window.py

6600 lines
306 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import copy
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
def _stream_diagnostics_enabled() -> bool:
"""仅在显式要求时开启高开销流式诊断。"""
return (os.environ.get("HAOCODE_STREAM_DIAG") == "1"
or os.environ.get("HAOCODE_SHOT") == "1")
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):
if not _stream_diagnostics_enabled():
return
try:
t = _diag_time.time()
with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"[{_diag_time.strftime('%H:%M:%S', _diag_time.localtime(t))}."
f"{int(t * 1000) % 1000:03d}] {line}\n")
except Exception:
pass
import uuid # <--- 🌟 加上这一行!
from ui.views.chat_bridge import ChatBridge # 🌟 新增:引入桥接器
from core import renderer_backend as _renderer_backend # P1-03 渲染器矩阵(纯 stdlib
# 🆕 WebView2 后端(A 双轨:Windows 首选,任何失败自动回落 QtWebEngine
# P1-03:非 Windows 永不导入 core.webview2(不触达 pythonnet/Win32 API/WebView2 DLL/taskkill
_wv2mod = None
if sys.platform == "win32":
try:
from core import webview2 as _wv2mod
except Exception as _wv2_imp_err: # pythonnet 缺失等
_wv2mod = None
print(f"[WV2] 后端不可用(回落 QtWebEngine: {_wv2_imp_err}")
from ui.views.system_tools.screen_capture import ScreenCaptureOverlay # 🌟 截图覆盖层
from ui.views.system_tools.global_hotkey import GlobalHotkeyThread # 🌟 全局热键监听
from ui.views.system_tools import desktop_session # P1-04 平台会话/能力路由(纯 stdlib)
from core.db_manager import DBManager
from core.debug_log import debug_log, poll_debug_cmd # 🆕 调试窗口文件协议(独立于项目树)
from ui.views.system_tools.file_reader import read_text_file, BINARY_EXTS # 🌟 文本/代码文件读取(编码探测+二进制探测)
from ui.views.bash_panel import BashPanel # 🆕 右侧任务面板(bash 任务监控)
from tools.builtin_tools.pdf_reader import extract_pdf_text, extract_pdf_images # 🌟 PDF 工具(文本+图片解析)
from PyQt6 import QtWidgets, QtCore, QtGui
# -*- coding: utf-8 -*-
"""
设置窗口 (SettingsWindow) - 完整改进版
修复了:
1. 右侧按钮卡边问题(优化内边距)
2. 居中虚化遮罩(固定卡片尺寸 + 全屏半透明背景)
3. 模型垂直排列(改用 QVBoxLayout
"""
class AttachmentPreviewOverlay(QtWidgets.QWidget):
"""🌟 原生附件预览覆盖层 (全屏半透明遮罩 + 居中卡片 + 现代UI + 一键复制)
🆕 必须是无边框顶层窗口(独立 HWND):WebView2 是原生子窗,永远绘制在主窗口
内所有 Qt 控件之上 —— 若预览层是 bg_widget 的子控件,遮罩盖不住 webview
(webview 显得特别亮),居中的卡片会被压到 webview 下面。独立顶层窗口
+ WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗。
"""
def __init__(self, meta_dict, parent=None, siblings=None, index=0, att_data=None):
super().__init__(parent)
self.meta = meta_dict
self._siblings = siblings if siblings else [meta_dict] # 同组可翻页图片(单图时即自身)
self._index = index
self._att_data = att_data # PDF 附件字典(提供时显示“选择”按钮并回写 selected_images)
# 🆕 无边框顶层 Tool 窗(不入任务栏,Windows 上默认浮在父窗之上)
self._main = parent.window() if parent is not None else None
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
if self._main is not None:
# 完全覆盖主窗口(含边框);移动/大小变化由 eventFilter 跟随
self.setGeometry(self._main.frameGeometry())
self._main.installEventFilter(self)
# 灰色半透明遮罩
self._overlay_color = QtGui.QColor(0, 0, 0, 100)
# 主卡片
self.card = QtWidgets.QFrame(self)
self.card.setObjectName("preview_card")
self.card.setStyleSheet("""
#preview_card { background: #ffffff; border-radius: 12px; border: 1px solid #dcdcdc; }
""")
lay = QtWidgets.QVBoxLayout(self.card)
lay.setContentsMargins(20, 16, 20, 20)
lay.setSpacing(12)
# 🌟 统一的现代滚动条样式
modern_scrollbar_qss = """
QScrollBar:vertical {
border: none; background: transparent; width: 8px; margin: 0px;
}
QScrollBar::handle:vertical {
background: #d0d0d0; min-height: 40px; border-radius: 4px;
}
QScrollBar::handle:vertical:hover {
background: #a0a0a0;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px; border: none; background: transparent;
}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: transparent;
}
QScrollBar:horizontal {
border: none; background: transparent; height: 8px; margin: 0px;
}
QScrollBar::handle:horizontal {
background: #d0d0d0; min-width: 40px; border-radius: 4px;
}
QScrollBar::handle:horizontal:hover {
background: #a0a0a0;
}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
width: 0px; border: none; background: transparent;
}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
background: transparent;
}
"""
# 标题栏
title_lay = QtWidgets.QHBoxLayout()
title_label = QtWidgets.QLabel(self.meta.get("name", "文本内容"))
self._title_label = title_label
title_label.setStyleSheet("""
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
font-size: 16px; font-weight: bold; color: #222; background: transparent;
""")
title_lay.addWidget(title_label)
title_lay.addStretch()
# 🌟 核心新增:如果是长文本,添加“复制”按钮
if self.meta.get("type") != "image":
self.btn_copy = QtWidgets.QPushButton("复制")
self.btn_copy.setFixedSize(64, 28)
self.btn_copy.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self._set_copy_btn_style(self.btn_copy, False) # 设置初始样式
self.btn_copy.clicked.connect(self._on_copy_clicked)
title_lay.addWidget(self.btn_copy)
title_lay.addSpacing(8) # 复制按钮和关闭按钮之间留点空隙
# PDF 解析图:添加“选择”按钮(选中后绿色边框,行为对标复制按钮)
if self.meta.get("type") == "image" and self._att_data is not None:
self.btn_select = QtWidgets.QPushButton("选择")
self.btn_select.setFixedSize(64, 28)
self.btn_select.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self._set_select_btn_style(self._is_current_selected())
self.btn_select.clicked.connect(self._on_select_clicked)
title_lay.addWidget(self.btn_select)
title_lay.addSpacing(8) # 选择按钮和关闭按钮之间留点空隙
btn_x = QtWidgets.QPushButton("✕")
btn_x.setFixedSize(28, 28)
btn_x.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
btn_x.setStyleSheet("""
QPushButton {
font-family: "Microsoft YaHei UI", sans-serif;
background: transparent; border: none; border-radius: 14px; color: #999; font-size: 15px;
}
QPushButton:hover { background: #eee; color: #333; }
""")
btn_x.clicked.connect(self.close_overlay)
title_lay.addWidget(btn_x)
lay.addLayout(title_lay)
# 内容区 (图片或文本)
if self.meta.get("type") == "image":
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
abs_path = os.path.join(root_dir, self.meta.get("local_path", ""))
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet(f"""
QScrollArea {{ border: 1px solid #eee; border-radius: 8px; background: #f9f9f9; }}
{modern_scrollbar_qss}
""")
img_label = QtWidgets.QLabel()
img_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
img_label.setStyleSheet("background: transparent;")
self._img_label = img_label # 翻页时复用它来切换图片
if os.path.exists(abs_path):
pixmap = QtGui.QPixmap(abs_path)
if pixmap.width() > 800 or pixmap.height() > 600:
pixmap = pixmap.scaled(800, 600, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation)
img_label.setPixmap(pixmap)
else:
img_label.setText("❌ 图片文件已丢失或被物理删除")
img_label.setStyleSheet('font-family: "Microsoft YaHei UI", sans-serif; color: red; font-size: 14px;')
scroll.setWidget(img_label)
lay.addWidget(scroll)
self.card.setFixedSize(860, 680)
else:
text_edit = QtWidgets.QTextEdit()
text_edit.setReadOnly(True)
text_edit.setPlainText(self.meta.get("content", ""))
text_edit.setStyleSheet(f"""
QTextEdit {{
border: 1px solid #eee; border-radius: 8px; padding: 12px;
background: #fafafa; color: #333;
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
font-size: 14px; line-height: 1.5;
}}
{modern_scrollbar_qss}
""")
lay.addWidget(text_edit)
self.card.setFixedSize(600, 500)
self._center_card()
# 多张图片(如 PDF 解析图)时,叠加左右翻页按钮 + 页码计数
if self.meta.get("type") == "image" and len(self._siblings) > 1:
self._add_nav_buttons()
self.show()
self.raise_()
self.activateWindow()
self._animate_in()
# ==================== 🌟 复制功能相关方法 ====================
def _on_copy_clicked(self):
"""一键复制到剪贴板,并触发按钮变色动画"""
text = self.meta.get("content", "")
if text:
# 写入系统剪贴板
QtWidgets.QApplication.clipboard().setText(text)
# 按钮变绿反馈
self.btn_copy.setText("已复制")
self._set_copy_btn_style(self.btn_copy, is_copied=True)
# 1.5秒后恢复原状
QtCore.QTimer.singleShot(1500, lambda: self._restore_copy_btn())
def _restore_copy_btn(self):
"""恢复复制按钮状态"""
try:
self.btn_copy.setText("复制")
self._set_copy_btn_style(self.btn_copy, is_copied=False)
except RuntimeError:
pass # 防止窗口已经关闭导致报错
def _set_copy_btn_style(self, btn, is_copied):
"""统一管理复制按钮的 QSS 样式"""
if is_copied:
# 成功状态:浅绿色背景,深绿色文字
btn.setStyleSheet("""
QPushButton {
font-family: "Microsoft YaHei UI", sans-serif;
background: #e6f4ea; border: none; border-radius: 6px; color: #1e8e3e; font-size: 13px; font-weight: bold;
}
""")
else:
# 默认状态:浅灰色背景,深灰色文字
btn.setStyleSheet("""
QPushButton {
font-family: "Microsoft YaHei UI", sans-serif;
background: #f0f0f0; border: none; border-radius: 6px; color: #555; font-size: 13px;
}
QPushButton:hover { background: #e0e0e0; color: #333; }
""")
# =========================================================
# ==================== 🌟 图片选择功能相关方法(PDF 解析图) ====================
def _current_image_dict(self):
"""当前预览图对应的完整 image dict(用于写入 selected_images)。"""
if not (self._att_data and self._att_data.get("images")):
return None
lp = self._siblings[self._index].get("local_path")
return next((x for x in self._att_data["images"] if x.get("local_path") == lp), None)
def _is_current_selected(self):
"""当前预览图是否已在 selected_images 中。"""
if not self._att_data:
return False
lp = self._siblings[self._index].get("local_path")
return any(s.get("local_path") == lp for s in self._att_data.get("selected_images", []))
def _on_select_clicked(self):
"""切换当前图的选中态,并回写 att_data["selected_images"](重开弹窗勾选仍在)。"""
im = self._current_image_dict()
if im is None:
return
sel = self._att_data.setdefault("selected_images", [])
lp = im["local_path"]
if any(s.get("local_path") == lp for s in sel):
self._att_data["selected_images"] = [s for s in sel if s.get("local_path") != lp]
selected = False
else:
sel.append(im)
selected = True
self._set_select_btn_style(selected)
def _set_select_btn_style(self, selected):
"""统一管理选择按钮的 QSS 样式:选中=绿色边框,未选=灰底。"""
if selected:
self.btn_select.setText("已选择")
self.btn_select.setStyleSheet("""
QPushButton {
font-family: "Microsoft YaHei UI", sans-serif;
background: #e6f4ea; border: 1px solid #1e8e3e; border-radius: 6px;
color: #1e8e3e; font-size: 13px; font-weight: bold;
}
""")
else:
self.btn_select.setText("选择")
self.btn_select.setStyleSheet("""
QPushButton {
font-family: "Microsoft YaHei UI", sans-serif;
background: #f0f0f0; border: 1px solid transparent; border-radius: 6px;
color: #555; font-size: 13px;
}
QPushButton:hover { background: #e0e0e0; color: #333; }
""")
# =========================================================
def _center_card(self):
x = (self.width() - self.card.width()) // 2
y = (self.height() - self.card.height()) // 2
self.card.move(x, y)
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.fillRect(self.rect(), self._overlay_color)
painter.end()
def mousePressEvent(self, event):
if not self.card.geometry().contains(event.pos()):
self.close_overlay()
def resizeEvent(self, event):
self._center_card()
def eventFilter(self, obj, ev):
"""跟随主窗口移动/大小变化;主窗口隐藏(最小化)时自动关闭预览。"""
if self._main is not None and obj is self._main:
t = ev.type()
if t == QtCore.QEvent.Type.Move:
self.move(self._main.frameGeometry().topLeft())
elif t == QtCore.QEvent.Type.Resize:
self.setGeometry(self._main.frameGeometry())
self._center_card()
elif t in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
self.close_overlay()
return super().eventFilter(obj, ev)
def _animate_in(self):
# 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠)
self.setWindowOpacity(0.0)
self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(150)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.start()
def close_overlay(self):
self.deleteLater()
# ==================== 🌟 图片翻页功能(PDF 解析图等多图场景) ====================
def _load_image(self, local_path):
"""按相对路径加载图片到 self._img_label,超出 800x600 则等比缩放。"""
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
abs_path = os.path.join(root_dir, local_path)
if os.path.exists(abs_path):
pixmap = QtGui.QPixmap(abs_path)
if pixmap.width() > 800 or pixmap.height() > 600:
pixmap = pixmap.scaled(800, 600, QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.SmoothTransformation)
self._img_label.setStyleSheet("background: transparent;")
self._img_label.setPixmap(pixmap)
else:
self._img_label.clear()
self._img_label.setText("❌ 图片文件已丢失或被物理删除")
self._img_label.setStyleSheet(
'font-family: "Microsoft YaHei UI", sans-serif; color: red; font-size: 14px;')
def _add_nav_buttons(self):
"""在卡片左右两侧叠加圆形翻页按钮(页码信息改由顶部标题展示)。"""
nav_qss = """
QPushButton {
background: rgba(0,0,0,0.35); border: none; border-radius: 20px;
color: #ffffff; font-size: 24px; font-weight: bold;
}
QPushButton:hover { background: rgba(0,0,0,0.55); }
"""
self.btn_prev = QtWidgets.QPushButton("", self.card)
self.btn_next = QtWidgets.QPushButton("", self.card)
for b in (self.btn_prev, self.btn_next):
b.setFixedSize(40, 40)
b.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
b.setStyleSheet(nav_qss)
b.raise_()
self.btn_prev.clicked.connect(self._nav_prev)
self.btn_next.clicked.connect(self._nav_next)
cy = self.card.height() // 2 - 20
self.btn_prev.move(44, cy) # 较初版向内收 30px
self.btn_next.move(self.card.width() - 84, cy) # 较初版向内收 30px(与左侧对称)
def _show_sibling(self):
meta = self._siblings[self._index]
self._title_label.setText(meta.get("name", ""))
self._load_image(meta.get("local_path", ""))
if getattr(self, "btn_select", None) is not None: # 翻页后刷新“选择”按钮状态
self._set_select_btn_style(self._is_current_selected())
def _nav_prev(self):
self._index = (self._index - 1) % len(self._siblings)
self._show_sibling()
def _nav_next(self):
self._index = (self._index + 1) % len(self._siblings)
self._show_sibling()
# =====================================================================
class SettingsWindow(QtWidgets.QWidget):
"""设置窗口:可覆盖原生 WebView2 的顶层遮罩。"""
close_requested = QtCore.pyqtSignal()
config_changed = QtCore.pyqtSignal(dict)
model_selected = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None, config_data=None):
super().__init__(parent)
self._main = parent.window() if parent is not None else None
self.config_data = copy.deepcopy(config_data) if isinstance(config_data, dict) else {}
self._closed = False
self._closing = False
self._setup_ui()
self._setup_animation()
# ------------------------------------------------------------------
def _setup_ui(self):
# 全屏透明遮罩(只负责背景展示,不负责关闭)
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.overlay = 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.provider_page = self._build_provider_section()
self.content_stack.addWidget(self.provider_page)
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))
if self._main is not None:
self._main.installEventFilter(self)
# ------------------------------------------------------------------
def eventFilter(self, obj, event):
"""阻止卡片点击穿透,并跟随主窗口几何/生命周期。"""
if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress:
event.ignore() # 阻止冒泡
return True
if self._main is not None and obj is self._main:
event_type = event.type()
if event_type in (
QtCore.QEvent.Type.Move,
QtCore.QEvent.Type.Resize,
QtCore.QEvent.Type.WindowStateChange):
self.resize_to_parent()
elif event_type in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
self._close(immediate=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; }"
)
add_btn.setObjectName("settings_add_provider")
add_btn.clicked.connect(self._prompt_new_provider)
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 isinstance(providers, dict):
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:
config = config if isinstance(config, dict) else {}
card = QtWidgets.QWidget()
card.setProperty("providerName", name)
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; }"
)
edit_btn.setProperty("providerName", name)
edit_btn.clicked.connect(
lambda checked=False, provider=name: self._prompt_edit_provider(provider))
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; }"
)
add_btn.setObjectName("settings_add_model")
add_btn.setProperty("providerName", name)
add_btn.clicked.connect(
lambda checked=False, provider=name: self._prompt_add_model(provider))
row1.addWidget(add_btn)
layout.addLayout(row1)
# 🌟 第二行:模型垂直排列,加左缩进,不超出边框
models = self._model_names(config)
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(str(model_name))
mbtn.setProperty("providerName", name)
mbtn.setProperty("modelName", 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=False, provider=name, model=model_name:
self._select_model(provider, model)
)
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
@staticmethod
def _model_names(config: dict) -> list:
"""兼容字符串、对象列表、映射及旧 model_contexts 配置。"""
if not isinstance(config, dict):
return []
raw = config.get("models", [])
if isinstance(raw, str):
raw = [raw]
elif isinstance(raw, dict):
raw = list(raw.keys())
elif not isinstance(raw, (list, tuple)):
raw = []
names = []
for entry in raw:
if isinstance(entry, str):
name = entry.strip()
elif isinstance(entry, dict):
name = next((str(entry.get(key, "")).strip()
for key in ("id", "name", "model", "value")
if str(entry.get(key, "")).strip()), "")
else:
name = ""
if name and name not in names:
names.append(name)
for key in ("model_contexts", "model_max_tokens"):
mapping = config.get(key, {})
if isinstance(mapping, dict):
for name in mapping:
name = str(name).strip()
if name and name not in names:
names.append(name)
return names
def _refresh_provider_section(self):
"""用当前内存配置重建供应商页,避免旧控件残留。"""
old_page = self.provider_page
index = self.content_stack.indexOf(old_page)
new_page = self._build_provider_section()
self.content_stack.insertWidget(max(0, index), new_page)
self.provider_page = new_page
self.content_stack.setCurrentWidget(new_page)
self.content_stack.removeWidget(old_page)
old_page.deleteLater()
def _commit_config(self, candidate: dict) -> bool:
"""先原子落盘,成功后再更新界面和主窗口。"""
from core.config_paths import save_config
if not save_config(candidate):
return False
self.config_data = copy.deepcopy(candidate)
self._refresh_provider_section()
self.config_changed.emit(copy.deepcopy(self.config_data))
return True
def _create_provider(self, name: str, base_url: str = "", api_key: str = "") -> bool:
"""创建供应商;供按钮路径与自动化测试共用。"""
provider_name = str(name).strip()
if not provider_name:
return False
candidate = copy.deepcopy(self.config_data)
providers = candidate.get("providers")
if not isinstance(providers, dict):
providers = {}
candidate["providers"] = providers
if provider_name in providers:
return False
providers[provider_name] = {
"api_key": str(api_key).strip(),
"base_url": str(base_url).strip(),
"models": [],
"model_contexts": {},
}
return self._commit_config(candidate)
def _update_provider(self, name: str, base_url: str, api_key: str) -> bool:
candidate = copy.deepcopy(self.config_data)
providers = candidate.get("providers")
info = providers.get(name) if isinstance(providers, dict) else None
if not isinstance(info, dict):
return False
info["base_url"] = str(base_url).strip()
info["api_key"] = str(api_key).strip()
return self._commit_config(candidate)
def _add_model(self, provider: str, model: str) -> bool:
"""向供应商添加模型,并立即重建设置列表与主模型弹窗。"""
model_name = str(model).strip()
if not model_name:
return False
candidate = copy.deepcopy(self.config_data)
providers = candidate.get("providers")
info = providers.get(provider) if isinstance(providers, dict) else None
if not isinstance(info, dict) or model_name in self._model_names(info):
return False
raw = info.get("models")
if isinstance(raw, list):
raw.append(model_name)
elif isinstance(raw, dict):
raw[model_name] = {}
elif isinstance(raw, str) and raw.strip():
info["models"] = [raw.strip(), model_name]
else:
info["models"] = [model_name]
contexts = info.get("model_contexts")
if not isinstance(contexts, dict):
contexts = {}
info["model_contexts"] = contexts
contexts.setdefault(model_name, 128000)
if not candidate.get("default_provider") or not candidate.get("default_model"):
candidate["default_provider"] = provider
candidate["default_model"] = model_name
return self._commit_config(candidate)
def _select_model(self, provider: str, model: str):
providers = self.config_data.get("providers", {})
info = providers.get(provider) if isinstance(providers, dict) else None
if model not in self._model_names(info):
return
candidate = copy.deepcopy(self.config_data)
candidate["default_provider"] = provider
candidate["default_model"] = model
if self._commit_config(candidate):
self.model_selected.emit(provider, model)
def _provider_dialog(self, title: str, name: str = "", config=None, editing=False):
config = config if isinstance(config, dict) else {}
dialog = QtWidgets.QDialog(self)
dialog.setWindowTitle(title)
dialog.setModal(True)
dialog.setMinimumWidth(430)
layout = QtWidgets.QFormLayout(dialog)
layout.setContentsMargins(22, 18, 22, 18)
layout.setSpacing(12)
name_input = QtWidgets.QLineEdit(name)
name_input.setReadOnly(editing)
base_input = QtWidgets.QLineEdit(str(config.get("base_url", "")))
base_input.setPlaceholderText("https://example.com/v1")
key_input = QtWidgets.QLineEdit(str(config.get("api_key", "")))
key_input.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
layout.addRow("名称", name_input)
layout.addRow("Base URL", base_input)
layout.addRow("API Key", key_input)
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Save
| QtWidgets.QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addRow(buttons)
name_input.setFocus()
if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return None
return name_input.text().strip(), base_input.text().strip(), key_input.text().strip()
def _prompt_new_provider(self):
result = self._provider_dialog("新建模型提供商")
if result is None:
return
if not self._create_provider(*result):
QtWidgets.QMessageBox.warning(self, "无法创建", "提供商名称为空、已存在或配置保存失败。")
def _prompt_edit_provider(self, provider: str):
providers = self.config_data.get("providers", {})
info = providers.get(provider) if isinstance(providers, dict) else None
if not isinstance(info, dict):
return
result = self._provider_dialog(
"编辑模型提供商", provider, info, editing=True)
if result is None:
return
_, base_url, api_key = result
if not self._update_provider(provider, base_url, api_key):
QtWidgets.QMessageBox.warning(self, "保存失败", "未能保存提供商配置。")
def _prompt_add_model(self, provider: str):
model, accepted = QtWidgets.QInputDialog.getText(
self, "添加模型", f"{provider} 的模型名称:")
if accepted and not self._add_model(provider, model):
QtWidgets.QMessageBox.warning(self, "无法添加", "模型名称为空、已存在或配置保存失败。")
# ------------------------------------------------------------------
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):
if self._main is None:
return
# SettingsWindow 是顶层 Tool 窗口,geometry 必须使用屏幕坐标;
# 主窗口的 rect 则准确表示不含系统标题栏的客户区。
top_left = self._main.mapToGlobal(self._main.rect().topLeft())
self.setGeometry(QtCore.QRect(top_left, self._main.rect().size()))
self.overlay.setGeometry(self.rect())
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, b"windowOpacity")
self._anim.setDuration(160)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
# ------------------------------------------------------------------
def show_with_animation(self):
self.resize_to_parent()
self.setWindowOpacity(0.0)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward)
self.show()
self.raise_()
self.activateWindow()
self._anim.start()
# ------------------------------------------------------------------
def _close(self, immediate=False):
"""关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画"""
if self._closed:
return
if immediate:
self._do_close()
else:
if self._closing:
return
self._closing = True
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Backward)
try:
self._anim.finished.disconnect(self._on_close_finished)
except TypeError:
pass
self._anim.finished.connect(self._on_close_finished)
self._anim.start()
def _do_close(self):
"""直接关闭,不播放动画"""
if self._closed:
return
self._closed = True
try:
self._anim.finished.disconnect(self._on_close_finished)
except TypeError:
pass
if self._main is not None:
self._main.removeEventFilter(self)
self.hide()
parent = self.parent()
if parent is not None and getattr(parent, "_settings_win", None) is self:
parent._settings_win = None
self.deleteLater()
self.close_requested.emit()
# ------------------------------------------------------------------
def _on_close_finished(self):
self._do_close()
# ------------------------------------------------------------------
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(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
class RenameOverlay(QtWidgets.QWidget):
"""无边框重命名 — 独立顶层透明窗口(P2-03 修复)
🆕 必须是可覆盖原生 WebView2 的独立顶层窗(独立 HWND):WebView2 是原生子窗,
永远绘制在主窗口内所有 Qt 控件之上 —— 旧实现是 bg_widget 的子控件,遮罩盖不住
webview 区域(聊天区不暗)、卡片被压到 webview 下面。独立顶层 Tool 窗 +
WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗(同 AttachmentPreviewOverlay)。
只覆盖主窗口【客户区】:系统标题栏与窗口控制保持可操作。
主窗口 Move / Resize / WindowStateChange(最大化/还原,含多显示器/DPI 变化时
Qt 合成的 move+resize)通过 eventFilter + mapToGlobal 跟随。
行为保持:点空白关闭、Esc 关闭、✕ 关闭、输入框初始全选、Enter 提交、关闭后焦点回主窗口。
"""
renamed = QtCore.pyqtSignal(str) # 发射新标题
def __init__(self, current_title, parent=None):
super().__init__(parent)
self._main = parent.window() if parent is not None else None
# 无边框顶层 Tool 窗(不入任务栏;Windows 上作为拥有者窗口默认浮在父窗之上)
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
# 半透明灰色遮罩
self._overlay_color = QtGui.QColor(0, 0, 0, 80)
# ---- 居中表单 ----
self.form = QtWidgets.QFrame(self)
self.form.setFixedSize(380, 200)
self.form.setObjectName("rename_form")
self.form.setStyleSheet("""
#rename_form {
background: #ffffff;
border: 1px solid #dcdcdc;
border-radius: 12px;
}
""")
lay = QtWidgets.QVBoxLayout(self.form)
lay.setContentsMargins(24, 20, 24, 18)
lay.setSpacing(16)
# 标题行
title_lay = QtWidgets.QHBoxLayout()
title_label = QtWidgets.QLabel("编辑会话名称")
title_label.setStyleSheet("font-size: 15px; font-weight: bold; color: #222;")
title_lay.addWidget(title_label)
title_lay.addStretch()
btn_x = QtWidgets.QPushButton("✕")
btn_x.setFixedSize(24, 24)
btn_x.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
btn_x.setStyleSheet("""
QPushButton { background: transparent; border: none; border-radius: 12px; color: #999; font-size: 14px; }
QPushButton:hover { background: #eee; color: #333; }
""")
btn_x.clicked.connect(self.close_overlay)
title_lay.addWidget(btn_x)
lay.addLayout(title_lay)
# 输入框(加大内边距防文字被裁)
self.input = QtWidgets.QLineEdit(current_title)
self.input.selectAll()
self.input.setStyleSheet("""
QLineEdit {
border: 1.5px solid #e0e0e0; border-radius: 8px;
padding: 10px 14px; font-size: 14px; color: #222;
background: #fafafa;
}
QLineEdit:focus { border-color: #4a90d9; background: #fff; }
""")
self.input.returnPressed.connect(self.confirm)
lay.addWidget(self.input)
# 按钮行
btn_lay = QtWidgets.QHBoxLayout()
btn_lay.addStretch()
btn_cancel = QtWidgets.QPushButton("取消")
btn_cancel.setFixedSize(72, 32)
btn_cancel.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
btn_cancel.setStyleSheet("""
QPushButton { background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px; color: #666; font-size: 13px; }
QPushButton:hover { background: #eaeaea; }
""")
btn_cancel.clicked.connect(self.close_overlay)
btn_ok = QtWidgets.QPushButton("确定")
btn_ok.setFixedSize(72, 32)
btn_ok.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
btn_ok.setStyleSheet("""
QPushButton { background: #4a90d9; border: none; border-radius: 8px; color: white; font-size: 13px; font-weight: bold; }
QPushButton:hover { background: #3a7bc8; }
""")
btn_ok.clicked.connect(self.confirm)
btn_lay.addWidget(btn_cancel)
btn_lay.addSpacing(8)
btn_lay.addWidget(btn_ok)
lay.addLayout(btn_lay)
# 拖拽(卡片可拖动,限制在客户区内)
self._drag_pos = None
self.form.mousePressEvent = self._form_press
self.form.mouseMoveEvent = self._form_move
self.form.mouseReleaseEvent = self._form_release
self._result = None
self._closed = False
# 先同步几何(覆盖主窗口客户区)并挂事件过滤器,再显示
self._sync_geometry()
if self._main is not None:
self._main.installEventFilter(self)
self.show()
self.raise_()
self.activateWindow()
self.input.setFocus()
self._animate_in()
# ==================== 几何同步(客户区覆盖,标题栏/窗口控制可操作) ====================
def _client_top_left(self):
"""主窗口客户区左上角的【全局】坐标(mapToGlobal 已含多显示器/DPI 换算)。"""
return self._main.mapToGlobal(self._main.rect().topLeft())
def _sync_geometry(self):
if self._main is None:
return
self.setGeometry(QtCore.QRect(self._client_top_left(), self._main.rect().size()))
self._center_form()
def eventFilter(self, obj, ev):
"""跟随主窗口移动/大小变化/窗口状态(最大化、还原);主窗口关闭/隐藏时关闭遮罩。
DPI 变化时 Qt 对主窗口合成 move+resize 事件,此过滤器随之自动跟随。"""
if self._main is not None and obj is self._main:
t = ev.type()
if t == QtCore.QEvent.Type.Move:
self.move(self._client_top_left())
elif t in (QtCore.QEvent.Type.Resize, QtCore.QEvent.Type.WindowStateChange):
self._sync_geometry()
elif t in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
self.close_overlay()
return super().eventFilter(obj, ev)
# ==============================================================================
def _center_form(self):
x = (self.width() - self.form.width()) // 2
y = (self.height() - self.form.height()) // 2
self.form.move(x, y)
def _animate_in(self):
# 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠)
self.setWindowOpacity(0.0)
self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(150)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.start()
# ---- 绘制半透明遮罩 ----
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.fillRect(self.rect(), self._overlay_color)
painter.end()
# ---- 点击遮罩区域关闭 ----
def mousePressEvent(self, event):
if not self.form.geometry().contains(event.pos()):
self.close_overlay()
# ---- Esc 关闭 ----
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close_overlay()
return
super().keyPressEvent(event)
# ---- 表单拖拽(限制在主窗口客户区内)----
def _form_press(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton:
self._drag_pos = event.pos()
def _form_move(self, event):
if self._drag_pos:
new_pos = self.form.pos() + event.pos() - self._drag_pos
# 限制在覆盖层范围内
x = max(0, min(new_pos.x(), self.width() - self.form.width()))
y = max(0, min(new_pos.y(), self.height() - self.form.height()))
self.form.move(x, y)
def _form_release(self, event):
self._drag_pos = None
def confirm(self):
title = self.input.text().strip()
if title:
self._result = title
self.renamed.emit(title)
self.close_overlay()
def close_overlay(self):
"""关闭并释放:卸事件过滤器、焦点回主窗口、延迟删除(不残留遮罩/过滤器/焦点捕获)。"""
if self._closed:
return
self._closed = True
if self._main is not None:
self._main.removeEventFilter(self)
self._main.activateWindow()
self._main.raise_()
self.deleteLater()
def get_result(self):
return self._result
from PyQt6 import QtCore, QtGui, QtWidgets
def _popup_svg_path(filename: str) -> str:
"""🌟 弹窗类内部取 svg 图标绝对路径(与 MainWindow.get_svg_path 同一套定位逻辑,不依赖主窗口实例)"""
base_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.abspath(os.path.join(base_dir, "..", ".."))
return os.path.join(project_root, "svg", filename)
# 🆕 抽屉 v2:模型行淡入淡出 / 相邻行滑移 的 per-item 数据角色
_FADE_ROLE = QtCore.Qt.ItemDataRole.UserRole + 901 # 模型行不透明度 0..1None=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._empty_item = None
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;
}
""")
@staticmethod
def _model_names(info):
"""兼容字符串、对象列表和以模型名为键的映射,返回去重后的模型名。"""
if not isinstance(info, dict):
return []
raw_models = info.get("models", [])
if isinstance(raw_models, dict):
raw_models = list(raw_models.keys())
elif isinstance(raw_models, str):
raw_models = [raw_models]
elif not isinstance(raw_models, (list, tuple)):
raw_models = []
names = []
for raw in raw_models:
name = raw.strip() if isinstance(raw, str) else ""
if isinstance(raw, dict):
for key in ("id", "name", "model", "value"):
value = raw.get(key)
if isinstance(value, str) and value.strip():
name = value.strip()
break
if name and name not in names:
names.append(name)
# 某些旧配置只保存模型参数映射,没有显式 models 数组。
for key in ("model_contexts", "model_max_tokens"):
mapping = info.get(key)
if not isinstance(mapping, dict):
continue
for name in mapping:
if isinstance(name, str) and name.strip() and name.strip() not in names:
names.append(name.strip())
return names
def reload_config(self, config_data):
"""按最新配置重建列表;配置缺失时仍保留可理解、可绘制的空状态。"""
self._finish_drawer(preserve_top=True)
self.config_data = config_data if isinstance(config_data, dict) else {}
self.list_widget.clear()
self._groups.clear()
self._empty_item = None
self.populate_data()
self.adjust_popup_height()
def populate_data(self):
providers = self.config_data.get("providers", {})
if not isinstance(providers, dict):
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():
if not isinstance(provider_name, str) or not provider_name.strip():
continue
models = self._model_names(info)
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,
})
if self._groups:
return
default_provider = self.config_data.get("default_provider")
default_model = self.config_data.get("default_model")
if isinstance(default_provider, str) and default_provider.strip() \
and isinstance(default_model, str) and default_model.strip():
self.config_data = dict(self.config_data)
self.config_data["providers"] = {
default_provider.strip(): {"models": [default_model.strip()]}
}
self.populate_data()
return
self._empty_item = QtWidgets.QListWidgetItem("未配置可用模型")
self._empty_item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags)
self._empty_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._empty_item.setForeground(QtGui.QColor("#8a94a6"))
self._empty_item.setSizeHint(QtCore.QSize(0, 42))
self.list_widget.addItem(self._empty_item)
def mark_selected(self, provider, model):
"""打开弹窗时高亮当前模型,并确保滚动到可见位置。"""
for group in self._groups:
for item in group["model_items"]:
if item.data(QtCore.Qt.ItemDataRole.UserRole) == (provider, model):
self.list_widget.setCurrentItem(item)
self.list_widget.scrollToItem(
item, QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible)
return
self.list_widget.clearSelection()
# ==================== 🆕 抽屉 v2fade + 相邻供应商行滑移 ====================
# 收起: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):
"""点击供应商头后立即应用稳定终态,避免列表子控件动画产生残影。"""
g = None
for gg in self._groups:
if gg["provider"] == provider_name:
g = gg
break
if g is None:
return
# 上一个抽屉动画进行中 → 先立即收敛到终态(允许快速连点切换)
# 折叠/展开只改变高度时锁定当前顶部;否则按底边贴按钮会把
# 用户正在看的内容整体向下挤。
self._finish_drawer(preserve_top=True)
g["expanded"] = not g["expanded"]
# SVG 箭头切换(展开=朝下 / 收起=朝右)
g["chevron"].setPixmap(
self._arrow_collapsed if not g["expanded"] else self._arrow_expanded)
self._clear_fx()
self._apply_group_state(g)
self.list_widget.doItemsLayout()
self._apply_height_and_position(preserve_top=True)
self.list_widget.viewport().update()
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 roledelegate 绘制);
供应商头→_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, preserve_top=False):
"""抽屉动画收敛到终态(动画完成 / 连点打断共用)"""
d = self._drawer
if d is None:
return
top_anchor = self.y() if preserve_top and self.isVisible() else None
d["timer"].stop()
# 每次动画都会创建一个 timer;停用后立即释放,避免反复折叠后
# 大量已停止的 QObject 留在弹窗树中。
d["timer"].deleteLater()
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 top_anchor is not None:
self.move(self.x(), top_anchor)
elif 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, preserve_top=False):
"""重算高度;折叠时可锁定顶部,避免当前列表位置被挤走。"""
top_anchor = self.y() if preserve_top and self.isVisible() else None
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 = top_anchor if top_anchor is not None else 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(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
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(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
# ==================== 🌟 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_datamode / 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():构造期弹窗未 showisVisible() 恒为 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(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
# 关闭方式:依赖 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._stream_diagnostics = _stream_diagnostics_enabled()
self._diag_chunk_n = 0
self._diag_think_n = 0
if self._stream_diagnostics:
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()
# 人肉 debugJS console 桥抽取器(JS console.log → Python 控制台)。
# 这是诊断通道,不应在普通运行中每 500ms 跨进程执行一次 JS。
self._jslog_timer = None
if self._stream_diagnostics:
self._jslog_timer = QtCore.QTimer(self)
self._jslog_timer.setInterval(500)
self._jslog_timer.timeout.connect(self._jslog_drain_tick)
self._jslog_timer.start()
# 🆕 Fix E: 上下文标签防抖定时器(工具/思考/正文任一显现 → 400ms 内合并刷新一次)
self._ctx_refresh_timer = QtCore.QTimer(self)
self._ctx_refresh_timer.setSingleShot(True)
self._ctx_refresh_timer.setInterval(400)
self._ctx_refresh_timer.timeout.connect(self.update_context_display)
# 🆕 调试窗口控制:文件协议 data/debug_window.cmd (show/hide),与项目树完全独立
self.debug_window = None
self._debug_cmd_timer = QtCore.QTimer(self)
self._debug_cmd_timer.setInterval(2000)
self._debug_cmd_timer.timeout.connect(self._poll_debug_cmd)
self._debug_cmd_timer.start()
self._title_workers = {}
self.current_ai_msg_id = None
self._ghost_msg_map = {} # 🌟 新增:专门记录幽灵报错气泡与提问的映射关系
import time
self._last_click_time = 0
self.init_model_popup()
self.init_mode_popup()
self.init_chat_events()
# 🌟 长文本折叠相关
self._folded_texts = []
self._is_folding = False
self.LONG_TEXT_THRESHOLD = 500
self.LONG_TEXT_LINES = 15
self.init_browser()
# 🆕 调试窗口随程序启动(config.json debug_window_autostart,缺省 true):
# 写 "show" 到控制文件,上方 2s 轮询在事件循环启动后自动开窗
try:
from core.llm_engine import _load_config
from core.debug_log import autostart_debug_window
autostart_debug_window(_load_config())
except Exception:
pass
def setup_ui(self):
self.bg_widget = QtWidgets.QWidget(self)
self.bg_widget.setObjectName("bg_widget")
self.setCentralWidget(self.bg_widget)
self.main_layout = QtWidgets.QHBoxLayout(self.bg_widget)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# ==================== 1. 左侧边栏 ====================
self.sidebar = QtWidgets.QWidget()
self.sidebar.setObjectName("sidebar")
self.sidebar.setFixedWidth(260)
self.sidebar_root = QtWidgets.QVBoxLayout(self.sidebar)
self.sidebar_root.setContentsMargins(0, 0, 0, 0)
self.sidebar_root.setSpacing(0)
# 🌟 双页面容器:展开页 / 收缩页(收缩图标用独立干净布局精确居中)
self.sidebar_stack = QtWidgets.QStackedWidget()
self.sidebar_root.addWidget(self.sidebar_stack)
# ---- 页面0:展开状态(标题行、logo、历史列表、功能按钮)----
self.expand_page = QtWidgets.QWidget()
self.sidebar_layout = QtWidgets.QVBoxLayout(self.expand_page)
self.sidebar_layout.setContentsMargins(10, 20, 10, 20)
# 🌟 顶部标题行:应用图标 + haocode + 收缩按钮(统一高度对齐)
self.sidebar_header = QtWidgets.QWidget()
self.sidebar_header.setFixedHeight(36)
self.sidebar_header_layout = QtWidgets.QHBoxLayout(self.sidebar_header)
self.sidebar_header_layout.setContentsMargins(2, 0, 2, 0)
self.sidebar_header_layout.setSpacing(8)
self.logo_icon = QtWidgets.QLabel()
self.logo_icon.setObjectName("logo_icon")
self.logo_icon.setFixedSize(22, 22)
self.logo_icon.setPixmap(QtGui.QPixmap(self.get_svg_path("main.svg"))
.scaled(22, 22, QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.SmoothTransformation))
self.logo_label = QtWidgets.QLabel("haocode")
self.logo_label.setObjectName("logo_label")
self.collapse_btn = QtWidgets.QPushButton("")
self.collapse_btn.setObjectName("collapse_btn")
self.collapse_btn.setIcon(QtGui.QIcon(self.get_svg_path("panel.svg")))
self.collapse_btn.setIconSize(QtCore.QSize(16, 16))
self.collapse_btn.setFixedSize(28, 28)
self.collapse_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.collapse_btn.setToolTip("收起侧边栏")
self.sidebar_header_layout.addWidget(self.logo_icon, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
self.sidebar_header_layout.addWidget(self.logo_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
self.sidebar_header_layout.addStretch()
self.sidebar_header_layout.addWidget(self.collapse_btn, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
self.sidebar_layout.addWidget(self.sidebar_header)
self.sidebar_layout.addSpacing(10)
self.history_list = DraggableHistoryList()
self.history_list.setObjectName("history_list")
self.sidebar_layout.addWidget(self.history_list)
self.btn_new_chat = QtWidgets.QPushButton(" + 新建对话")
self.btn_new_chat.setObjectName("btn_new_chat")
self.sidebar_layout.addWidget(self.btn_new_chat)
self.sidebar_layout.addSpacing(20)
self.btn_skills = QtWidgets.QPushButton("Skill & Tools")
self.btn_settings = QtWidgets.QPushButton("设置")
self.btn_help = QtWidgets.QPushButton("帮助")
self.btn_about = QtWidgets.QPushButton("关于 (1.19.0)")
for btn in [self.btn_skills, self.btn_settings, self.btn_help, self.btn_about]:
btn.setObjectName("sidebar_menu_btn")
self.sidebar_layout.addWidget(btn)
self.sidebar_stack.addWidget(self.expand_page)
# ---- 页面1:收缩状态(上下 stretch 使图标精确垂直居中)----
self.collapse_page = QtWidgets.QWidget()
self.collapse_lay = QtWidgets.QVBoxLayout(self.collapse_page)
self.collapse_lay.setContentsMargins(0, 0, 0, 0)
self.collapse_lay.addStretch(1)
self.collapse_expand_btn = QtWidgets.QPushButton("")
self.collapse_expand_btn.setObjectName("collapse_btn")
self.collapse_expand_btn.setIcon(QtGui.QIcon(self.get_svg_path("panel.svg")))
self.collapse_expand_btn.setIconSize(QtCore.QSize(18, 18))
self.collapse_expand_btn.setFixedSize(34, 34)
self.collapse_expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.collapse_expand_btn.setToolTip("展开侧边栏")
self.collapse_lay.addWidget(self.collapse_expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter)
self.collapse_lay.addStretch(1)
self.sidebar_stack.addWidget(self.collapse_page)
# ==================== 2. 右侧主聊天区 ====================
self.chat_area = QtWidgets.QWidget()
self.chat_area.setObjectName("chat_area")
self.chat_layout = QtWidgets.QVBoxLayout(self.chat_area)
self.chat_layout.setContentsMargins(0, 0, 0, 0)
self.chat_layout.setSpacing(0)
# --- 2.1 自定义顶部标题栏 ---
self.top_bar = QtWidgets.QWidget()
self.top_bar.setObjectName("top_bar")
self.top_bar.setFixedHeight(50)
self.top_bar_layout = QtWidgets.QHBoxLayout(self.top_bar)
self.top_bar_layout.setContentsMargins(20, 0, 10, 0)
self.top_label = QtWidgets.QLabel("")
self.top_label.setStyleSheet("font-weight: bold; font-size: 16px;")
self.top_bar_layout.addWidget(self.top_label)
self.top_bar_layout.addStretch()
self.btn_history = QtWidgets.QPushButton("历史")
self.btn_history.setObjectName("top_tool_btn")
self.top_bar_layout.addWidget(self.btn_history)
# 🆕 右侧任务面板的开关【不在这里】:与左侧栏一致,开关在栏内
# (收起=栏正中按钮 / 展开=栏标题行右上角按钮)→ 永远只有一个可动按钮
# 🌟 v2: 移除自绘 最小化/最大化/关闭 按钮 —— 已由 Windows 11 原生标题栏接管。
# 历史 按钮保留,随顶部工具栏整体下移到原生标题栏下方。
self.chat_layout.addWidget(self.top_bar)
# --- 2.2 🌟 核心修复:浏览器渲染引擎防穿帮“幕布” ---
self.browser_container = QtWidgets.QWidget()
self.browser_container.setObjectName("browser_container")
self.browser_container.setStyleSheet("background-color: #ffffff;") # 纯白幕布
self.browser_layout = QtWidgets.QVBoxLayout(self.browser_container)
self.browser_layout.setContentsMargins(0, 0, 0, 0)
self.browser_layout.setSpacing(0)
# 创建浏览器(🆕 A′ 双轨:Windows 上优先 WebView2,失败/配置指定时回落 QtWebEngine
# P1-03:平台矩阵 —— 解析 webview_backend 配置;非法值可见警告 + 回落平台默认,不阻断启动
self._wv2_session = None
_backend_pref = "auto"
try:
from core.config_paths import load_config as _load_cfg # P0-01 统一配置入口
_backend_pref = _load_cfg().get("webview_backend", "auto")
except Exception:
pass
_backend, _backend_warn = _renderer_backend.resolve_backend(_backend_pref)
if _backend_warn:
print(_backend_warn, flush=True)
if _backend == "webview2" and _wv2mod is not None and sys.platform == "win32":
_app = QtWidgets.QApplication.instance()
_env = _wv2mod.get_environment(_app)
if _env is not None:
try:
self._wv2_session = _wv2mod.Wv2Session(_env, int(self.winId()), _app)
except Exception as _ex:
print(f"[WV2] session 创建失败 → 回落 QtWebEngine: {_ex}")
self._wv2_session = None
if self._wv2_session is not None:
# ---- WebView2 路径 ----
from ui.views.wv2_view import WebView2View
self.browser = WebView2View(self._wv2_session)
self.browser.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Expanding)
self.browser.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
# 桥接:Python→JS 走 ExecuteScriptJS 文本同构);JS→Python 走 WebMessageReceived
self.chat_bridge = ChatBridge(None, None, js_runner=self.browser.run_js)
self.browser.attach_bridge(self.chat_bridge)
self.web_channel = None
print("[System] 浏览器内核: WebView2")
else:
# ---- QtWebEngine 路径(原逻辑,保持可用) ----
# P1-03:本实例独立 profile 目录(两个并行源码实例不争用同一个 Chromium profile
# 目录在 data/webengine/,测试经 HAOCODE_WEBENGINE_PROFILE_DIR 重定向到临时目录)
from PyQt6.QtWebEngineCore import QWebEngineProfile
_prof_name = _renderer_backend.webengine_profile_name()
_prof_dir = _renderer_backend.webengine_profile_dir(_prof_name)
self._qtwe_profile = QWebEngineProfile(_prof_name)
self._qtwe_profile.setPersistentStoragePath(os.path.join(_prof_dir, "storage"))
self._qtwe_profile.setCachePath(os.path.join(_prof_dir, "cache"))
print(f"[Renderer] QtWebEngine 独立 profile: {_prof_dir}", flush=True)
self.browser = QWebEngineView()
# 🌟 关键修改:使用自定义的 WebPage
custom_page = CustomWebPage(self._qtwe_profile, self.browser)
self.browser.setPage(custom_page)
# 🌟 v2: 不透明绘制属性 —— 告诉 Qt 无需先擦背景再等 WebEngine 纹理,
# 减少窗口缩放瞬间新暴露区域的底色闪烁(配合白底幕布 + main.py 的 GPU 光栅化 flags
self.browser.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
self.browser.setAttribute(QtCore.Qt.WidgetAttribute.WA_NoSystemBackground, True)
# 🌟 让 Chromium 合成器用白色作为清屏/新暴露区域的底色,而非默认黑色:
# → GPU 合成(59 FPS)+ 白底不黑闪,兼得速度与观感(配合 main.py 的合成模式)
self.browser.page().setBackgroundColor(QtGui.QColor("#ffffff"))
# 其他配置保持不变
self.browser.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Expanding)
self.browser.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
# 🌟 初始化桥接器时,传入自定义的 page
from PyQt6.QtWebChannel import QWebChannel
# 创建 QWebChannel,它是 Python ↔ JS 的桥梁
self.web_channel = QWebChannel()
# 创建 ChatBridge,传入 page 和 channel(构造函数内部会 channel.registerObject
self.chat_bridge = ChatBridge(custom_page, self.web_channel)
# 把 channel 设置到 page 上,使 qt.webChannelTransport 生效
custom_page.setWebChannel(self.web_channel)
print("[System] 浏览器内核: QtWebEngine")
self.browser_container.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
# 将浏览器放入布局
self.browser_layout.addWidget(self.browser)
# 🌟 自定义滚动条:固定在浏览器右侧,总高度随浏览器变化(镜像网页滚动)
self.web_scrollbar = WebScrollBar(self.chat_bridge)
self.browser_row = QtWidgets.QWidget()
self.browser_row_layout = QtWidgets.QHBoxLayout(self.browser_row)
self.browser_row_layout.setContentsMargins(0, 0, 0, 0)
self.browser_row_layout.setSpacing(0)
self.browser_row_layout.addWidget(self.browser_container, 1)
self.browser_row_layout.addWidget(self.web_scrollbar, 0)
self.chat_layout.addWidget(self.browser_row) # 浏览器 + 滚动条整行加入主布局
# --- 2.3 底部输入框容器 ---
self.input_container = QtWidgets.QFrame()
self.input_container.setObjectName("input_container")
# 💡 小优化:把最大高度从 200 调到 250,给折叠框和滚动条留出足够的呼吸空间
self.input_container.setMaximumHeight(250)
self.input_container_layout = QtWidgets.QVBoxLayout(self.input_container)
# 【第 1 行】工具栏 (图标)
self.input_tools_layout = QtWidgets.QHBoxLayout()
self.btn_upload = QtWidgets.QPushButton("") # 清空原来的 Emoji 字符
self.btn_upload.setIcon(QtGui.QIcon(self.get_svg_path("upload.svg"))) # 使用我们刚才写的安全路径工具
self.btn_upload.setIconSize(QtCore.QSize(18, 18)) # 图标设为 18x18,配合 30x30 的按钮留出绝佳边距
self.btn_screenshot = QtWidgets.QPushButton("✂️")
self.btn_web = QtWidgets.QPushButton("🌐")
self.btn_skill = QtWidgets.QPushButton("🛠️")
self.btn_server = QtWidgets.QPushButton("☁️")
for btn in [self.btn_upload, self.btn_screenshot, self.btn_web, self.btn_skill, self.btn_server]:
btn.setFixedSize(30, 30)
btn.setObjectName("icon_btn")
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.input_tools_layout.addWidget(btn)
self.input_tools_layout.addStretch()
self.input_container_layout.addLayout(self.input_tools_layout)
# 【第 2 行】🌟 多附件标签滚动区域
self.attachment_scroll_area = QtWidgets.QScrollArea()
self.attachment_scroll_area.setVisible(False)
self.attachment_scroll_area.setWidgetResizable(True)
self.attachment_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.attachment_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.attachment_scroll_area.setFixedHeight(48)
self.attachment_scroll_area.setStyleSheet("""
QScrollArea { border: none; background: transparent; }
QScrollBar:horizontal { border: none; background: transparent; height: 6px; margin: 0px; }
QScrollBar::handle:horizontal { background: #c0c0c0; min-width: 30px; border-radius: 3px; }
QScrollBar::handle:horizontal:hover { background: #a0a0a0; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; }
""")
self.attachment_area = QtWidgets.QWidget()
self.attachment_area.setStyleSheet("background: transparent;")
self.attachment_area_layout = QtWidgets.QHBoxLayout(self.attachment_area)
self.attachment_area_layout.setContentsMargins(4, 0, 4, 8)
self.attachment_area_layout.setSpacing(8)
self.attachment_area_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
self.attachment_scroll_area.setWidget(self.attachment_area)
self.input_container_layout.addWidget(self.attachment_scroll_area) # <--- 确保它被作为第2行加入
# 【第 3 行】文本输入框
self.text_input = PasteAwareTextEdit()
self.text_input.setPlaceholderText("给 haocode 发送消息...")
self.text_input.setObjectName("text_input")
self.text_input.setMaximumHeight(100)
self.input_container_layout.addWidget(self.text_input)
# 【第 4 行】底部栏 (提示文字、模型选择、发送按钮)
self.input_bottom_layout = QtWidgets.QHBoxLayout()
self.hint_label = QtWidgets.QLabel("AI 生成内容可能不准确。")
self.hint_label.setObjectName("hint_label")
self.context_label = QtWidgets.QLabel("128k / 200k")
self.context_label.setObjectName("context_label")
self.model_selector = QtWidgets.QPushButton("gemini-3-pro-preview...")
self.model_selector.setObjectName("model_selector")
# 🆕 圆角改造:与模式按钮(mode_btn)完全一致的白底 13px 圆角,同高 26
self.model_selector.setFixedHeight(26)
self.model_selector.setMinimumWidth(96)
self.btn_send = QtWidgets.QPushButton("") # 1. 传入空字符串,去除原本的文字
self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("send.svg"))) # 2. 加载 SVG 图标(用冻结安全路径)
self.btn_send.setIconSize(QtCore.QSize(20, 20)) # 3. 设置图标大小 (稍微比 32x32 的按钮小一点,留出边距更好看)
self.btn_send.setObjectName("btn_send")
self.btn_send.setFixedSize(32, 32)
# ============ 🆕 模式切换按钮(chat / worker ============
# 按按钮 → 上方浮动窗缓慢弹出(SessionModePopup,与模型选择弹窗同款实现);
# 选择后发送第一条消息 → 该会话锁定此模式
self._mode_panel_selected = "chat"
self.btn_mode = QtWidgets.QToolButton()
self.btn_mode.setObjectName("mode_btn")
self.btn_mode.setFixedSize(96, 26)
self.btn_mode.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.btn_mode.setToolTip("切换聊天模式(发送第一条消息后锁定)")
self.btn_mode.clicked.connect(self.show_mode_popup)
self.input_bottom_layout.addWidget(self.hint_label)
self.input_bottom_layout.addStretch()
self.input_bottom_layout.addWidget(self.context_label)
self.input_bottom_layout.addWidget(self.model_selector)
self.input_bottom_layout.addWidget(self.btn_mode)
self.input_bottom_layout.addWidget(self.btn_send)
self.input_container_layout.addLayout(self.input_bottom_layout)
# 打包装入主聊天区域
self.input_wrapper = QtWidgets.QHBoxLayout()
self.input_wrapper.setContentsMargins(20, 0, 20, 20)
self.input_wrapper.addWidget(self.input_container)
self.chat_layout.addLayout(self.input_wrapper)
self.main_layout.addWidget(self.sidebar)
self.main_layout.addWidget(self.chat_area)
# 🆕 右侧任务面板(默认收起 52px;不参与 resizeEvent 自动收缩,只能手动切换)
self.bash_panel = BashPanel(self)
self.main_layout.addWidget(self.bash_panel)
self.browser.loadFinished.connect(self.on_web_load_finished)
# ==========================================
# 🌟 数据库与历史记录初始化逻辑 (放在 UI 控件全建好之后)
# ==========================================
self.db = DBManager()
if self.db.is_first_run:
print("[System] 欢迎!检测到初次运行,已自动创建本地数据库和初始对话。")
else:
print("[System] 欢迎回来!成功读取本地数据库。")
# 1. 获取所有会话并在侧边栏渲染
sessions = self.db.get_all_sessions()
# 清空 UI 上可能残留的占位符
self.history_list.clear()
# 🟢 改为:
self.current_session_id = None # 先初始化
self.pending_mode = "chat" # 🆕 锁定前选中的模式(默认 chat)
self.rebuild_sidebar()
# 2. 确定启动时默认加载的对话 (默认最新的一条)
if sessions:
self.current_session_id = sessions[0]["id"]
# 让 UI 列表默认选中第一项
self.history_list.setCurrentRow(0)
# ⚠️ 注意:不要在这里直接调用 load_messages_to_web
# 因为此时 QWebEngineView 的网页还没加载完,直接执行 JS 会报错。
# 我们只需记住 current_session_id,等网页加载完毕后再灌入数据。
else:
self.current_session_id = None
self.pending_load_session_id = None
# ==================== 🌟 核心新增:初始化浏览器 ====================
# 把它放在 def setup_ui(self): 的正上方
def get_svg_path(self, filename):
"""🌟 自动获取 svg 文件的绝对路径,并检测文件是否存在"""
import os
base_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.abspath(os.path.join(base_dir, "..", ".."))
full_path = os.path.join(project_root, "svg", filename)
# 🛠️ 杀手锏:帮你自动检查文件到底在不在!
if not os.path.exists(full_path):
print(f"\n❌ [严重警告]: 找不到图标文件!系统去这里找了,但是没找到: \n -> {full_path}\n")
else:
print(f"\n✅ [成功]: 找到了图标文件: {full_path}")
return full_path
def init_browser(self):
"""初始化浏览器设置、信号绑定及页面加载"""
# 🆕 P1-01 渲染窗口:配置(10/40/200,非法值静默回落 40)+ 代次管理
from core import config_paths as _cp
_rw_cfg = _cp.render_window_settings()
self._rw_mode = _rw_cfg["mode"]
self._rw_size = _rw_cfg["size"]
# 页大小 = 半窗:保证向上换页时“首个可见消息”锚点留在窗口内(锚点恢复前提)
self._rw_page_size = max(1, self._rw_size // 2)
self._rw_generation = 0 # 每次加载/切会话/分支/删除/重答/新建 +1
self._rw_config_pushed = False
self._rw_page_inflight = False
# 1. 信号绑定 (ChatBridge 已在 setup_ui 中通过 QWebChannel 注册)
# 重新回答信号
self.chat_bridge.regenerate_clicked.connect(self._on_regenerate_clicked)
# 分支切换信号
self.chat_bridge.branch_switch_clicked.connect(self._on_branch_switch)
# 删除消息信号
self.chat_bridge.delete_message_requested.connect(self._on_delete_message)
# 🌟 绑定附件点击信号
self.chat_bridge.attachment_clicked.connect(self._on_attachment_clicked)
# 🆕 P1-01 渲染窗口换页请求(fire-and-forget,响应经 rwPageResponse 推送)
self.chat_bridge.window_page_requested.connect(self._on_window_page_request)
# 2. 计算本地 HTML 路径
# 假设 web 文件夹位于当前脚本目录的上一级
base_dir = os.path.dirname(os.path.abspath(__file__))
html_path = os.path.normpath(os.path.join(base_dir, '..', 'web', 'index.html'))
# 3. 加载页面
# 注意:确保在加载 URL 之前,QWebChannel 已经正确设置到了 self.browser.page() 上
# 🛡 防缓存:file:// URL 追加时间戳查询串 —— 每次启动保证加载最新 web/ 代码,
# 避免 QtWebEngine 残留旧 JS/CSS 导致“改了前端没生效”
_u = QUrl.fromLocalFile(html_path)
# 🆕 立即设按钮初始文本:WebView2 冷启动链路慢(~10s+),不能等页面加载后才填按钮
try:
self._refresh_mode_button()
except Exception:
pass
self.browser.setUrl(QUrl(_u.toString() + "?hocode_v=" + str(int(_diag_time.time() * 1000))))
def _on_attachment_clicked(self, meta_json_str):
"""🌟 弹出原生附件预览窗口"""
import json
try:
meta = json.loads(meta_json_str)
# self.bg_widget 是铺满整个主窗口的底层容器,挂在它上面就能完美覆盖全屏
# PDF 图片卡:用勾选的图片开可翻页预览(抄弹窗预览那套,页码显示在顶部标题)
if meta.get("type") == "pdf" and meta.get("mode") == "image" and meta.get("selected_images"):
base_name = meta.get("name", "")
siblings = [{
"type": "image",
"name": f'{base_name} · 第 {x["page"]} 页 no.{x["index"]}',
"local_path": x["local_path"],
"mime": x.get("mime", "image/png"),
"size_kb": x.get("size_kb", 0),
} for x in meta["selected_images"]]
overlay = AttachmentPreviewOverlay(siblings[0], self.bg_widget,
siblings=siblings, index=0)
else:
overlay = AttachmentPreviewOverlay(meta, self.bg_widget)
except Exception as e:
print(f"[Warn]: 打开附件预览失败: {e}")
def setup_stylesheet(self):
self.setStyleSheet("""
* { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", "Segoe UI", sans-serif; }
#bg_widget { background-color: #ffffff; }
#chat_area { background-color: #ffffff; }
/* ---- 左侧边栏 / 右侧任务面板(共用一套淡蓝设计语言)---- */
#sidebar { background-color: #f7f8fa; border-right: 1px solid #ececec; }
#right_sidebar { background-color: #f7f8fa; border-left: 1px solid #ececec; }
#logo_icon { background: transparent; }
#logo_label { font-size: 20px; font-weight: 900; color: #1a2332; letter-spacing: 0.5px; background: transparent; }
#collapse_btn { background: transparent; border: none; border-radius: 8px; }
#collapse_btn:hover { background-color: #e6e8ee; }
/* ---- 侧边栏历史列表(🐛 用 objectName 限定作用域:旧版是 QListWidget 类型选择器,
会级联泄漏进模型选择弹窗——选中行 3px 蓝竖条/padding/margin 全部漏进去,
造成“调参工具与实际不一致”。现在只作用于 #history_list ---- */
#history_list { border: none; background: transparent; outline: none; }
#history_list::item { padding: 10px 12px; border-radius: 8px; margin: 2px 4px; color: #444; }
#history_list::item:hover { background-color: #eceef2; }
#history_list::item:selected { background-color: #e4edfa; color: #1a3a63; font-weight: 600; border-left: 3px solid #3b82f6; }
#btn_new_chat { background-color: #ffffff; border: 1px solid #e2e5ea; border-radius: 10px; padding: 10px; font-weight: 600; color: #1f2937; }
#btn_new_chat:hover { background-color: #eef2f7; border-color: #cfd6e0; }
#sidebar_menu_btn { text-align: left; border: none; padding: 9px 12px; margin: 0 4px; color: #5b6472; font-size: 13px; border-radius: 8px; }
#sidebar_menu_btn:hover { background-color: #eceef2; color: #111827; }
/* ---- 🆕 右侧任务面板(继承左侧栏配色:#f7f8fa / #ececec / 淡蓝 #e4edfa---- */
#bl_panel_head { background: transparent; }
#bl_panel_title { font-size: 13px; font-weight: 700; color: #1a2332; }
#bl_panel_total { font-size: 11px; color: #8a94a6; }
#bl_splitter::handle { background: #e9ecf3; }
#bl_splitter::handle:hover { background: #d7deea; }
#bl_sect_head { background: transparent; }
#bl_sect_head:hover { background-color: #eceef2; }
#bl_sect_title { font-size: 12px; font-weight: 600; color: #33415c; }
#bl_sect_count { font-size: 11px; color: #7c8698; }
#bl_sect_chev { color: #8a94a6; font-size: 10px; }
#bl_scroll, #bl_host { background: transparent; border: none; }
#bl_hint { color: #9aa4b5; font-size: 11px; padding: 2px 10px 8px 10px; }
#bl_layer { background-color: #ffffff; border: 1px solid #e6eaf2; border-radius: 9px; }
#bl_layer:hover { border-color: #cfe0f7; }
#bl_head { background: transparent; border-radius: 9px; }
#bl_head:hover { background-color: #f2f7fe; }
#bl_dot { font-size: 10px; color: #9aa4b5; }
#bl_dot[state="run"] { color: #3b82f6; }
#bl_dot[state="ok"] { color: #16a34a; }
#bl_dot[state="bad"] { color: #dc2626; }
#bl_name { font-size: 11px; font-weight: 700; color: #1a3a63; }
#bl_meta { font-size: 10px; color: #7c8698; }
#bl_cmd { font-size: 10px; color: #5b6472; }
#bl_chev { color: #9aa4b5; font-size: 10px; }
#bl_tag { font-size: 9px; border-radius: 6px; padding: 1px 5px;
color: #4b5563; background-color: #eef0f4; }
#bl_tag[kind="run"] { color: #1d4ed8; background-color: #e4edfa; }
#bl_tag[kind="ok"] { color: #15803d; background-color: #e7f6ec; }
#bl_tag[kind="bad"] { color: #b91c1c; background-color: #fdeaea; }
#bl_tag[kind="out"] { color: #6b7280; background-color: #eef0f4; }
#bl_sect_label { font-size: 10px; font-weight: 600; color: #7c8698; }
#bl_code { background-color: #fbfcfe; border: 1px solid #e6eaf2; border-radius: 7px;
padding: 4px; font-family: Consolas, "Courier New", monospace;
font-size: 11px; color: #243043; }
/* ---- 🆕 P2-02:右侧任务面板滚动条 + 横纵交汇角(全部选择器限定在
#bl_code / #bl_scroll,绝不添加无作用域的 QScrollBar/QAbstractScrollArea
全局规则,不污染模型弹窗/会话列表/附件预览等其他控件) ----
口径:厚 8px、隐藏箭头(add-line/sub-line 置 0)、handle 可见 + hover、
corner 用 Qt 支持的 QAbstractScrollArea::corner 子控件(不写 QScrollBar::corner),
与代码框背景 #fbfcfe 一致(消除原生亮色 corner 方块) */
QPlainTextEdit#bl_code QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; }
QPlainTextEdit#bl_code QScrollBar::handle:vertical { background: #d0d0d0; min-height: 30px; border-radius: 4px; }
QPlainTextEdit#bl_code QScrollBar::handle:vertical:hover { background: #a0a0a0; }
QPlainTextEdit#bl_code QScrollBar::add-line:vertical, QPlainTextEdit#bl_code QScrollBar::sub-line:vertical { height: 0px; border: none; background: transparent; }
QPlainTextEdit#bl_code QScrollBar::add-page:vertical, QPlainTextEdit#bl_code QScrollBar::sub-page:vertical { background: transparent; }
QPlainTextEdit#bl_code QScrollBar:horizontal { border: none; background: transparent; height: 8px; margin: 0px; }
QPlainTextEdit#bl_code QScrollBar::handle:horizontal { background: #d0d0d0; min-width: 30px; border-radius: 4px; }
QPlainTextEdit#bl_code QScrollBar::handle:horizontal:hover { background: #a0a0a0; }
QPlainTextEdit#bl_code QScrollBar::add-line:horizontal, QPlainTextEdit#bl_code QScrollBar::sub-line:horizontal { width: 0px; border: none; background: transparent; }
QPlainTextEdit#bl_code QScrollBar::add-page:horizontal, QPlainTextEdit#bl_code QScrollBar::sub-page:horizontal { background: transparent; }
QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; }
QScrollArea#bl_scroll QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; }
QScrollArea#bl_scroll QScrollBar::handle:vertical { background: #d0d0d0; min-height: 30px; border-radius: 4px; }
QScrollArea#bl_scroll QScrollBar::handle:vertical:hover { background: #a0a0a0; }
QScrollArea#bl_scroll QScrollBar::add-line:vertical, QScrollArea#bl_scroll QScrollBar::sub-line:vertical { height: 0px; border: none; background: transparent; }
QScrollArea#bl_scroll QScrollBar::add-page:vertical, QScrollArea#bl_scroll QScrollBar::sub-page:vertical { background: transparent; }
QScrollArea#bl_scroll::corner { background: transparent; }
#bl_body { background: transparent; }
/* ---- 顶部栏 ---- */
#top_bar { background-color: #ffffff; border-bottom: 1px solid #eeeeee; }
#top_tool_btn { background: transparent; border: 1px solid #e2e5ea; border-radius: 8px; padding: 5px 14px; color: #4b5563; font-size: 12px; }
#top_tool_btn:hover { background-color: #f3f4f6; border-color: #d1d5db; color: #111827; }
/* ---- 输入区 ---- */
#input_container { background-color: #f6f7f9; border: 1px solid #e4e6ea; border-radius: 20px; padding: 10px; }
#icon_btn { background: transparent; border: none; border-radius: 15px; }
#icon_btn:hover { background-color: #e6e8ec; }
#text_input { border: none; background: transparent; font-size: 15px; color: #1f2937; selection-background-color: #bfdbfe; }
#hint_label { color: #9ca3af; font-size: 11px; }
#context_label { color: #6b7280; font-size: 12px; margin-right: 10px; }
/* 🆕 模型选择按钮:与模式按钮(mode_btn)完全一致的圆角样式 */
#model_selector { background: #ffffff; border: 1px solid #e2e5ea; border-radius: 13px; font-size: 12px; color: #4b5563; padding: 2px 8px; }
#model_selector:hover { background-color: #eef1f5; border-color: #cbd5e1; color: #111827; }
/* 🆕 模式切换按钮(选择窗为 SessionModePopup 浮动窗,配色由其自己注入) */
#mode_btn { background: #ffffff; border: 1px solid #e2e5ea; border-radius: 13px; font-size: 12px; color: #4b5563; padding: 2px 8px; }
#mode_btn:hover { background-color: #eef1f5; border-color: #cbd5e1; color: #111827; }
/* ---- 发送按钮(实际配色由 set_send_button_state 动态控制) ---- */
#btn_send { background-color: #2563eb; border: none; border-radius: 16px; font-weight: 700; }
#btn_send:hover { background-color: #1d4ed8; }
#btn_send:pressed { background-color: #1e40af; }
""")
def set_send_button_state(self, is_generating: bool):
"""🌟 统一管理发送/停止按钮的 UI 状态"""
if is_generating:
# 正在生成:红色停止按钮(白色图标)
self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("stop_white.svg")))
self.btn_send.setStyleSheet("""
QPushButton { background-color: #ef4444; border: none; border-radius: 16px; font-weight: 700; }
QPushButton:hover { background-color: #dc2626; }
QPushButton:pressed { background-color: #b91c1c; }
""")
else:
# 空闲状态:蓝色发送按钮(白色图标)
self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("send_white.svg")))
self.btn_send.setStyleSheet("""
QPushButton { background-color: #2563eb; border: none; border-radius: 16px; font-weight: 700; }
QPushButton:hover { background-color: #1d4ed8; }
QPushButton:pressed { background-color: #1e40af; }
""")
# ==================== 🌟 侧边栏收缩 ====================
@QtCore.pyqtProperty(float)
def sidebarWidth(self):
"""侧边栏宽度属性:供 QPropertyAnimation 平滑插值收缩/展开"""
return float(self.sidebar.width())
@sidebarWidth.setter
def sidebarWidth(self, w):
self.sidebar.setFixedWidth(int(w))
def _effective_collapsed(self):
"""当前生效的收缩状态:动画播放中按其目标方向,否则按已落定状态。"""
if self._sidebar_anim is not None:
return self._sidebar_target
return self.sidebar_collapsed
def toggle_sidebar(self):
"""手动切换侧边栏展开/收缩"""
self._set_sidebar_collapsed(not self._effective_collapsed())
def _set_sidebar_collapsed(self, collapsed: bool):
"""收缩/展开侧边栏:从右到左平滑滑动宽度动画。
展开/收缩分别使用独立干净页面(QStackedWidget),
收缩图标由上下 stretch 精确垂直居中,彻底规避残留间距导致的偏移。
"""
# 若已有动画正在播放,先停止并重定向到新目标(保证 resize 时也能正常工作)
if self._sidebar_anim is not None:
self._sidebar_anim.stop()
self._sidebar_anim.deleteLater()
self._sidebar_anim = None
self._sidebar_target = collapsed
# 🌟 切换页面:收缩页(图标精确居中) / 展开页(完整内容)
self.sidebar_stack.setCurrentWidget(
self.collapse_page if collapsed else self.expand_page)
# 🌟 从右到左 / 从左到右 宽度滑动动画
start_w = self.sidebar.width()
end_w = 52 if collapsed else 260
self._sidebar_anim = QtCore.QPropertyAnimation(self, b"sidebarWidth")
self._sidebar_anim.setDuration(260)
self._sidebar_anim.setStartValue(float(start_w))
self._sidebar_anim.setEndValue(float(end_w))
self._sidebar_anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic)
self._sidebar_anim.finished.connect(self._on_sidebar_anim_finished)
self._sidebar_anim.start()
def _on_sidebar_anim_finished(self):
"""动画结束:锁定最终宽度并落定状态。"""
self.sidebar.setFixedWidth(52 if self._sidebar_target else 260)
self.sidebar_collapsed = self._sidebar_target
self._sidebar_anim.deleteLater()
self._sidebar_anim = None
def resizeEvent(self, event):
super().resizeEvent(event)
# 🌟 侧边栏自动收缩:带回滞,且动画播放中也能正确重定向(resize 下同样正常工作)
eff = self._effective_collapsed()
if eff:
# 当前处于(或正趋向)收缩态:仅当宽度明显变大才展开
if self.width() > 1150:
self._set_sidebar_collapsed(False)
else:
# 当前处于(或正趋向)展开态:仅当宽度明显变小才收缩
if self.width() < 1050:
self._set_sidebar_collapsed(True)
# 🌟 v2: 原生边框自带拉伸,EdgeGrip 隐形拉伸块已移除,无需手动几何计算。
# 设置窗口跟随主窗口自适应
if hasattr(self, '_settings_win') and self._settings_win is not None:
self._settings_win.resize_to_parent()
def showEvent(self, event):
super().showEvent(event)
# 🌟 v2: 每次显示都重新声明圆角偏好(Windows 11 22000+)。
# 最大化时 Windows 会按系统规则自动切换为直角,属正常行为。
self._apply_win11_rounded_corners()
def _apply_win11_rounded_corners(self):
"""通过 DWM 设置窗口圆角偏好(Windows 11 原生圆角)。"""
try:
import ctypes
if not hasattr(ctypes, 'windll'):
return # 非 Windows 平台无需处理
# DWMWA_WINDOW_CORNER_PREFERENCE = 33, DWMWCP_ROUND = 2
val = ctypes.c_int(2)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(int(self.winId())),
33,
ctypes.byref(val),
ctypes.sizeof(val)
)
except Exception as e:
print(f"[Warn]: 设置窗口圆角失败: {e}")
def _try_remove_file(self, path: str, absolute: bool = False) -> None:
"""尽力物理删除一个附件缓存文件, 失败仅打印告警。
Args:
path: 相对项目根目录的路径, 或 absolute=True 时的绝对路径。
absolute: path 是否已是绝对路径。
"""
import os
if not path:
return
if absolute:
abs_path = path
else:
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
abs_path = os.path.join(root_dir, path)
try:
if os.path.exists(abs_path):
os.remove(abs_path)
print(f"[System]: 已清理废弃附件缓存 -> {path}")
except Exception as e:
print(f"[Warn]: 清理废弃附件失败: {e}")
def remove_attachment(self, tag_widget, index):
"""删除指定的附件标签,如果是未发送的图片,同时清理本地缓存文件"""
import os
# 将对应位置标记为 None(不直接 pop,防止索引错乱)
if index < len(self._folded_texts):
att_data = self._folded_texts[index]
# 🌟 核心新增:垃圾回收机制 (物理删除废弃附件缓存)
if att_data and att_data.get("type") in ("image", "pdf"):
# 附件本体文件 (图片 / PDF 拷贝)
if att_data.get("local_path"):
self._try_remove_file(att_data["local_path"])
# PDF 图片解析模式提取出的所有图片
for im in att_data.get("images", []):
abs_im = im.get("abs_path")
if abs_im:
self._try_remove_file(abs_im, absolute=True)
# 标记数据为废弃
self._folded_texts[index] = None
# 从布局中移除并销毁 widget
self.attachment_area_layout.removeWidget(tag_widget)
tag_widget.deleteLater()
# 如果所有附件都被删除了,隐藏容器并恢复提示语
if all(t is None for t in self._folded_texts):
self._folded_texts.clear()
self.attachment_scroll_area.setVisible(False)
self.text_input.setPlaceholderText("给 haocode 发送消息...")
print(f"[UI]: 已移除附件 {index + 1}")
def reset_input_ui(self):
"""恢复输入框和按钮的初始状态"""
self.set_send_button_state(False)
# ==================== 事件拦截(P0-02MainWindow 唯一 eventFilter ====================
def eventFilter(self, obj, event):
"""统一事件过滤器:只处理输入框的 Enter / Shift+Enter,其余对象与事件全部交给父类。
「是否允许发送」的规则是 send_message(from_enter=True) 里的单一实现,
与发送按钮点击路径共用;一次按键至多触发一次 send_message 调用。"""
if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress:
if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier:
return False # Shift+Enter:放行换行
self.send_message(from_enter=True)
return True # 事件已消费:不往输入框插入换行
return super().eventFilter(obj, event)
# ==================== 🌟 核心新增:发送与线程控制 ====================
def init_chat_events(self):
# 1. 绑定发送按钮
self.btn_send.clicked.connect(self.send_message)
# 2. 为输入框安装事件过滤器 (拦截 Enter 键)
self.text_input.installEventFilter(self)
self.text_input.long_text_pasted.connect(self._on_long_text_pasted)
self.text_input.image_pasted.connect(self._on_image_pasted)
self.text_input.files_pasted.connect(self._on_files_dropped) # 🌟 文本/代码文件拖拽/粘贴
# 3. 绑定左侧边栏按钮 (占位)
self.btn_new_chat.clicked.connect(self.on_new_chat_clicked)
self.collapse_btn.clicked.connect(self.toggle_sidebar)
self.collapse_expand_btn.clicked.connect(self.toggle_sidebar)
# 🌟 自定义滚动条:前端页面滚动 → 滑块位置/长度镜像
self.chat_bridge.scroll_changed.connect(self.web_scrollbar.set_scroll_info)
self.btn_skills.clicked.connect(lambda: print("\n[UI]: 点击了 -> Skill & Tools"))
self.btn_settings.clicked.connect(self.show_settings) # 或者你原来的写法
self.btn_help.clicked.connect(lambda: print("\n[UI]: 点击了 -> 帮助"))
self.btn_about.clicked.connect(lambda: print("\n[UI]: 点击了 -> 关于"))
# 4. 绑定顶部工具栏按钮 (占位)
self.btn_history.clicked.connect(lambda: print("\n[UI]: 点击了 -> 历史"))
# 5. 绑定输入框下方的工具按钮 (占位)
self.btn_upload.clicked.connect(self._on_upload_clicked)
# 6. 截图功能 (Alt+S 全局热键 + 按钮)
self.screenshot_overlay = ScreenCaptureOverlay()
self.screenshot_overlay.screenshot_captured.connect(self._on_image_pasted)
self.btn_screenshot.clicked.connect(self._start_screenshot)
# P1-04:平台路由 —— Windows RegisterHotKey / X11 XGrabKey / Wayland+offscreen 无全局热键
_sess_kind = desktop_session.session_kind()
_hk_factory, _hk_msg = desktop_session.hotkey_plan(_sess_kind)
print(_hk_msg, flush=True)
self.hotkey_screenshot = _hk_factory() if _hk_factory else None
if self.hotkey_screenshot is not None:
self.hotkey_screenshot.triggered.connect(self._start_screenshot)
self.hotkey_screenshot.start()
# 非 Windows 平台回退到应用级快捷键(Wayland/offscreen 下窗口获焦时仍可用)
if sys.platform != "win32":
self.shortcut_screenshot = QtGui.QShortcut(QtGui.QKeySequence("Alt+S"), self)
self.shortcut_screenshot.activated.connect(self._start_screenshot)
self.btn_web.clicked.connect(lambda: print("\n[UI]: 点击了 -> 联网搜索 🌐"))
self.btn_skill.clicked.connect(lambda: print("\n[UI]: 点击了 -> 动态技能 🛠️"))
self.btn_server.clicked.connect(lambda: print("\n[UI]: 点击了 -> 后台服务 ☁️"))
self.history_list.itemClicked.connect(self.on_sidebar_item_clicked)
self.history_list.order_changed.connect(self.on_session_order_changed)
def on_session_order_changed(self, ordered_ids):
self.db.update_session_order(ordered_ids)
print(f"[UI]: 会话顺序已更新 ({len(ordered_ids)} 条)")
def _on_upload_clicked(self):
"""点击上传按钮:支持图片 + 文本/代码文件多选,按扩展名自动分类入库。"""
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(
self, "选择文件(图片 / 文本 / 代码)", "",
"所有文件 (*);;图片 (*.png *.jpg *.jpeg *.webp *.bmp *.gif)"
)
if not paths:
return
# 按扩展名分类:图片走图片管线,其余交给文件管线(内部做二进制/编码探测)
image_paths, other_paths = [], []
for path in paths:
ext = os.path.splitext(path)[1].lower()
(image_paths if ext in PasteAwareTextEdit.IMAGE_EXTS else other_paths).append(path)
if image_paths:
self._on_image_pasted(image_paths) # 复用:拷贝到 data/attachments 并加图片附件
if other_paths:
self._on_files_dropped(other_paths) # 复用:读取文本内容并加文件附件
def _on_long_text_pasted(self, text: str):
"""长文本粘贴,转为通用附件格式,然后显示在输入框上方"""
size_kb = round(len(text.encode('utf-8')) / 1024, 2)
line_count = text.count('\n') + 1
att_data = {
"type": "text",
"content": text,
"size_kb": size_kb,
"lines": line_count
}
self._add_attachment_tag(att_data)
def _on_image_pasted(self, image_data):
"""处理粘贴或拖拽的图片:保存到本地 data/attachments 并添加为附件"""
import os
import shutil
import uuid
# 定位到项目根目录的 data/attachments
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
attach_dir = os.path.join(root_dir, "data", "attachments")
os.makedirs(attach_dir, exist_ok=True)
# 情况1image_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)
# 情况2image_data 是文件路径列表(拖拽图片文件 / 剪切板文件 URL)
elif isinstance(image_data, list):
for file_path in image_data:
ext = os.path.splitext(file_path)[1].lower()
if ext == '.jpg':
ext = '.jpeg'
new_filename = f"img_{uuid.uuid4().hex[:8]}{ext}"
target_path = os.path.join(attach_dir, new_filename)
shutil.copy2(file_path, target_path)
size_kb = round(os.path.getsize(target_path) / 1024, 2)
mime_type = f"image/{ext[1:]}" if ext else "image/jpeg"
att_data = {
"type": "image",
"name": os.path.basename(file_path),
"size_kb": size_kb,
"mime": mime_type,
"local_path": f"data/attachments/{new_filename}"
}
self._add_attachment_tag(att_data)
# 截图确认后,若主窗口原处于后台(Alt+S 触发),拉回前端显示
if getattr(self, '_screenshot_from_background', False):
self._screenshot_from_background = False
self.showNormal() # 从最小化恢复
self.raise_() # 提到 Z 序顶层
self.activateWindow() # 请求系统焦点
def _on_files_dropped(self, paths):
"""处理拖拽/粘贴/选择的文件:PDF 走专用解析,其余按文本读取转为附件。
不支持的类型 / 二进制 / 超大文件静默跳过,不打扰用户。"""
for path in paths:
ext = os.path.splitext(path)[1].lower()
if ext == '.pdf': # 🌟 PDF 交给专用工具解析(文本/图片双模式)
self._on_pdf_dropped(path)
continue
if ext in BINARY_EXTS:
continue
try:
content, enc, size_kb, lines = read_text_file(path)
except (ValueError, OSError):
continue
self._add_attachment_tag({
"type": "text",
"name": os.path.basename(path),
"content": content,
"size_kb": size_kb,
"lines": lines,
"encoding": enc,
})
def _on_pdf_dropped(self, path):
"""PDF 专用导入:拷贝落地 + 用 pdf_reader 解析逐页文本,生成默认文本模式附件。
加密/损坏/超大 PDF 静默跳过。"""
import shutil
# 1. 拷贝到 data/attachments(像图片一样落地;图片模式需要原文件再次提取)
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
attach_dir = os.path.join(root_dir, "data", "attachments")
os.makedirs(attach_dir, exist_ok=True)
new_filename = f"pdf_{uuid.uuid4().hex[:8]}.pdf"
target_path = os.path.join(attach_dir, new_filename)
try:
shutil.copy2(path, target_path)
# 2. 解析逐页结构化文本 + 总页数
text_content, pages = extract_pdf_text(target_path)
except (ValueError, OSError):
return
self._add_attachment_tag({
"type": "pdf",
"name": os.path.basename(path),
"size_kb": round(os.path.getsize(target_path) / 1024, 2),
"pages": pages,
"mode": "text", # 默认文本模式;候选栏里可切 "image"
"text_content": text_content,
"local_path": f"data/attachments/{new_filename}",
"images": [], # 切到图片模式时懒提取并缓存
"selected_images": [], # 用户勾选的图片(图片模式发送用)
})
def _set_pdf_tag_style(self, tag_label: "ClickableLabel", mode: str) -> None:
"""按当前模式给 PDF 附件标签设置外框颜色。
text 模式: 灰框, 悬停变蓝(与图片附件一致); image 模式: 持久红框。
Args:
tag_label: PDF 附件的标签控件。
mode: "text" 或 "image"。
"""
if mode == "image":
# 图片解析模式: 持久红框(悬停仅微调边框色, 不变蓝)
tag_label.setStyleSheet("""
QLabel { background-color: #fff1f0; color: #cf1322; font-size: 13px;
padding: 8px 14px; border-radius: 6px; border: 1px solid #ff4d4f; }
QLabel:hover { border: 1px solid #ff7875; }
""")
else:
# 文本模式: 与图片/文本附件标签完全一致, 悬停变蓝
tag_label.setStyleSheet("""
QLabel { background-color: #f5f5f5; color: #555555; font-size: 13px;
padding: 8px 14px; border-radius: 6px; border: 1px solid #e5e5e5; }
QLabel:hover { color: #0066cc; border: 1px solid #b3d9ff; text-decoration: underline; }
""")
def _show_pdf_popup(self, att_data: dict) -> None:
"""在 PDF 附件标签上方弹出模式选择弹窗。
Args:
att_data: PDF 附件数据字典(含 _tag_label / images / mode 等)。
"""
# 关闭上一个弹窗
if getattr(self, "_pdf_popup", None) is not None:
try:
self._pdf_popup.close()
self._pdf_popup.deleteLater()
except Exception:
pass
self._pdf_popup = None
tag_label = att_data.get("_tag_label")
if tag_label is None:
return
popup = PdfModePopup(att_data, self.bg_widget)
popup.mode_changed.connect(lambda m, d=att_data: self._set_pdf_tag_style(d["_tag_label"], m))
popup.preview_requested.connect(self._on_pdf_image_preview)
self._pdf_popup = popup
# 弹窗高度由 setFixedHeight 固定, 可直接用来算标签上方的落点
g = tag_label.mapToGlobal(QtCore.QPoint(0, 0))
x = g.x()
y = g.y() - popup.height() - 5
if y < 0:
y = g.y() + tag_label.height() + 5
popup.show_with_animation(QtCore.QPoint(x, y))
def _on_pdf_image_preview(self, meta: dict, siblings: list, index: int, att_data: dict) -> None:
"""预览 PDF 提取出的图片(复用附件图片预览覆盖层),支持同组左右翻页与选择。
Args:
meta: 当前图片元信息, 含 local_path / name / mime。
siblings: 同组全部图片的 meta 列表(供翻页)。
index: 当前图片在 siblings 中的下标。
att_data: PDF 附件字典(供“选择”按钮回写 selected_images)。
"""
overlay = AttachmentPreviewOverlay(meta, self.bg_widget, siblings=siblings,
index=index, att_data=att_data)
overlay.show()
def _start_screenshot(self):
"""启动截图(P1-04 平台路由:win32/x11 → 覆盖层;wayland → portalunknown → 明确告知)"""
# 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发)
self._screenshot_from_background = not self.isActiveWindow()
_kind = desktop_session.session_kind()
_mode, _msg = desktop_session.capture_plan(_kind)
if _msg:
print(_msg, flush=True)
return
if _mode == "portal":
self._start_portal_screenshot()
return
self.screenshot_overlay.start()
def _start_portal_screenshot(self):
"""Wayland:经 xdg-desktop-portal 交互截图(用户授权,不绕过 compositor)。"""
from ui.views.system_tools import portal_capture
if not hasattr(self, "_portal_worker"):
self._portal_worker = None
self._portal_worker = portal_capture.PortalScreenshotWorker(parent=self)
self._portal_worker.done.connect(self._on_portal_screenshot_done)
self._portal_worker.start()
def _on_portal_screenshot_done(self, ok, path_or_reason):
"""portal 截图结果回主线程:成功 → 进现有图片附件流程;失败 → 明确日志。"""
if ok:
self._on_image_pasted([path_or_reason])
else:
print(f"[Screenshot] portal 截图未完成:{path_or_reason} → 当前环境截图能力不可用/已取消;"
f"聊天与其他功能不受影响", flush=True)
def _add_attachment_tag(self, att_data: dict):
"""🌟 通用附件 UI 渲染(图片和长文本共用这一个精美样式)"""
index = len(self._folded_texts)
self._folded_texts.append(att_data)
tag_widget = QtWidgets.QWidget()
tag_widget.setObjectName(f"attachment_tag_{index}")
tag_layout = QtWidgets.QHBoxLayout(tag_widget)
tag_layout.setContentsMargins(0, 0, 0, 0)
tag_layout.setSpacing(6)
# 根据类型设置不同的文字
if att_data["type"] == "text":
display_name = att_data.get("name", "长文本")
label_text = f"📄 附件 {index + 1}: {display_name} ({att_data['size_kb']} KB · {att_data['lines']} 行)"
elif att_data["type"] == "pdf":
label_text = f"📕 附件 {index + 1}: {att_data['name']} ({att_data.get('pages', 0)} 页 · {att_data['size_kb']} KB)"
else:
label_text = f"🖼️ 附件 {index + 1}: 图片 ({att_data['name']} · {att_data['size_kb']} KB)"
label = ClickableLabel(label_text)
label.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
if att_data["type"] == "pdf":
# 🌟 PDF:点击弹出解析模式候选栏;外框颜色随模式切换(文本=灰 / 图片=红)
att_data["_tag_label"] = label
self._set_pdf_tag_style(label, att_data.get("mode", "text"))
label.clicked.connect(lambda checked=False, d=att_data: self._show_pdf_popup(d))
else:
label.setStyleSheet("""
QLabel { background-color: #f5f5f5; color: #555555; font-size: 13px;
padding: 8px 14px; border-radius: 6px; border: 1px solid #e5e5e5; }
QLabel:hover { color: #0066cc; border: 1px solid #b3d9ff; text-decoration: underline; }
""")
label.clicked.connect(lambda checked=False, d=att_data: AttachmentPreviewOverlay(d, self.bg_widget))
remove_btn = QtWidgets.QPushButton("✕")
remove_btn.setFixedSize(26, 26)
remove_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
remove_btn.setStyleSheet("""
QPushButton { background: #eeeeee; border: none; border-radius: 13px; color: #888888; font-size: 12px; font-weight: bold; }
QPushButton:hover { background: #dddddd; color: #333333; }
""")
remove_btn.clicked.connect(lambda checked, w=tag_widget, i=index: self.remove_attachment(w, i))
tag_layout.addWidget(label)
tag_layout.addWidget(remove_btn)
self.attachment_area_layout.addWidget(tag_widget)
self.attachment_scroll_area.setVisible(True)
self.text_input.setPlaceholderText("可继续添加文件或输入补充说明...")
# ==================== 重新回答发送逻辑 ====================
def _on_regenerate_clicked(self, msg_id):
"""前端点击“重新回答”按钮时触发 (完美兼容 User框、正常AI框、幽灵报错框)"""
if self.current_session_id in self._active_streams:
return
# 获取当前时间线上的所有消息
messages = self.db.get_message_chain(self.current_session_id)
target_msg = next((m for m in messages if m["id"] == msg_id), None)
# 🌟 核心修复:精准路由指针!
if not target_msg:
# 如果在数据库找不到,说明点的是报错的幽灵气泡
# 查小本本,拿到绝对正确的提问指针!
user_msg_id = self._ghost_msg_map.get(msg_id)
if not user_msg_id:
print(f"[UI]: 未知气泡 {msg_id},拒绝重试操作")
return # 万一没查到,安全退出,绝不乱指
else:
# 如果是正常的历史气泡
if target_msg["role"] == "user":
user_msg_id = target_msg["id"]
else:
user_msg_id = target_msg["parent_id"]
# 备份当前指针 (时光倒流用,这部分你之前的修改已经做得很好了)
original_leaf_id = self.db.get_session_leaf(self.current_session_id)
# 截断时间线,重新拉取 UI,并启动生成
self.db.update_session_leaf(self.current_session_id, user_msg_id)
self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 内部刷新,不触发加载层
self._regenerate_from_user_msg(user_msg_id, original_leaf_id)
# 🌟 参数增加 original_leaf_id
def _regenerate_from_user_msg(self, user_msg_id, original_leaf_id=None):
import uuid
session_id = self.current_session_id
api_messages = self.build_api_context(session_id)
self._dbg(f"[send-regen] model={self.current_model} "
f"mode={self._get_current_mode()}")
ai_msg_id = f"msg-{uuid.uuid4().hex}"
siblings = self.db.get_branch_info(user_msg_id)
total_branches = len(siblings) + 1
branch_info = {"current": total_branches, "total": total_branches}
self.chat_bridge.create_message(
ai_msg_id, "assistant", "",
self.current_model or "Assistant",
branch_info=branch_info
)
# 🆕 P1-01:重新回答占位进渲染窗口
self._rw_note_live(ai_msg_id, session_id)
self._ghost_msg_map[ai_msg_id] = user_msg_id
self._active_streams[session_id] = {
"msg_id": ai_msg_id,
"content": "",
"reasoning": "",
"timeline": [], # 🌟 agent 时间线(思考/文本/工具 按事件顺序)
"tl_kind": None, # 时间线当前段类型(think/text/tool
"parent_id": user_msg_id,
"branch_info": branch_info,
"worker": None,
"usage": None, # 🆕 P1: 本轮已收到的精确 usage(显示锚定)
"previous_leaf_id": original_leaf_id or user_msg_id # 🌟 核心:存入时光倒流锚点!
}
# 🆕 模式分派:重新回答跟随会话已锁定的模式(老会话未锁定 → chat 普通聊天)
worker = self._create_stream_worker(api_messages, self._get_current_mode() or "chat")
self._active_streams[session_id]["worker"] = worker
worker.chunk_received.connect(lambda token: self.on_chunk_received(session_id, token))
worker.reasoning_received.connect(lambda token: self.on_reasoning_received(session_id, token))
worker.error_occurred.connect(lambda err: self.on_error(session_id, err))
worker.finished.connect(lambda: self.on_reply_finished(session_id))
# 🆕 P1: 本轮精确 usagechat/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):
# 按模式创建 workerworker→AgentWorker(agent 循环) / chat→ChatWorker(普通聊天)
if mode == "worker":
return AgentWorker(self.current_provider, self.current_model, messages)
return ChatWorker(self.current_provider, self.current_model, messages)
def _persist_interrupted_stream(self, session_id, stream_state):
"""🆕 Fix B: 持久化进行中的部分回复(发送中断 / 关窗共用)。
- 有内容/思考/时间线 → 以 assistant 消息挂到链表(parent 指向本轮提问)
- 完全为空 → 时光倒流:会话叶子回退到发送前
"""
import json as _json
try:
if (stream_state.get("content") or stream_state.get("reasoning")
or stream_state.get("timeline")):
tl_json = (_json.dumps(stream_state["timeline"], ensure_ascii=False)
if stream_state.get("timeline") else None)
# 🆕 P1: 中断回复也保存精确 usage(供后续显示/压缩估算锚定)
_usage_json = None
try:
from core.agent import calculate_context_tokens as _calc_usage
if _calc_usage(stream_state.get("usage") or {}) > 0:
_usage_json = _json.dumps(stream_state.get("usage"))
except Exception:
pass
self.db.add_message(
session_id=session_id,
role="assistant",
content=stream_state.get("content", ""),
parent_id=stream_state.get("parent_id"),
reasoning=stream_state.get("reasoning", ""),
msg_id=stream_state.get("msg_id"),
timeline=tl_json,
usage=_usage_json
)
print(f"[DB]: 中断内容已保存 "
f"({len(stream_state.get('content') or '')} 字) session={session_id[:8]}", flush=True)
else:
prev_leaf = stream_state.get("previous_leaf_id")
if prev_leaf:
self.db.update_session_leaf(session_id, prev_leaf)
print(f"[DB]: 中断时未生成内容,叶子节点已回退至: {prev_leaf}", flush=True)
except Exception as e:
print(f"[DB]: 中断内容保存失败: {e}", flush=True)
def send_message(self, from_enter: bool = False):
import time
import uuid
import json
# 🌟 P0-02:发送规则的单一实现(Enter 与按钮点击路径共用):
# 发送按钮禁用 → 一律不得发送;
# 当前会话正在流式生成 → Enter 路径直接 no-op(不参与停止/中断语义,防误触);
# 按钮点击路径保留原有中断行为(红色停止按钮),继续落入下方中断逻辑。
if not self.btn_send.isEnabled():
return
if from_enter and self.current_session_id in self._active_streams:
return
current_time = time.time()
if current_time - self._last_click_time < 0.3:
return
self._last_click_time = current_time
# 检查当前会话是否正在生成(处理中断请求)
if self.current_session_id in self._active_streams:
stream_state = self._active_streams[self.current_session_id]
worker = stream_state.get("worker")
if worker:
try:
worker.chunk_received.disconnect()
worker.error_occurred.disconnect()
worker.reasoning_received.disconnect()
worker.finished.disconnect()
if hasattr(worker, "tool_execution_started"):
worker.tool_execution_started.disconnect()
worker.tool_execution_updated.disconnect()
worker.tool_execution_finished.disconnect()
worker.context_compacted.disconnect()
worker.usage_updated.disconnect()
if hasattr(worker, "retry_scheduled"):
worker.retry_scheduled.disconnect()
except Exception:
pass
worker.abort()
worker.finished.connect(worker.deleteLater)
msg_id = stream_state["msg_id"]
self.chat_bridge.show_error(msg_id, "🛑 已中断生成")
self.chat_bridge.finish_message(msg_id)
# 🆕 Fix B: 中断部分内容的持久化收拢到公共方法(与 closeEvent 共用)
self._persist_interrupted_stream(self.current_session_id, stream_state)
del self._active_streams[self.current_session_id]
self.set_send_button_state(False)
self.update_context_display()
print("\n[系统]: 🛑 已中断。")
# 🆕 Fix C: 输入框里若有新提问(用户是边中断边点发送的),
# 不再 return 吞掉 —— 直接落入下方正常发送流程(中断 + 发送一步完成)
if not (self.text_input.toPlainText().strip()
or [t for t in self._folded_texts if t is not None]):
return
# 正常发送消息流程
extra_text = self.text_input.toPlainText().strip()
valid_attachments = [t for t in self._folded_texts if t is not None]
if not extra_text and not valid_attachments:
return
self.text_input.clear()
self.clear_fold_state()
self.set_send_button_state(True)
user_msg_id = f"msg-{uuid.uuid4().hex}"
attachment_metadata_json = None
# 🌟 分离文本附件和图片附件 (多模态支持)
text_attachments_content = []
api_attachments_meta = []
if valid_attachments:
for att in valid_attachments:
if att["type"] == "text":
# 带文件名的附件在 prompt 里包一层来源标记,方便模型区分多文件
if att.get("name"):
text_attachments_content.append(f"[文件: {att['name']}]\n{att['content']}")
else:
text_attachments_content.append(att["content"])
meta_item = {
"type": "text",
"size_kb": att["size_kb"],
"lines": att["lines"],
"content": att["content"] # 🌟 补回 content 字段,防止前端 JS 截取预览时报错
}
if att.get("name"):
meta_item["name"] = att["name"] # 🌟 文件名存入元数据,前端卡片与历史重载用
api_attachments_meta.append(meta_item)
elif att["type"] == "image":
api_attachments_meta.append({
"type": "image",
"name": att["name"],
"mime": att["mime"],
"local_path": att["local_path"],
"size_kb": att.get("size_kb", 0), # 🌟 补上大小,防止 JS 出现 undefined KB
"lines": 1, # 🌟 补上行数,防止 JS 出现 undefined 行
"content": f"[图片文件: {att['name']}]" # 假 content 防报错
})
elif att["type"] == "pdf":
mode = att.get("mode", "text")
text_content = att.get("text_content", "")
if mode == "image":
# 图片解析模式: 文本与勾选的图片一起发送
if text_content:
text_attachments_content.append(f"[文件: {att['name']}]\n{text_content}")
# 卡片①:图片(用户勾选的)
api_attachments_meta.append({
"type": "pdf",
"mode": "image",
"name": att["name"],
"size_kb": att.get("size_kb", 0),
"pages": att.get("pages", 0),
"selected_images": att.get("selected_images", []),
"images": att.get("images", []), # 全部提取图片, 供清理物理文件
"local_path": att.get("local_path"), # PDF 本体拷贝, 供清理
"lines": len(att.get("selected_images", [])),
"content": f"[PDF 图片解析: {att['name']}]"
})
# 卡片②:文本(与图片分开成独立气泡显示)
if text_content:
api_attachments_meta.append({
"type": "pdf",
"mode": "text",
"name": att["name"],
"size_kb": att.get("size_kb", 0),
"pages": att.get("pages", 0),
"images": [],
"lines": text_content.count("\n") + 1,
"content": text_content
})
else:
# 文本模式: 结构化文本作为文本附件注入 prompt
text_attachments_content.append(f"[文件: {att['name']}]\n{text_content}")
api_attachments_meta.append({
"type": "pdf",
"mode": "text",
"name": att["name"],
"size_kb": att.get("size_kb", 0),
"pages": att.get("pages", 0),
"images": att.get("images", []), # 文本模式一般为空, 供清理
"local_path": att.get("local_path"),
"lines": text_content.count("\n") + 1,
"content": text_content
})
# 前端渲染展示 (传 meta 过去)
self.chat_bridge.create_user_message_with_attachments(user_msg_id, extra_text, api_attachments_meta)
attachment_metadata_json = json.dumps({
"user_text": extra_text,
"attachments": api_attachments_meta
}, ensure_ascii=False)
# 数据库里的 content 依然只存纯文本
all_text_attachments = "\n\n---\n\n".join(text_attachments_content)
if extra_text and all_text_attachments:
llm_text = extra_text + "\n\n" + all_text_attachments
elif all_text_attachments:
llm_text = all_text_attachments
else:
llm_text = extra_text
else:
self.chat_bridge.create_message(user_msg_id, "user", extra_text, "You")
llm_text = extra_text
# 🚀 1. 获取当前对话的尾巴节点
current_leaf_id = self.db.get_session_leaf(self.current_session_id)
# 🚀 2. 插入用户消息(它的父亲是旧的尾巴节点),插入后这只 User 成了新尾巴
self.db.add_message(
session_id=self.current_session_id,
role="user",
content=llm_text, # 纯文本部分
parent_id=current_leaf_id, # <--- 链表指针!
msg_id=user_msg_id,
attachment_metadata=attachment_metadata_json # 图片路径和文本元数据存入这里
)
self.db.mark_session_has_messages(self.current_session_id)
# 🆕 P1-01:用户消息进渲染窗口(已持久化,带真实链内下标)
self._rw_note_live(user_msg_id, self.current_session_id)
self._dbg(f"[send] model={self.current_model} mode={self._get_current_mode()}")
# 组装给大模型的 API 上下文(build_api_context 内部会去解析图片 local_path 并转为 base64 发给 API
messages = self.build_api_context(self.current_session_id)
ai_msg_id = f"msg-{uuid.uuid4().hex}"
# 🚀 正常发送产生的新回答,必定是这句提问的第 1 个分支
branch_info = {"current": 1, "total": 1}
self.chat_bridge.create_message(
ai_msg_id, "assistant", "",
self.current_model or "Assistant",
branch_info=branch_info # 🚀 传给前端
)
# 🆕 P1-01:助手占位进渲染窗口(未持久化 -1 降级;流式保护由 streaming 类自动生效)
self._rw_note_live(ai_msg_id, self.current_session_id)
self._ghost_msg_map[ai_msg_id] = user_msg_id
session_id = self.current_session_id
self._active_streams[session_id] = {
"msg_id": ai_msg_id,
"content": "",
"reasoning": "",
"timeline": [], # 🌟 agent 时间线(思考/文本/工具 按事件顺序)
"tl_kind": None, # 时间线当前段类型(think/text/tool
"parent_id": user_msg_id,
"branch_info": branch_info,
"worker": None,
"usage": None, # 🆕 P1: 本轮已收到的精确 usage(显示锚定)
"previous_leaf_id": user_msg_id # 🌟 核心修复:新提问的锚点就是提问本身
}
# 创建 worker(只创建一次)
# 🆕 模式分派:worker → agent 循环(工具/重试/压缩);chat → 普通聊天
mode = self._get_current_mode() or self.pending_mode
self._lock_session_mode() # 首条消息即锁定
worker = self._create_stream_worker(messages, mode)
self._active_streams[session_id]["worker"] = worker
# 用 lambda 绑定 session_id,让回调函数知道是哪个会话的数据
worker.chunk_received.connect(lambda token: self.on_chunk_received(session_id, token))
worker.reasoning_received.connect(lambda token: self.on_reasoning_received(session_id, token))
worker.error_occurred.connect(lambda err: self.on_error(session_id, err))
worker.finished.connect(lambda: self.on_reply_finished(session_id))
# 🆕 P1: 本轮精确 usagechat/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)
if self._stream_diagnostics:
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 体检均为诊断操作;尤其是 WebView2 grab()
# 会同步读回原生窗口,可能阻塞渲染器数百毫秒,普通运行绝不执行。
if self._stream_diagnostics:
if os.environ.get("HAOCODE_SHOT") == "1":
try:
self._shot_n = getattr(self, "_shot_n", 0) + 1
import os as _os
_shot_path = _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
f"diag_shot_{self._shot_n:02d}_FINISH.png")
self.browser.grab().save(_shot_path)
print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True)
except Exception as _e:
print(f"[画面] 完成截图失败: {_e}", flush=True)
def _finish_dom_probe(_res):
diag_log(f"FINISH_DOM {_res}")
print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True)
self.browser.page().runJavaScript(
f"probeStream('{msg_id}')", _finish_dom_probe)
# 🚀 入库,自动成为时间线新叶子!
if (stream_state["content"] or stream_state["reasoning"]
or stream_state.get("timeline")):
tl_json = (json.dumps(stream_state["timeline"], ensure_ascii=False)
if stream_state.get("timeline") else None)
# 🆕 P1: 保存本轮精确 usage(供下次显示/压缩估算做 usage 锚定)
_usage_json = None
try:
from core.agent import calculate_context_tokens as _calc_usage
if _calc_usage(stream_state.get("usage") or {}) > 0:
_usage_json = json.dumps(stream_state.get("usage"))
except Exception:
pass
self.db.add_message(
session_id=session_id,
role="assistant",
content=stream_state["content"],
parent_id=parent_id, # <--- 链表指针!
reasoning=stream_state["reasoning"],
msg_id=msg_id,
timeline=tl_json, # 🌟 时间线持久化(思考/文本/工具 按序)
usage=_usage_json # 🆕 P1: usage 锚点
)
print(f"[生命周期] DB 写入完成 mid={msg_id} timeline={len(_tl0)}条", flush=True)
# 🆕 P1-01:入库后刷新链内下标(仅当前会话推送;JS 侧同名校验)
self._rw_note_live(msg_id, session_id)
if self.db.check_session_needs_title(session_id):
self._generate_session_title(session_id)
del self._active_streams[session_id]
if session_id == self.current_session_id:
self.set_send_button_state(False)
self.update_context_display()
def _generate_session_title(self, session_id):
"""调用 LLM 生成会话标题"""
# 🌟 如果这个会话已经在生成标题了,跳过
if session_id in self._title_workers:
return
# 🚀 修复 Bug:使用新的链表查询方法 get_message_chain
messages = self.db.get_message_chain(session_id)
user_msg = next((m for m in messages if m["role"] == "user"), None)
assistant_msg = next((m for m in messages if m["role"] == "assistant"), None)
if not user_msg or not assistant_msg:
return
title_prompt = [
{"role": "system", "content": "你是一个标题生成助手。根据对话内容,生成一个6个字以内的简洁标题。只返回标题文本,不要其他内容。"},
{"role": "user", "content": f"用户:{user_msg['content'][:100]}\n助手:{assistant_msg['content'][:100]}"}
]
worker = TitleWorker(self.current_provider, self.current_model, title_prompt)
# 🌟 用字典存储,以 session_id 为 key
self._title_workers[session_id] = {
"worker": worker,
"accumulator": ""
}
# 🌟 用 lambda 绑定 session_id
worker.chunk_received.connect(lambda chunk: self._on_title_chunk(session_id, chunk))
worker.finished.connect(lambda: self._on_title_finished(session_id))
worker.start()
def _on_title_chunk(self, session_id, chunk):
"""累积标题 token"""
if session_id in self._title_workers:
self._title_workers[session_id]["accumulator"] += chunk
def _on_title_finished(self, session_id):
if session_id not in self._title_workers:
return
title_state = self._title_workers[session_id]
new_title = title_state["accumulator"].strip()[:20]
if new_title:
self.db.update_session_title(session_id, new_title)
# 🔴 原来: item.setText(new_title)
# 🟢 改为: 通过 widget 更新
for i in range(self.history_list.count()):
item = self.history_list.item(i)
if item.data(QtCore.Qt.ItemDataRole.UserRole) == session_id:
w = self.history_list.itemWidget(item)
if w and hasattr(w, 'set_title'):
w.set_title(new_title)
break
print(f"[System]: ✅ 会话标题已生成 -> {new_title}")
worker = title_state["worker"]
worker.deleteLater()
del self._title_workers[session_id]
def _on_title_generated(self, worker):
"""标题生成完成的回调"""
# 从 worker 中提取生成的标题
if hasattr(worker, '_accumulated_content'):
new_title = worker._accumulated_content.strip()[:20] # 最多 20 个字
if new_title:
# 更新数据库
self.db.update_session_title(self.current_session_id, new_title)
# 更新侧边栏 UI
for i in range(self.history_list.count()):
item = self.history_list.item(i)
if item.data(QtCore.Qt.ItemDataRole.UserRole) == self.current_session_id:
item.setText(new_title)
break
print(f"[System]: ✅ 会话标题已生成 -> {new_title}")
worker.deleteLater()
# ==================== 工具执行事件(pi tool_execution_* ====================
def _on_tool_started(self, session_id, call_id, name, args):
"""工具开始执行 → 前端在时间线当前位置插入执行 chip"""
print(f"[工具] 开始 name={name} call={call_id} session={session_id[:8]}", flush=True)
# 🆕 右侧任务面板(bash 才登记;只跟当前会话)
if session_id == self.current_session_id:
try:
self.bash_panel.on_started(call_id, name, args)
except Exception as _e:
print(f"[Warn]: 任务面板 on_started 失败: {_e}")
st = self._active_streams.get(session_id)
if not st:
return
if session_id == self.current_session_id:
self._schedule_context_refresh() # 🆕 Fix E: 工具显现 → 刷新上下文标签
# 🌟 时间线:工具条目(跨会话也记录,供持久化)
st["tl_kind"] = "tool"
st["timeline"].append({"t": "tool", "id": call_id, "name": name,
"args": args, "ok": None, "result": ""})
if session_id == self.current_session_id:
self.chat_bridge.tool_execution_started(st["msg_id"], call_id, name, args)
def _on_tool_updated(self, session_id, call_id, text):
"""工具执行中的增量输出"""
# 🆕 右侧任务面板:实时输出(独立缓冲,不受上游 4000 字符上限影响)
if session_id == self.current_session_id:
try:
self.bash_panel.on_output(call_id, text)
except Exception:
pass
st = self._active_streams.get(session_id)
if not st:
return
if session_id == self.current_session_id:
self._schedule_context_refresh() # 🆕 Fix E: 工具输出显现 → 刷新
# 🌟 时间线:按 call_id 累积工具输出(上限 4000 字符)
for e in reversed(st["timeline"]):
if e.get("t") == "tool" and e.get("id") == call_id:
e["result"] = (e.get("result", "") + text)[-4000:]
break
if session_id == self.current_session_id:
self.chat_bridge.tool_execution_updated(st["msg_id"], call_id, text)
def _on_tool_timed(self, session_id, call_id, elapsed, timeout):
"""🆕 bash 运行中每秒读秒 → 前端气泡刷新 N/Ts0/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.cmdshow/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 self._stream_diagnostics and 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._stream_diagnostics and (
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 控制台打印(仅显式诊断时开启)。
if self._stream_diagnostics:
try:
_mid = self._active_streams[session_id]["msg_id"]
_flat = " ".join(chunk.split())
print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True)
except Exception:
pass
# 只有当前激活的会话才推送到前端显示
if session_id == self.current_session_id:
msg_id = self._active_streams[session_id]["msg_id"]
self.chat_bridge.append_token(msg_id, chunk)
from PyQt6 import QtWidgets, QtCore
def add_session_item_to_sidebar(self, sess_data, at_top=False):
"""渲染一条会话到侧边栏(使用自定义 widget"""
item = QtWidgets.QListWidgetItem()
item.setData(QtCore.Qt.ItemDataRole.UserRole, sess_data["id"])
item.setSizeHint(QtCore.QSize(0, 44))
widget = SessionItemWidget(sess_data["id"], sess_data["title"])
widget.menu_requested.connect(self.show_session_context_menu)
is_starred = sess_data.get("is_starred", 0)
if at_top:
if is_starred:
self.history_list.insertItem(0, item)
else:
# 插到分隔线下方第一个位置
sep_row = self.history_list._find_separator_row()
insert_row = (sep_row + 1) if sep_row >= 0 else 0
self.history_list.insertItem(insert_row, item)
self.history_list.setItemWidget(item, widget)
self.history_list.setCurrentItem(item)
else:
self.history_list.addItem(item)
self.history_list.setItemWidget(item, widget)
def _rw_visible_chain(self, session_id):
"""🆕 P1-01:会话的可见消息链(过滤 system/compaction),旧→新。"""
chain = self.db.get_message_chain(session_id)
return [m for m in chain if m["role"] not in ("system", "compaction")]
def _rw_note_live(self, msg_id, expected_session=None):
"""🆕 P1-01:把新生成的消息推进渲染窗口。
已持久化 → 带真实链内下标 + 链长;未持久化/查询失败 → -1(安全降级)。
非当前会话的后台完成不推送(JS 侧状态属于当前会话)。"""
session = self.current_session_id
gen = self._rw_generation
if not session or gen <= 0:
return
if expected_session and expected_session != session:
return
chain_index, chain_len = -1, -1
try:
visible = self._rw_visible_chain(session)
chain_len = len(visible)
for i, m in enumerate(visible):
if m["id"] == msg_id:
chain_index = i
break
except Exception:
pass
self.chat_bridge.rw_note_live(session, gen, msg_id, chain_index, chain_len)
def _render_history_one(self, msg):
"""🆕 P1-01:渲染单条历史消息(自 load_messages_to_web 抽取;分页复用)。
渲染分发逻辑与原版完全一致(附件补齐/时间线/降级)。"""
import json
branch_info = None
if msg["role"] == "assistant" and msg.get("parent_id"):
siblings = self.db.get_branch_info(msg["parent_id"])
if len(siblings) > 1:
try:
sibling_ids = [s["id"] for s in siblings]
idx = sibling_ids.index(msg["id"]) + 1
branch_info = {"current": idx, "total": len(siblings)}
except (ValueError, KeyError):
branch_info = None
if msg["role"] == "user" and msg.get("attachment_metadata"):
try:
metadata = json.loads(msg["attachment_metadata"])
user_text = metadata.get("user_text", "")
attachments = metadata.get("attachments", [])
for att in attachments:
if "content" not in att:
if att.get("type") == "image":
att["content"] = f"[图片文件: {att.get('name', '未知图片')}]"
else:
att["content"] = "[附件内容]"
if "size_kb" not in att:
att["size_kb"] = 0
if "lines" not in att:
att["lines"] = 1
self.chat_bridge.create_user_message_with_attachments(
msg["id"], user_text, attachments)
except (json.JSONDecodeError, TypeError):
self.chat_bridge.render_history_message(
msg_id=msg["id"],
role=msg["role"],
content=msg["content"],
reasoning=msg.get("reasoning", ""),
branch_info=branch_info)
else:
tl_json = msg.get("timeline") if msg["role"] == "assistant" else None
if tl_json:
self.chat_bridge.create_message(
msg_id=msg["id"], role=msg["role"],
text=msg["content"],
sender_name=self.current_model or "Assistant",
branch_info=branch_info)
self.chat_bridge.render_timeline_history(msg["id"], tl_json)
self.chat_bridge.finish_message(msg["id"])
else:
self.chat_bridge.render_history_message(
msg_id=msg["id"],
role=msg["role"],
content=msg["content"],
reasoning=msg.get("reasoning", ""),
branch_info=branch_info)
def _on_window_page_request(self, session_id, direction,
boundary_msg_id, generation):
"""🆕 P1-01:前端换页请求 → 按边界消息在 DB 切片 → 渲染页 → 推送响应。
边界失效(并发数据变更)→ 空页安全降级,计数仍正确收敛。"""
if self._rw_page_inflight:
return # 最多一页在途(JS 侧 pending 同效,双保险)
if session_id != self.current_session_id or generation != self._rw_generation:
return # 会话已切 / 代次已推进 → 丢弃
if direction not in ("older", "newer"):
return
self._rw_page_inflight = True
try:
visible = self._rw_visible_chain(session_id)
chain_len = len(visible)
ids = [m["id"] for m in visible]
try:
bi = ids.index(boundary_msg_id)
except ValueError:
bi = -1
size = self._rw_page_size # 半窗页(非整窗):锚点消息必须留在窗口内
if bi < 0:
page, page_indices = [], []
elif direction == "older":
start = max(0, bi - size)
page = visible[start:bi]
page_indices = list(range(start, bi))
else:
start = bi + 1
page = visible[start:start + size]
page_indices = list(range(start, start + len(page)))
# 批次渲染期间抑制 createMessage/finishMessage 的滚动副作用
self.chat_bridge.run_js("window.__rwPageRendering = true;")
for m in page:
self._render_history_one(m)
self.chat_bridge.rw_page_response({
"sessionId": session_id,
"generation": generation,
"boundaryId": boundary_msg_id,
"direction": direction,
"chainLen": chain_len,
"items": [{"id": m["id"], "chainIndex": ix}
for m, ix in zip(page, page_indices)],
})
self.chat_bridge.run_js("window.__rwPageRendering = false;")
except Exception as e:
print(f"[RW] 分页处理异常: {e}")
self.chat_bridge.run_js("window.__rwPageRendering = false;")
finally:
self._rw_page_inflight = False
def load_messages_to_web(self, session_id, show_loading=True):
"""加载消息链到 WebView(🆕 P1-01:窗口化——初始仅渲染最新 size 条)"""
import json
# 🆕 P1-01:每次加载递增代次,旧会话的在途分页响应一律 stale
self._rw_generation += 1
gen = self._rw_generation
self.current_session_id = session_id
self._refresh_mode_button() # 🆕 会话切换 → 刷新模式按钮
# 🆕 右侧任务面板:跟随会话切换(范围永远是当前会话)
try:
self.bash_panel.set_session(
session_id, self.db, self._active_streams.get(session_id))
except Exception as _e:
print(f"[Warn]: 任务面板刷新失败: {_e}")
# 🌟 统一加载界面:切换会话/启动时盖加载层;内部刷新(重新回答/分支/删除)不触发
if show_loading:
self.chat_bridge.show_loading()
# 🆕 P1-01rwBegin 内部执行 clearChat + (session, generation) 重同步
visible = self._rw_visible_chain(session_id)
total = len(visible)
self.chat_bridge.rw_begin(session_id, gen, total)
# 2. 处理空对话状态
if not visible:
self.chat_bridge.show_welcome()
self._update_send_button_state()
if show_loading:
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
return
# 3. 窗口化渲染:仅最新 size 条(🆕 P1-01);更早消息经换页按需加载
window_items = visible[-self._rw_size:] if self._rw_size > 0 else visible
# 批次渲染期间抑制逐条滚底(初始窗口完成后由前端统一对齐底部)
self.chat_bridge.run_js("window.__rwPageRendering = true;")
for msg in window_items:
self._render_history_one(msg)
# 链内下标 = 窗口在链尾的偏移 + 窗口内序号(不是局部下标!)
offset = total - len(window_items)
self.chat_bridge.rw_init_window(
session_id, gen, total,
[(m["id"], offset + i) for i, m in enumerate(window_items)])
self.chat_bridge.run_js("window.__rwPageRendering = false;")
# 5. 恢复流式输出状态(处理页面刷新或切换回正在生成的会话)
#🌟 如果该会话有正在进行的流式输出,每次都重新推送
if session_id in self._active_streams:
stream_state = self._active_streams[session_id]
msg_id = stream_state["msg_id"]
# 🚀 修复点:恢复时也要带上缓存的 branch_info
branch_info = stream_state.get("branch_info")
self.chat_bridge.create_message(
msg_id, "assistant", "",
self.current_model or "Assistant",
branch_info=branch_info # 🚀 传给前端
)
# 🆕 P1-01:切回复活的流式消息进渲染窗口(已入库则带真实下标)
self._rw_note_live(msg_id, session_id)
# 🌟 有 agent 时间线 → 按序恢复 思考/文本/工具 块(后续 token 无缝续流)
if stream_state.get("timeline"):
diag_log(f"RESTORE mid={msg_id} entries={len(stream_state['timeline'])} "
f"text={len(stream_state.get('content',''))}c think={len(stream_state.get('reasoning',''))}c")
self.chat_bridge.restore_streaming_timeline(
msg_id, json.dumps(stream_state["timeline"], ensure_ascii=False))
else:
# 恢复已生成的推理内容
if stream_state.get("reasoning"):
reasoning_text = stream_state["reasoning"]
for i in range(0, len(reasoning_text), 500):
self.chat_bridge.append_reasoning(msg_id, reasoning_text[i:i+500])
# 恢复已生成的正文内容
if stream_state.get("content"):
content_text = stream_state["content"]
for i in range(0, len(content_text), 500):
self.chat_bridge.append_token(msg_id, content_text[i:i+500])
# 6. 更新 UI 状态
self._update_send_button_state()
self.update_context_display()
# 🌟 绘制完成后,渐变退场露出会话(图标扫描条 → 渐隐)
if show_loading:
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
def _update_send_button_state(self):
"""根据当前会话是否在生成,更新发送按钮状态"""
is_generating = self.current_session_id in self._active_streams
self.set_send_button_state(is_generating)
def on_sidebar_item_clicked(self, item):
# 🌟 点击分隔线无效
if self.history_list._is_separator(item):
return
session_id = item.data(QtCore.Qt.ItemDataRole.UserRole)
if not session_id:
return
print(f"[UI]: 切换到历史对话 -> {session_id}")
self.load_messages_to_web(session_id)
def init_model_popup(self):
# 1. 读取配置(P0-01 统一入口:缺失/损坏 → 可见警告 + 安全默认值)
from core.config_paths import load_config as _load_cfg
self.config_data = _load_cfg() or {"providers": {}}
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.config_data = dict(self.config_data)
self.config_data.setdefault("default_provider", default_p)
self.config_data.setdefault("default_model", default_m)
# 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. 设置默认选中
self.on_model_selected(default_p, default_m)
def show_model_popup(self):
"""刷新模型列表,在当前屏幕内计算位置并显示。"""
from core.config_paths import load_config as _load_cfg
latest_config = _load_cfg() or {}
latest_config = dict(latest_config)
latest_config.setdefault("default_provider", self.current_provider or "GPTBest")
latest_config.setdefault(
"default_model", self.current_model or "gemini-3.1-pro-preview-thinking-high")
self.config_data = latest_config
self.model_popup.reload_config(latest_config)
self.model_popup.mark_selected(self.current_provider, self.current_model)
# 获取按钮在屏幕上的全局坐标
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
screen = QtGui.QGuiApplication.screenAt(btn_pos) or self.screen()
if screen is not None:
area = screen.availableGeometry()
x = max(area.left(), min(x, area.right() - self.model_popup.width() + 1))
if y < area.top():
y = btn_pos.y() + self.model_selector.height() + 5
y = max(area.top(), min(y, area.bottom() - self.model_popup.height() + 1))
# 🌟 调用动画显示方法,传入目标坐标
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_settings_config_changed(self, config_data):
"""设置页保存后同步刷新主窗口和模型弹窗。"""
if not isinstance(config_data, dict):
return
self.config_data = copy.deepcopy(config_data)
self.model_popup.reload_config(self.config_data)
self.model_popup.mark_selected(
self.current_provider, self.current_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._stream_diagnostics and (
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 控制台打印(仅显式诊断时开启)。
if self._stream_diagnostics:
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 引擎已就绪,正在加载历史记录...")
if self._stream_diagnostics:
self.chat_bridge.run_js(
"if (window.setStreamDiagnostics) window.setStreamDiagnostics(true);")
# 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载
if not self._rw_config_pushed:
self._rw_config_pushed = True
self.chat_bridge.rw_config(self._rw_mode, self._rw_size)
self.load_messages_to_web(self.current_session_id)
# 🌟 触发前端上报一次初始滚动状态,同步自定义滚动条
self.chat_bridge.run_js("setTimeout(reportWebScroll, 60);")
else:
# 还没就绪,100ms 后重试
QtCore.QTimer.singleShot(100, lambda: self._check_js_ready(retry_count + 1))
def on_new_chat_clicked(self):
# 🆕 P1-01:代次 +1(旧会话的在途分页响应失效)
self._rw_generation += 1
new_sess = self.db.create_session(title="新对话")
print(f"[UI]: 新建对话 -> {new_sess['id']}")
self.current_session_id = new_sess["id"]
self._refresh_mode_button() # 🆕 新建会话 → 未锁定,恢复可选状态
# 🌟 统一加载界面:新建会话同样走 加载 → 渐变 → 欢迎页 流程
self.chat_bridge.show_loading()
# 重建侧边栏(自动处理分隔线和插入位置)
self.rebuild_sidebar()
self.chat_bridge.clear_chat()
self.chat_bridge.show_welcome()
self._update_send_button_state()
self.update_context_display()
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
def build_api_context(self, session_id):
"""
🚀 构建上下文:读取本地图片文件,实时转为 Base64 发给大模型,彻底解放数据库!
"""
import json
import os
import base64
messages = self.db.get_message_chain(session_id)
api_messages = []
# 🆕 压缩持久化:链上最后一个 compaction 标记 = 切点。
# 它之前的一切只以摘要形式发送(作为一条 user 消息,与轮内压缩
# 的 to_openai_messages 渲染一致);标记保留在库中,UI 渲染照常。
_mark_idx = -1
for _i, _m in enumerate(messages):
if _m["role"] == "compaction":
_mark_idx = _i
if _mark_idx >= 0:
_mark = messages[_mark_idx]
api_messages.append({
"role": "user",
"content": _mark["content"],
"_kind": "compaction_summary",
"_db_msg_id": _mark["id"],
# 🆕 Fix F: 摘要条目必须带时间戳(标记插入时刻,晚于全部保留行)。
# 缺了它 P0 锚点时效校验失效:保留行里 assistant 的入库 usage 是
# 压缩前快照(如 92.7k),显示/压缩判定会锚到过期值 → 标签虚高,
# 小窗口模型还会误触发二次压缩(摘要套摘要)。
"timestamp": int(_mark.get("created_at", 0) or 0) * 1000,
})
messages = messages[_mark_idx + 1:]
# 获取项目根目录
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_dir, '..', '..'))
for msg in messages:
if msg["is_ignored"]:
continue
# 🆕 失败轮次·全空行:入库留痕但**不回放**(对照 pi transform-messages
# 跳过 error/aborted 的 assistant;空 assistant 内容会让个别服务商 400)
if msg.get("role") == "assistant":
_tl = (msg.get("timeline") or "").strip()
_has_tl = _tl not in ("", "[]", "null")
if (not (msg.get("content") or "").strip() and not _has_tl
and not (msg.get("reasoning") or "").strip()):
continue
content_payload = msg["content"]
# 🌟 核心:如果是用户消息且带有附件元数据,检查是否有图片
if msg["role"] == "user" and msg.get("attachment_metadata"):
try:
meta = json.loads(msg["attachment_metadata"])
# 收集普通图片附件 + PDF 图片解析模式下用户勾选的图片
images = []
for a in meta.get("attachments", []):
if a.get("type") == "image":
images.append(a)
elif a.get("type") == "pdf" and a.get("mode") == "image":
for sel in a.get("selected_images", []):
if sel.get("local_path"):
images.append({
"local_path": sel["local_path"],
"mime": sel.get("mime", "image/png"),
})
if images:
# 发现图片!将 content 转换为 OpenAI 视觉接口要求的 List 格式
content_payload = [{"type": "text", "text": msg["content"]}]
for img in images:
local_path = img.get('local_path')
if not local_path: continue
# 拼接出图片的绝对路径并实时读取转码
# 拼接出图片的绝对路径并实时读取转码
abs_path = os.path.join(root_dir, local_path)
if os.path.exists(abs_path):
with open(abs_path, "rb") as f:
b64_data = base64.b64encode(f.read()).decode('utf-8')
content_payload.append({
"type": "image_url",
"image_url": {
"url": f"data:{img['mime']};base64,{b64_data}"
}
})
else:
print(f"[Warn]: 找不到图片文件,可能已被物理删除: {abs_path}")
except Exception as e:
print(f"[Warn]: 解析图片附件失败: {e}")
# 🌟 AI 消息带 agent 时间线 → 重建完整上下文(含 tool_calls 与工具结果),
# 让模型跨轮次看得到自己用过的工具和输出(对照 pi 全量上下文语义)
if msg["role"] == "assistant" and msg.get("timeline"):
try:
tl = json.loads(msg["timeline"])
except Exception:
tl = None
if isinstance(tl, list) and tl:
# 🆕 P0/P1: 回放本行时间戳(created_at 秒→毫秒)与 usage(锚点)
row_ts = int(msg.get("created_at", 0) or 0) * 1000
row_usage = None
if msg.get("usage"):
try:
row_usage = json.loads(msg["usage"])
except Exception:
row_usage = None
for e in tl:
et = e.get("t")
if et == "think":
continue
if et == "text":
api_messages.append(
{"role": "assistant", "content": e.get("text", ""),
"timestamp": row_ts, "_db_msg_id": msg["id"]})
elif et == "tool":
tcid = e.get("id") or f"tl{len(api_messages)}"
if api_messages and api_messages[-1].get("role") == "assistant":
base = api_messages[-1]
else:
base = {"role": "assistant", "content": None}
api_messages.append(base)
base["timestamp"] = row_ts
base["_db_msg_id"] = msg["id"]
base.setdefault("tool_calls", []).append({
"id": tcid,
"type": "function",
"function": {"name": e.get("name", ""),
"arguments": e.get("args") or "{}"},
})
_res = e.get("result", "")
if e.get("ok") is None:
# 🆕 孤儿工具调用(开始了但没拿到结果 = 本轮异常中断):
# 对照 pi insertSyntheticToolResults —— 补一条合成结果,
# 保证 tool_call/tool_result 成对,否则服务商直接 400
_res = "(本轮请求异常中断,未收到该工具的结果)"
api_messages.append({
"role": "tool", "tool_call_id": tcid,
# 长结果 UI 存 2 万字供展开;回灌模型限 4000 字防上下文爆炸
"content": _res[:4000],
"timestamp": row_ts, "_db_msg_id": msg["id"],
})
# 🆕 P1: 本行 usage 挂到本行最后一条 assistant 上(usage 锚点)
if row_usage:
for _entry in reversed(api_messages):
if _entry.get("role") == "assistant":
_entry["usage"] = row_usage
break
continue
# P0 修复: 不再向 API 注入 reasoning 字段
# (OpenAI 兼容接口不认该字段,部分服务端直接 400;
# 思考过程仅用于本地展示与入库)
entry = {"role": msg["role"], "content": content_payload,
# 🆕 P0: 回放本行时间戳(供 usage 锚点时效校验)
"timestamp": int(msg.get("created_at", 0) or 0) * 1000,
"_db_msg_id": msg["id"]}
# 🆕 P1: assistant 行有入库 usage → 回放(显示/agent 做 usage 锚定)
if msg["role"] == "assistant" and msg.get("usage"):
try:
entry["usage"] = json.loads(msg["usage"])
except Exception:
pass
api_messages.append(entry)
return api_messages
def closeEvent(self, event):
"""窗口关闭前,清理所有正在运行的 worker 线程"""
print("[System]: 正在清理后台任务...")
# 1. 停止所有正在运行的会话生成任务
for session_id in list(self._active_streams.keys()):
stream_state = self._active_streams[session_id]
# 🆕 Fix B: 关窗 ≈ 中断,先把进行中的部分内容落库(防止蒸发)
self._persist_interrupted_stream(session_id, stream_state)
worker = stream_state.get("worker")
if worker:
try:
worker.chunk_received.disconnect()
worker.error_occurred.disconnect()
worker.reasoning_received.disconnect()
worker.finished.disconnect()
if hasattr(worker, "tool_execution_started"):
worker.tool_execution_started.disconnect()
worker.tool_execution_updated.disconnect()
worker.tool_execution_finished.disconnect()
worker.context_compacted.disconnect()
worker.usage_updated.disconnect()
if hasattr(worker, "retry_scheduled"):
worker.retry_scheduled.disconnect()
except Exception:
pass
worker.abort()
worker.wait(1000)
if worker.isRunning():
worker.terminate()
worker.wait()
self._active_streams.clear()
# 🌟 2. 停止所有标题生成任务
for session_id in list(self._title_workers.keys()):
title_state = self._title_workers[session_id]
worker = title_state.get("worker")
if worker:
try:
worker.chunk_received.disconnect()
worker.finished.disconnect()
except Exception:
pass
worker.cancel()
worker.wait(1000)
if worker.isRunning():
worker.terminate()
worker.wait()
self._title_workers.clear()
# 🌟 停止全局热键监听线程(注销 Alt+S)
if hasattr(self, "hotkey_screenshot"):
self.hotkey_screenshot.stop()
print("[System]: ✅ 清理完成")
event.accept()
def _get_current_max_context(self):
"""获取当前选中模型的最大上下文 (token 数)"""
try:
provider_info = self.config_data["providers"][self.current_provider]
return provider_info.get("model_contexts", {}).get(self.current_model, 128000)
except (KeyError, TypeError):
return 128000
def update_context_display(self):
max_tokens = self._get_current_max_context()
max_k = max_tokens // 1000
if not self.current_session_id:
self.context_label.setText(f"0 / {max_k}k")
self.context_label.setStyleSheet("color: #888; font-size: 12px; margin-right: 10px;")
return
messages = self.build_api_context(self.current_session_id)
# 🆕 G3(替代 Fix F):统一「下一请求」口径 —— 与压缩判定(should_compact
# 同一公式:
# - 有有效锚点(轮中内存 / 纯文本行)→ provider 实测值(含 system+tools
# - 工具行的入库 usage 是内存快照(未截断)≠ 下一请求(4k 截断回放)
# → G1 自动失效其锚点 → 全量公式
# - 全量公式计入 system 提示词 + 工具 schemaworker 模式),
# 度量下一请求真实载荷(旧 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):
existing = getattr(self, '_settings_win', None)
if existing is not None:
existing._close(immediate=True)
if hasattr(self, "model_popup"):
self.model_popup.hide()
self._settings_win = SettingsWindow(
parent=self,
config_data=self.config_data
)
self._settings_win.config_changed.connect(
self._on_settings_config_changed)
self._settings_win.model_selected.connect(self.on_model_selected)
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())