fix(ui): main window fixes - event filter, bash layer order, scrollbar styles, rename overlay

- merge duplicated eventFilter paths and gate send-by-Enter (P0-02)
- display bash task layers in reverse start-time order while reusing
  layer instances and preserving scroll/expand state (P2-01)
- add scoped 8px scrollbars with matching corner for the bash code box
  (P2-02)
- rewrite RenameOverlay as a top-level transparent tool window so it
  can cover the native WebView2 child HWND (P2-03)
This commit is contained in:
2026-09-17 16:40:04 +08:00
parent ef00f64435
commit 95024785ae
3 changed files with 592 additions and 182 deletions
+195
View File
@@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
"""tests/test_main_window_event_filter.py —— P0-02 事件策略回归
运行: python tests/test_main_window_event_filter.py
(仓库惯例:无 pytest 依赖,独立可跑;GUI 走 offscreen
完成证据(REPAIR_BACKLOG.md P0-02):
1. AST 静态断言:MainWindow 只有一个 eventFilter
2. 四种键盘状态:Enter 可发送 / 按钮禁用时 Enter 不发送 / 流式生成时 Enter 被拦截 /
Shift+Enter 放行换行;
3. 一次按键对应最多一次 send_message 调用(计数 wrapper 验证);
4. 其他键与事件继续交给父类(按 a 正常插入字符、不触发发送)。
遵守 P0-01:临时配置 + 临时数据库,在 import MainWindow 之前完成。
"""
import ast
import os
import sys
import time
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
sys.path.insert(0, _ROOT)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from tests._test_env import isolate # noqa: E402
_TMP = isolate("winfilter") # 必须在 import MainWindow 之前
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name +
("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
# ======================================================================
# 1) AST 静态断言:MainWindow 只有一个 eventFilter
# ======================================================================
with open(os.path.join(_ROOT, "ui", "views", "main_window.py"),
encoding="utf-8") as f:
_tree = ast.parse(f.read())
_mw = [n for n in _tree.body
if isinstance(n, ast.ClassDef) and n.name == "MainWindow"][0]
_ef_lines = [n.lineno for n in _mw.body
if isinstance(n, ast.FunctionDef) and n.name == "eventFilter"]
check("ASTMainWindow 只有一个 eventFilter", len(_ef_lines) == 1,
f"行号: {_ef_lines}")
# ======================================================================
# 2) 四种键盘状态(真实事件派发:QApplication.sendEvent → 已安装过滤器 → 控件本身)
# ======================================================================
from PyQt6 import QtGui, QtCore # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
import core.llm_engine as le # noqa: E402
app = QApplication.instance() or QApplication(sys.argv)
win = MainWindow()
if not win.current_session_id:
win.on_new_chat_clicked()
sid = win.current_session_id
check("前置:存在当前会话", bool(sid))
# 计数 wrapper:包住真实 send_message(记录调用并透传执行)
calls = []
_orig_send = win.send_message
def _spy_send(*a, **k):
calls.append(k)
return _orig_send(*a, **k)
win.send_message = _spy_send
def _press(key, shift=False, text=""):
"""通过 Qt 事件系统向输入框派发一次真实的 KeyPress。"""
mods = (QtCore.Qt.KeyboardModifier.ShiftModifier if shift
else QtCore.Qt.KeyboardModifier.NoModifier)
ev = QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, key, mods, text)
QApplication.sendEvent(win.text_input, ev)
app.processEvents()
def _clear_stream():
"""确保当前会话没有残留流(等待 worker 错误自清理,兜底手工清理)。"""
for _ in range(100):
if sid not in win._active_streams:
return
app.processEvents()
time.sleep(0.05)
st = win._active_streams.pop(sid, None)
if st and st.get("worker") is not None:
try:
st["worker"].abort()
st["worker"].wait(2000)
except Exception:
pass
win.set_send_button_state(False)
# ---- A) Enter 可发送(空闲 + 按钮可用 + 有文本) ----
# 网络层桩:立即抛 ConnectionError(等价于不可达端口),走真实错误清理路径
_REAL_OPENAI_STREAM = le.openai_stream
def _fake_openai_stream(*a, **k):
raise ConnectionError("test stub: no network")
yield # 保持生成器函数形态
le.openai_stream = _fake_openai_stream
win.btn_send.setEnabled(True)
win.text_input.setPlainText("p002 enter send")
calls.clear()
_press(QtCore.Qt.Key.Key_Return)
check("A1 一次 Enter 至多一次 send_message 调用", len(calls) == 1,
f"调用次数: {len(calls)}")
check("A2 Enter 走 from_enter 规则", calls and calls[0].get("from_enter") is True,
f"kwargs: {calls}")
check("A3 发送已同步执行(流已注册)", sid in win._active_streams)
check("A4 输入框被清空", win.text_input.toPlainText() == "")
_clear_stream()
check("A5 流清理完毕(错误路径自恢复)", sid not in win._active_streams)
le.openai_stream = _REAL_OPENAI_STREAM
# ---- B) 发送按钮禁用时 Enter 不发送 ----
_clear_stream()
win.btn_send.setEnabled(False)
win.text_input.setPlainText("disabled should not send")
calls.clear()
_press(QtCore.Qt.Key.Key_Return)
check("B1 禁用时仍至多一次调用(规则在 send_message 内单一实现)",
len(calls) <= 1, f"调用次数: {len(calls)}")
check("B2 禁用时未发送(无流)", sid not in win._active_streams)
check("B3 禁用时输入内容保留", win.text_input.toPlainText() == "disabled should not send")
win.btn_send.setEnabled(True)
# ---- C) 流式生成时 Enter 被拦截(不发送、不触发中断) ----
win.set_send_button_state(True) # 与真实流式状态一致(停止图标,按钮仍可用)
_fake_stream = {"msg_id": "fake-ai", "content": "", "worker": None, "timeline": [],
"parent_id": None, "branch_info": {"current": 1, "total": 1}}
win._active_streams[sid] = _fake_stream
win.text_input.setPlainText("typing while generating")
calls.clear()
_press(QtCore.Qt.Key.Key_Return)
check("C1 流式时未产生真实发送(流仍是注入的假流)",
win._active_streams.get(sid) is _fake_stream)
check("C2 流式时未触发中断(假流字段未被改动)",
_fake_stream["msg_id"] == "fake-ai" and _fake_stream["worker"] is None)
check("C3 流式时输入内容保留", win.text_input.toPlainText() == "typing while generating")
del win._active_streams[sid]
win.set_send_button_state(False)
# ---- D) Shift+Enter 放行换行 ----
win.text_input.setPlainText("abc")
_cur = win.text_input.textCursor()
_cur.movePosition(QtGui.QTextCursor.MoveOperation.End)
win.text_input.setTextCursor(_cur)
calls.clear()
_press(QtCore.Qt.Key.Key_Return, shift=True)
check("D1 Shift+Enter 未调用 send_message", len(calls) == 0,
f"调用次数: {len(calls)}")
check("D2 Shift+Enter 插入换行(事件放行到输入框)",
win.text_input.toPlainText() == "abc\n",
repr(win.text_input.toPlainText()))
check("D3 Shift+Enter 未发送(无流)", sid not in win._active_streams)
# ---- E) 其他键/事件继续交给父类 ----
win.text_input.setPlainText("")
calls.clear()
_press(QtCore.Qt.Key.Key_A, text="a")
check("E1 普通字符键正常插入", win.text_input.toPlainText() == "a",
repr(win.text_input.toPlainText()))
check("E2 普通字符键不触发发送", len(calls) == 0)
# ---- 收尾 ----
try:
win.close()
except Exception:
pass
app.quit()
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
os._exit(0 if ok else 1) # 避免 QtWebEngine offscreen 静态析构段错误(不影响结果)
+21 -9
View File
@@ -6,7 +6,10 @@
中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px, 中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px,
展开区放不下「参数 + 输出」两块列表,故用上下两栏)。 展开区放不下「参数 + 输出」两块列表,故用上下两栏)。
若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。 若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。
· 一次 bash = 一层(BashLayer),自上而下按执行先后排列 · 一次 bash = 一层(BashLayer),自上而下按【启动先后倒序】排列(P2-01
最新启动的任务在第一项;排序键恒为启动顺序,运行中→完成归位时
不按完成时间重排;已落库时间线无显式时间时,启动序号 = 消息链
顺序 + 时间线内顺序,稳定可复现)
· 单击层头 = 展开/收起;展开后分「参数」「输出」两块 · 单击层头 = 展开/收起;展开后分「参数」「输出」两块
· 运行中:输出为 tool_bash 的实时流(缓冲上限 200KB,超出丢弃最旧并标注) · 运行中:输出为 tool_bash 的实时流(缓冲上限 200KB,超出丢弃最旧并标注)
· 已完成:输出为「进入上下文的原文」(DB messages.timeline[].result,不截断) · 已完成:输出为「进入上下文的原文」(DB messages.timeline[].result,不截断)
@@ -35,12 +38,14 @@ PANEL_W_DEFAULT = 260 # 首次运行的默认展开宽度
PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了) PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了)
PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束) PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束)
_CFG_KEY = "bash_panel_width" _CFG_KEY = "bash_panel_width"
_CFG_PATH = os.path.join(_ROOT, "data", "config.json")
# P0-01:配置路径统一走 core.config_paths(环境变量 HAOCODE_CONFIG_FILE 优先)
from core.config_paths import config_path as _unified_config_path # noqa: E402
def _cfg_path() -> str: def _cfg_path() -> str:
"""配置文件路径(测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)""" """配置文件路径(P0-01 统一入口:测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)"""
return os.environ.get("HAOCODE_CONFIG_FILE") or _CFG_PATH return _unified_config_path()
def load_panel_width(default: int = PANEL_W_DEFAULT) -> int: def load_panel_width(default: int = PANEL_W_DEFAULT) -> int:
@@ -539,7 +544,7 @@ class BashPanel(QtWidgets.QWidget):
self._session_id = None self._session_id = None
self._db = None self._db = None
self._layers = {} # call_id -> BashLayer self._layers = {} # call_id -> BashLayer
self._order = [] # 执行先后 self._order = [] # 稳定启动序号(0=最早启动;追加顺序 = 启动先后
self._running = set() self._running = set()
self._done = set() self._done = set()
self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复) self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复)
@@ -886,11 +891,17 @@ class BashPanel(QtWidgets.QWidget):
self._refresh() self._refresh()
def _refresh(self): def _refresh(self):
# P2-01:两栏都按【启动顺序倒序】显示(最新启动在第一项)。
# 排序键恒为 self._order 中的位置(稳定启动序号)——
# · 运行中→已完成 的任务仍占原启动位置(绝不按完成时间重排);
# · 已完成栏的限量窗口 = 最近启动的 LAYER_LIMIT 个(窗口成员不变,仅显示顺序反转)。
# 重排走 set_layers(复用同一批 BashLayer 实例):展开/折叠状态、实时输出、
# 代码框水平/垂直滚动值、两栏 section 滚动位置全部保持(同对象,不重建)。
run_ids = [c for c in self._order if c in self._running] run_ids = [c for c in self._order if c in self._running]
done_ids = [c for c in self._order if c in self._done] done_ids = [c for c in self._order if c in self._done]
self.sec_running.set_layers([self._layers[c] for c in run_ids]) self.sec_running.set_layers([self._layers[c] for c in reversed(run_ids)])
shown = done_ids[-LAYER_LIMIT:] shown = done_ids[-LAYER_LIMIT:]
self.sec_done.set_layers([self._layers[c] for c in shown], total=len(done_ids)) self.sec_done.set_layers([self._layers[c] for c in reversed(shown)], total=len(done_ids))
self.lbl_total.setText(f"{len(self._order)}" if self._order else "") self.lbl_total.setText(f"{len(self._order)}" if self._order else "")
# ---------------- 事件 ---------------- # ---------------- 事件 ----------------
@@ -937,8 +948,9 @@ class BashPanel(QtWidgets.QWidget):
# ---------------- 测试/调试辅助 ---------------- # ---------------- 测试/调试辅助 ----------------
def layer_ids(self, which="all"): def layer_ids(self, which="all"):
# running/done 返回真实显示顺序(启动倒序);"all" = 原始启动序号(正序)
if which == "running": if which == "running":
return [c for c in self._order if c in self._running] return [c for c in self._order if c in self._running][::-1]
if which == "done": if which == "done":
return [c for c in self._order if c in self._done] return [c for c in self._order if c in self._done][::-1]
return list(self._order) return list(self._order)
+376 -173
View File
@@ -23,14 +23,19 @@ def diag_log(line: str):
pass pass
import uuid # <--- 🌟 加上这一行! import uuid # <--- 🌟 加上这一行!
from ui.views.chat_bridge import ChatBridge # 🌟 新增:引入桥接器 from ui.views.chat_bridge import ChatBridge # 🌟 新增:引入桥接器
from core import renderer_backend as _renderer_backend # P1-03 渲染器矩阵(纯 stdlib
# 🆕 WebView2 后端(A 双轨:Windows 首选,任何失败自动回落 QtWebEngine # 🆕 WebView2 后端(A 双轨:Windows 首选,任何失败自动回落 QtWebEngine
try: # P1-03:非 Windows 永不导入 core.webview2(不触达 pythonnet/Win32 API/WebView2 DLL/taskkill
from core import webview2 as _wv2mod _wv2mod = None
except Exception as _wv2_imp_err: # pythonnet 缺失 / 非 Windows 等 if sys.platform == "win32":
_wv2mod = None try:
print(f"[WV2] 后端不可用(回落 QtWebEngine: {_wv2_imp_err}") 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.screen_capture import ScreenCaptureOverlay # 🌟 截图覆盖层
from ui.views.system_tools.global_hotkey import GlobalHotkeyThread # 🌟 全局热键监听 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.db_manager import DBManager
from core.debug_log import debug_log, poll_debug_cmd # 🆕 调试窗口文件协议(独立于项目树) 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.system_tools.file_reader import read_text_file, BINARY_EXTS # 🌟 文本/代码文件读取(编码探测+二进制探测)
@@ -1356,14 +1361,27 @@ class SessionContextPopup(QtWidgets.QWidget):
self._anim_group.start() self._anim_group.start()
class RenameOverlay(QtWidgets.QWidget): class RenameOverlay(QtWidgets.QWidget):
"""无边框重命名 — 主窗口内模态覆盖层""" """无边框重命名 — 独立顶层透明窗口(P2-03 修复)
🆕 必须是可覆盖原生 WebView2 的独立顶层窗独立 HWNDWebView2 是原生子窗
永远绘制在主窗口内所有 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) # 发射新标题 renamed = QtCore.pyqtSignal(str) # 发射新标题
def __init__(self, current_title, parent=None): def __init__(self, current_title, parent=None):
super().__init__(parent) super().__init__(parent)
# 覆盖整个主窗口 self._main = parent.window() if parent is not None else None
self.setGeometry(parent.rect()) # 无边框顶层 Tool 窗(不入任务栏;Windows 上作为拥有者窗口默认浮在父窗之上)
self.setStyleSheet("background: transparent;") 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._overlay_color = QtGui.QColor(0, 0, 0, 80)
@@ -1379,7 +1397,6 @@ class RenameOverlay(QtWidgets.QWidget):
border-radius: 12px; border-radius: 12px;
} }
""") """)
self._center_form()
lay = QtWidgets.QVBoxLayout(self.form) lay = QtWidgets.QVBoxLayout(self.form)
lay.setContentsMargins(24, 20, 24, 18) lay.setContentsMargins(24, 20, 24, 18)
@@ -1444,27 +1461,59 @@ class RenameOverlay(QtWidgets.QWidget):
btn_lay.addWidget(btn_ok) btn_lay.addWidget(btn_ok)
lay.addLayout(btn_lay) lay.addLayout(btn_lay)
# 拖拽 # 拖拽(卡片可拖动,限制在客户区内)
self._drag_pos = None self._drag_pos = None
self.form.mousePressEvent = self._form_press self.form.mousePressEvent = self._form_press
self.form.mouseMoveEvent = self._form_move self.form.mouseMoveEvent = self._form_move
self.form.mouseReleaseEvent = self._form_release self.form.mouseReleaseEvent = self._form_release
# 浮现动画
self._result = None self._result = None
self._closed = False
# 先同步几何(覆盖主窗口客户区)并挂事件过滤器,再显示
self._sync_geometry()
if self._main is not None:
self._main.installEventFilter(self)
self.show() self.show()
self.raise_()
self.activateWindow()
self.input.setFocus() self.input.setFocus()
self._animate_in() 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): def _center_form(self):
x = (self.width() - self.form.width()) // 2 x = (self.width() - self.form.width()) // 2
y = (self.height() - self.form.height()) // 2 y = (self.height() - self.form.height()) // 2
self.form.move(x, y) self.form.move(x, y)
def _animate_in(self): def _animate_in(self):
self._opacity_effect = QtWidgets.QGraphicsOpacityEffect(self) # 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠)
self.setGraphicsEffect(self._opacity_effect) self.setWindowOpacity(0.0)
self._anim = QtCore.QPropertyAnimation(self._opacity_effect, b"opacity") self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(150) self._anim.setDuration(150)
self._anim.setStartValue(0.0) self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0) self._anim.setEndValue(1.0)
@@ -1481,7 +1530,14 @@ class RenameOverlay(QtWidgets.QWidget):
if not self.form.geometry().contains(event.pos()): if not self.form.geometry().contains(event.pos()):
self.close_overlay() 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): def _form_press(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton: if event.button() == QtCore.Qt.MouseButton.LeftButton:
self._drag_pos = event.pos() self._drag_pos = event.pos()
@@ -1497,11 +1553,6 @@ class RenameOverlay(QtWidgets.QWidget):
def _form_release(self, event): def _form_release(self, event):
self._drag_pos = None self._drag_pos = None
# ---- 主窗口 resize 时跟随 ----
def resizeEvent(self, event):
self.setGeometry(self.parentWidget().rect())
self._center_form()
def confirm(self): def confirm(self):
title = self.input.text().strip() title = self.input.text().strip()
if title: if title:
@@ -1510,13 +1561,20 @@ class RenameOverlay(QtWidgets.QWidget):
self.close_overlay() self.close_overlay()
def close_overlay(self): 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() self.deleteLater()
def get_result(self): def get_result(self):
return self._result return self._result
from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6 import QtCore, QtGui, QtWidgets
def _popup_svg_path(filename: str) -> str: def _popup_svg_path(filename: str) -> str:
"""🌟 弹窗类内部取 svg 图标绝对路径(与 MainWindow.get_svg_path 同一套定位逻辑,不依赖主窗口实例)""" """🌟 弹窗类内部取 svg 图标绝对路径(与 MainWindow.get_svg_path 同一套定位逻辑,不依赖主窗口实例)"""
@@ -3125,24 +3183,26 @@ class MainWindow(QtWidgets.QMainWindow):
self.browser_layout.setSpacing(0) self.browser_layout.setSpacing(0)
# 创建浏览器(🆕 A′ 双轨:Windows 上优先 WebView2,失败/配置指定时回落 QtWebEngine # 创建浏览器(🆕 A′ 双轨:Windows 上优先 WebView2,失败/配置指定时回落 QtWebEngine
# P1-03:平台矩阵 —— 解析 webview_backend 配置;非法值可见警告 + 回落平台默认,不阻断启动
self._wv2_session = None self._wv2_session = None
if _wv2mod is not None and sys.platform == "win32": _backend_pref = "auto"
_backend_pref = "auto" try:
try: from core.config_paths import load_config as _load_cfg # P0-01 统一配置入口
_root_cfg = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) _backend_pref = _load_cfg().get("webview_backend", "auto")
with open(os.path.join(_root_cfg, "data", "config.json"), "r", encoding="utf-8") as _f: except Exception:
_backend_pref = json.load(_f).get("webview_backend", "auto") pass
except Exception: _backend, _backend_warn = _renderer_backend.resolve_backend(_backend_pref)
pass if _backend_warn:
if _backend_pref in ("auto", "webview2"): print(_backend_warn, flush=True)
_app = QtWidgets.QApplication.instance() if _backend == "webview2" and _wv2mod is not None and sys.platform == "win32":
_env = _wv2mod.get_environment(_app) _app = QtWidgets.QApplication.instance()
if _env is not None: _env = _wv2mod.get_environment(_app)
try: if _env is not None:
self._wv2_session = _wv2mod.Wv2Session(_env, int(self.winId()), _app) try:
except Exception as _ex: self._wv2_session = _wv2mod.Wv2Session(_env, int(self.winId()), _app)
print(f"[WV2] session 创建失败 → 回落 QtWebEngine: {_ex}") except Exception as _ex:
self._wv2_session = None print(f"[WV2] session 创建失败 → 回落 QtWebEngine: {_ex}")
self._wv2_session = None
if self._wv2_session is not None: if self._wv2_session is not None:
# ---- WebView2 路径 ---- # ---- WebView2 路径 ----
@@ -3158,9 +3218,19 @@ class MainWindow(QtWidgets.QMainWindow):
print("[System] 浏览器内核: WebView2") print("[System] 浏览器内核: WebView2")
else: else:
# ---- QtWebEngine 路径(原逻辑,保持可用) ---- # ---- 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() self.browser = QWebEngineView()
# 🌟 关键修改:使用自定义的 WebPage # 🌟 关键修改:使用自定义的 WebPage
custom_page = CustomWebPage(self.browser) custom_page = CustomWebPage(self._qtwe_profile, self.browser)
self.browser.setPage(custom_page) self.browser.setPage(custom_page)
# 🌟 v2: 不透明绘制属性 —— 告诉 Qt 无需先擦背景再等 WebEngine 纹理, # 🌟 v2: 不透明绘制属性 —— 告诉 Qt 无需先擦背景再等 WebEngine 纹理,
@@ -3371,6 +3441,17 @@ class MainWindow(QtWidgets.QMainWindow):
def init_browser(self): 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 注册) # 1. 信号绑定 (ChatBridge 已在 setup_ui 中通过 QWebChannel 注册)
# 重新回答信号 # 重新回答信号
self.chat_bridge.regenerate_clicked.connect(self._on_regenerate_clicked) self.chat_bridge.regenerate_clicked.connect(self._on_regenerate_clicked)
@@ -3382,6 +3463,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.chat_bridge.delete_message_requested.connect(self._on_delete_message) self.chat_bridge.delete_message_requested.connect(self._on_delete_message)
# 🌟 绑定附件点击信号 # 🌟 绑定附件点击信号
self.chat_bridge.attachment_clicked.connect(self._on_attachment_clicked) 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 路径 # 2. 计算本地 HTML 路径
# 假设 web 文件夹位于当前脚本目录的上一级 # 假设 web 文件夹位于当前脚本目录的上一级
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
@@ -3484,6 +3567,29 @@ class MainWindow(QtWidgets.QMainWindow):
#bl_code { background-color: #fbfcfe; border: 1px solid #e6eaf2; border-radius: 7px; #bl_code { background-color: #fbfcfe; border: 1px solid #e6eaf2; border-radius: 7px;
padding: 4px; font-family: Consolas, "Courier New", monospace; padding: 4px; font-family: Consolas, "Courier New", monospace;
font-size: 11px; color: #243043; } font-size: 11px; color: #243043; }
/* ---- 🆕 P2-02右侧任务面板滚动条 + 横纵交汇角全部选择器限定在
#bl_code / #bl_scroll,绝不添加无作用域的 QScrollBar/QAbstractScrollArea
全局规则不污染模型弹窗/会话列表/附件预览等其他控件 ----
口径 8px隐藏箭头add-line/sub-line 0handle 可见 + 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; } #bl_body { background: transparent; }
/* ---- 顶部栏 ---- */ /* ---- 顶部栏 ---- */
@@ -3694,18 +3800,17 @@ class MainWindow(QtWidgets.QMainWindow):
self.set_send_button_state(False) self.set_send_button_state(False)
# ==================== 事件拦截 ==================== # ==================== 事件拦截P0-02MainWindow 唯一 eventFilter ====================
def eventFilter(self, obj, event): 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 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.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier: if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier:
return False return False # Shift+Enter:放行换行
else: self.send_message(from_enter=True)
# 🌟 修改:只有在非生成状态下,按回车才会发送;生成时按回车无效,防止误触 return True # 事件已消费:不往输入框插入换行
# 回车发送检查改为:
if self.current_session_id not in self._active_streams:
self.send_message()
return True
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
# ==================== 🌟 核心新增:发送与线程控制 ==================== # ==================== 🌟 核心新增:发送与线程控制 ====================
def init_chat_events(self): def init_chat_events(self):
@@ -3739,11 +3844,15 @@ class MainWindow(QtWidgets.QMainWindow):
self.screenshot_overlay = ScreenCaptureOverlay() self.screenshot_overlay = ScreenCaptureOverlay()
self.screenshot_overlay.screenshot_captured.connect(self._on_image_pasted) self.screenshot_overlay.screenshot_captured.connect(self._on_image_pasted)
self.btn_screenshot.clicked.connect(self._start_screenshot) self.btn_screenshot.clicked.connect(self._start_screenshot)
# Alt+S 全局热键:独立线程注册系统级热键,窗口失焦也能触发 # P1-04:平台路由 —— Windows RegisterHotKey / X11 XGrabKey / Wayland+offscreen 无全局热键
self.hotkey_screenshot = GlobalHotkeyThread() _sess_kind = desktop_session.session_kind()
self.hotkey_screenshot.triggered.connect(self._start_screenshot) _hk_factory, _hk_msg = desktop_session.hotkey_plan(_sess_kind)
self.hotkey_screenshot.start() print(_hk_msg, flush=True)
# 非 Windows 平台回退到应用级快捷键 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": if sys.platform != "win32":
self.shortcut_screenshot = QtGui.QShortcut(QtGui.QKeySequence("Alt+S"), self) self.shortcut_screenshot = QtGui.QShortcut(QtGui.QKeySequence("Alt+S"), self)
self.shortcut_screenshot.activated.connect(self._start_screenshot) self.shortcut_screenshot.activated.connect(self._start_screenshot)
@@ -3760,28 +3869,6 @@ class MainWindow(QtWidgets.QMainWindow):
print(f"[UI]: 会话顺序已更新 ({len(ordered_ids)} 条)") print(f"[UI]: 会话顺序已更新 ({len(ordered_ids)} 条)")
# ==================== 🌟 核心新增:键盘事件拦截 (Enter 发送) ====================
def eventFilter(self, obj, event):
"""全局事件过滤器"""
if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress:
# 捕获回车键 (主键盘 Enter 或 数字键盘 Enter)
if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
# 如果按住了 Shift 键,则放行 (实现换行)
if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier:
return False
else:
# 如果没按 Shift,则拦截该事件,并触发发送逻辑
# 注意:只有在发送按钮处于激活状态时才允许发送
if self.btn_send.isEnabled():
self.send_message()
return True # 告诉 PyQt: 这个事件我处理过了,不要再往输入框里加回车符了
# 其他事件交给父类正常处理
return super().eventFilter(obj, event)
# ==================== 🌟 核心新增:长文本折叠检测 ====================
# ==================== 🌟 核心升级:统一附件 UI 与本地存储处理 ====================
def _on_upload_clicked(self): def _on_upload_clicked(self):
"""点击上传按钮:支持图片 + 文本/代码文件多选,按扩展名自动分类入库。""" """点击上传按钮:支持图片 + 文本/代码文件多选,按扩展名自动分类入库。"""
paths, _ = QtWidgets.QFileDialog.getOpenFileNames( paths, _ = QtWidgets.QFileDialog.getOpenFileNames(
@@ -3994,11 +4081,36 @@ class MainWindow(QtWidgets.QMainWindow):
overlay.show() overlay.show()
def _start_screenshot(self): def _start_screenshot(self):
"""启动截图覆盖层""" """启动截图P1-04 平台路由:win32/x11 → 覆盖层;wayland → portalunknown → 明确告知)"""
# 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发) # 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发)
self._screenshot_from_background = not self.isActiveWindow() 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() 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): def _add_attachment_tag(self, att_data: dict):
"""🌟 通用附件 UI 渲染(图片和长文本共用这一个精美样式)""" """🌟 通用附件 UI 渲染(图片和长文本共用这一个精美样式)"""
@@ -4105,6 +4217,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.current_model or "Assistant", self.current_model or "Assistant",
branch_info=branch_info 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._ghost_msg_map[ai_msg_id] = user_msg_id
self._active_streams[session_id] = { self._active_streams[session_id] = {
"msg_id": ai_msg_id, "msg_id": ai_msg_id,
@@ -4300,11 +4414,20 @@ class MainWindow(QtWidgets.QMainWindow):
except Exception as e: except Exception as e:
print(f"[DB]: 中断内容保存失败: {e}", flush=True) print(f"[DB]: 中断内容保存失败: {e}", flush=True)
def send_message(self): def send_message(self, from_enter: bool = False):
import time import time
import uuid import uuid
import json 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() current_time = time.time()
if current_time - self._last_click_time < 0.3: if current_time - self._last_click_time < 0.3:
return return
@@ -4476,6 +4599,8 @@ class MainWindow(QtWidgets.QMainWindow):
attachment_metadata=attachment_metadata_json # 图片路径和文本元数据存入这里 attachment_metadata=attachment_metadata_json # 图片路径和文本元数据存入这里
) )
self.db.mark_session_has_messages(self.current_session_id) 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()}") self._dbg(f"[send] model={self.current_model} mode={self._get_current_mode()}")
# 组装给大模型的 API 上下文(build_api_context 内部会去解析图片 local_path 并转为 base64 发给 API # 组装给大模型的 API 上下文(build_api_context 内部会去解析图片 local_path 并转为 base64 发给 API
@@ -4491,6 +4616,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.current_model or "Assistant", self.current_model or "Assistant",
branch_info=branch_info # 🚀 传给前端 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 self._ghost_msg_map[ai_msg_id] = user_msg_id
session_id = self.current_session_id session_id = self.current_session_id
self._active_streams[session_id] = { self._active_streams[session_id] = {
@@ -4605,6 +4732,8 @@ class MainWindow(QtWidgets.QMainWindow):
usage=_usage_json # 🆕 P1: usage 锚点 usage=_usage_json # 🆕 P1: usage 锚点
) )
print(f"[生命周期] DB 写入完成 mid={msg_id} timeline={len(_tl0)}", flush=True) 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): if self.db.check_session_needs_title(session_id):
self._generate_session_title(session_id) self._generate_session_title(session_id)
@@ -5072,8 +5201,148 @@ class MainWindow(QtWidgets.QMainWindow):
self.history_list.setItemWidget(item, widget) self.history_list.setItemWidget(item, widget)
def load_messages_to_web(self, session_id, show_loading=True): 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 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.current_session_id = session_id
self._refresh_mode_button() # 🆕 会话切换 → 刷新模式按钮 self._refresh_mode_button() # 🆕 会话切换 → 刷新模式按钮
# 🆕 右侧任务面板:跟随会话切换(范围永远是当前会话) # 🆕 右侧任务面板:跟随会话切换(范围永远是当前会话)
@@ -5085,101 +5354,32 @@ class MainWindow(QtWidgets.QMainWindow):
# 🌟 统一加载界面:切换会话/启动时盖加载层;内部刷新(重新回答/分支/删除)不触发 # 🌟 统一加载界面:切换会话/启动时盖加载层;内部刷新(重新回答/分支/删除)不触发
if show_loading: if show_loading:
self.chat_bridge.show_loading() self.chat_bridge.show_loading()
self.chat_bridge.clear_chat() # 🆕 P1-01rwBegin 内部执行 clearChat + (session, generation) 重同步
visible = self._rw_visible_chain(session_id)
# 1. 获取当前时间线的消息链 total = len(visible)
# 🚀 调用链表查询方法:永远只渲染当前选中的时间线分支 self.chat_bridge.rw_begin(session_id, gen, total)
messages = self.db.get_message_chain(session_id)
visible_messages = [msg for msg in messages
if msg["role"] not in ("system", "compaction")]
# 2. 处理空对话状态 # 2. 处理空对话状态
if not visible_messages: if not visible:
self.chat_bridge.show_welcome() self.chat_bridge.show_welcome()
self._update_send_button_state() self._update_send_button_state()
if show_loading: if show_loading:
QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading()) QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading())
return return
# 3. 遍历渲染消息
for msg in visible_messages:
branch_info = None
# 🌟 核心逻辑:为 AI 消息计算多分支切换信息
if msg["role"] == "assistant" and msg.get("parent_id"):
# 获取该父节点下的所有兄弟回复
siblings = self.db.get_branch_info(msg["parent_id"])
if len(siblings) > 1:
try:
# 找到当前消息在所有兄弟分支里的排序 (1-based index)
sibling_ids = [s["id"] for s in siblings]
idx = sibling_ids.index(msg["id"]) + 1
branch_info = {
"current": idx,
"total": len(siblings)
}
except (ValueError, KeyError):
branch_info = None
# 4. 渲染逻辑分发 # 3. 窗口化渲染:仅最新 size 条(🆕 P1-01);更早消息经换页按需加载
# 情况 A: 带有附件的用户消息 window_items = visible[-self._rw_size:] if self._rw_size > 0 else visible
if msg["role"] == "user" and msg.get("attachment_metadata"): # 批次渲染期间抑制逐条滚底(初始窗口完成后由前端统一对齐底部)
try: self.chat_bridge.run_js("window.__rwPageRendering = true;")
metadata = json.loads(msg["attachment_metadata"]) for msg in window_items:
user_text = metadata.get("user_text", "") self._render_history_one(msg)
attachments = metadata.get("attachments", []) # 链内下标 = 窗口在链尾的偏移 + 窗口内序号(不是局部下标!)
offset = total - len(window_items)
# 🌟 核心修复:清洗历史脏数据,强制补齐 content 字段,彻底绝杀 JS 崩溃! self.chat_bridge.rw_init_window(
# 🌟 核心修复:清洗历史脏数据,强制补齐所有前端需要的字段! session_id, gen, total,
for att in attachments: [(m["id"], offset + i) for i, m in enumerate(window_items)])
# 1. 补齐 content 防崩溃 self.chat_bridge.run_js("window.__rwPageRendering = false;")
if "content" not in att:
if att.get("type") == "image":
att["content"] = f"[图片文件: {att.get('name', '未知图片')}]"
else:
att["content"] = "[附件内容]"
# 2. 补齐 size_kb 和 lines 防 undefined
if "size_kb" not in att:
att["size_kb"] = 0
if "lines" not in att:
att["lines"] = 1
self.chat_bridge.create_user_message_with_attachments(
msg["id"], user_text, attachments
)
except (json.JSONDecodeError, TypeError):
# 如果解析失败,降级渲染为普通历史消息
self.chat_bridge.render_history_message(
msg_id=msg["id"],
role=msg["role"],
content=msg["content"],
reasoning=msg.get("reasoning", ""),
branch_info=branch_info
)
# 情况 B: 普通消息或 AI 消息
else:
tl_json = msg.get("timeline") if msg["role"] == "assistant" else None
if tl_json:
# 🌟 带 agent 时间线的 AI 消息:按序渲染 思考/文本/工具
self.chat_bridge.create_message(
msg_id=msg["id"], role=msg["role"],
text=msg["content"],
sender_name=self.current_model or "Assistant",
branch_info=branch_info)
self.chat_bridge.render_timeline_history(msg["id"], tl_json)
self.chat_bridge.finish_message(msg["id"])
else:
self.chat_bridge.render_history_message(
msg_id=msg["id"],
role=msg["role"],
content=msg["content"],
reasoning=msg.get("reasoning", ""),
branch_info=branch_info
)
# 5. 恢复流式输出状态(处理页面刷新或切换回正在生成的会话) # 5. 恢复流式输出状态(处理页面刷新或切换回正在生成的会话)
#🌟 如果该会话有正在进行的流式输出,每次都重新推送 #🌟 如果该会话有正在进行的流式输出,每次都重新推送
if session_id in self._active_streams: if session_id in self._active_streams:
@@ -5194,6 +5394,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.current_model or "Assistant", self.current_model or "Assistant",
branch_info=branch_info # 🚀 传给前端 branch_info=branch_info # 🚀 传给前端
) )
# 🆕 P1-01:切回复活的流式消息进渲染窗口(已入库则带真实下标)
self._rw_note_live(msg_id, session_id)
# 🌟 有 agent 时间线 → 按序恢复 思考/文本/工具 块(后续 token 无缝续流) # 🌟 有 agent 时间线 → 按序恢复 思考/文本/工具 块(后续 token 无缝续流)
if stream_state.get("timeline"): if stream_state.get("timeline"):
diag_log(f"RESTORE mid={msg_id} entries={len(stream_state['timeline'])} " diag_log(f"RESTORE mid={msg_id} entries={len(stream_state['timeline'])} "
@@ -5240,14 +5442,9 @@ class MainWindow(QtWidgets.QMainWindow):
def init_model_popup(self): def init_model_popup(self):
# 1. 读取配置 # 1. 读取配置(P0-01 统一入口:缺失/损坏 → 可见警告 + 安全默认值)
config_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'config.json') from core.config_paths import load_config as _load_cfg
try: self.config_data = _load_cfg() or {"providers": {}}
with open(config_path, 'r', encoding='utf-8') as f:
self.config_data = json.load(f)
except Exception as e:
print(f"加载配置失败: {e}")
self.config_data = {"providers": {}}
# 2. 实例化我们写的自定义弹窗 (先不显示) # 2. 实例化我们写的自定义弹窗 (先不显示)
self.model_popup = ModelSelectPopup(self, self.config_data) self.model_popup = ModelSelectPopup(self, self.config_data)
@@ -5369,6 +5566,10 @@ class MainWindow(QtWidgets.QMainWindow):
"""JS 就绪检测的回调""" """JS 就绪检测的回调"""
if is_ready: if is_ready:
print("[System]:✅ JS 引擎已就绪,正在加载历史记录...") print("[System]:✅ JS 引擎已就绪,正在加载历史记录...")
# 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载
if not self._rw_config_pushed:
self._rw_config_pushed = True
self.chat_bridge.rw_config(self._rw_mode, self._rw_size)
self.load_messages_to_web(self.current_session_id) self.load_messages_to_web(self.current_session_id)
# 🌟 触发前端上报一次初始滚动状态,同步自定义滚动条 # 🌟 触发前端上报一次初始滚动状态,同步自定义滚动条
self.chat_bridge.run_js("setTimeout(reportWebScroll, 60);") self.chat_bridge.run_js("setTimeout(reportWebScroll, 60);")
@@ -5378,6 +5579,8 @@ class MainWindow(QtWidgets.QMainWindow):
def on_new_chat_clicked(self): def on_new_chat_clicked(self):
# 🆕 P1-01:代次 +1(旧会话的在途分页响应失效)
self._rw_generation += 1
new_sess = self.db.create_session(title="新对话") new_sess = self.db.create_session(title="新对话")
print(f"[UI]: 新建对话 -> {new_sess['id']}") print(f"[UI]: 新建对话 -> {new_sess['id']}")