diff --git a/tests/test_main_window_event_filter.py b/tests/test_main_window_event_filter.py new file mode 100644 index 0000000..8e70948 --- /dev/null +++ b/tests/test_main_window_event_filter.py @@ -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("AST:MainWindow 只有一个 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 静态析构段错误(不影响结果) diff --git a/ui/views/bash_panel.py b/ui/views/bash_panel.py index a3b1ebe..05cb975 100644 --- a/ui/views/bash_panel.py +++ b/ui/views/bash_panel.py @@ -6,7 +6,10 @@ 中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px, 展开区放不下「参数 + 输出」两块列表,故用上下两栏)。 若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。 - · 一次 bash = 一层(BashLayer),自上而下按执行先后排列 + · 一次 bash = 一层(BashLayer),自上而下按【启动先后倒序】排列(P2-01: + 最新启动的任务在第一项;排序键恒为启动顺序,运行中→完成归位时 + 不按完成时间重排;已落库时间线无显式时间时,启动序号 = 消息链 + 顺序 + 时间线内顺序,稳定可复现) · 单击层头 = 展开/收起;展开后分「参数」「输出」两块 · 运行中:输出为 tool_bash 的实时流(缓冲上限 200KB,超出丢弃最旧并标注) · 已完成:输出为「进入上下文的原文」(DB messages.timeline[].result,不截断) @@ -35,12 +38,14 @@ PANEL_W_DEFAULT = 260 # 首次运行的默认展开宽度 PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了) PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束) _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: - """配置文件路径(测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)""" - return os.environ.get("HAOCODE_CONFIG_FILE") or _CFG_PATH + """配置文件路径(P0-01 统一入口:测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)""" + return _unified_config_path() def load_panel_width(default: int = PANEL_W_DEFAULT) -> int: @@ -539,7 +544,7 @@ class BashPanel(QtWidgets.QWidget): self._session_id = None self._db = None self._layers = {} # call_id -> BashLayer - self._order = [] # 执行先后 + self._order = [] # 稳定启动序号(0=最早启动;追加顺序 = 启动先后) self._running = set() self._done = set() self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复) @@ -886,11 +891,17 @@ class BashPanel(QtWidgets.QWidget): self._refresh() 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] 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:] - 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 "") # ---------------- 事件 ---------------- @@ -937,8 +948,9 @@ class BashPanel(QtWidgets.QWidget): # ---------------- 测试/调试辅助 ---------------- def layer_ids(self, which="all"): + # running/done 返回真实显示顺序(启动倒序);"all" = 原始启动序号(正序) 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": - 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) diff --git a/ui/views/main_window.py b/ui/views/main_window.py index 2b115e0..9365e9e 100644 --- a/ui/views/main_window.py +++ b/ui/views/main_window.py @@ -23,14 +23,19 @@ def diag_log(line: str): 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) -try: - from core import webview2 as _wv2mod -except Exception as _wv2_imp_err: # pythonnet 缺失 / 非 Windows 等 - _wv2mod = None - print(f"[WV2] 后端不可用(回落 QtWebEngine): {_wv2_imp_err}") +# 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 # 🌟 文本/代码文件读取(编码探测+二进制探测) @@ -1356,14 +1361,27 @@ class SessionContextPopup(QtWidgets.QWidget): self._anim_group.start() class RenameOverlay(QtWidgets.QWidget): - """无边框重命名 — 主窗口内模态覆盖层""" + """无边框重命名 — 独立顶层透明窗口(P2-03 修复) + + 🆕 必须是可覆盖原生 WebView2 的独立顶层窗(独立 HWND):WebView2 是原生子窗, + 永远绘制在主窗口内所有 Qt 控件之上 —— 旧实现是 bg_widget 的子控件,遮罩盖不住 + webview 区域(聊天区不暗)、卡片被压到 webview 下面。独立顶层 Tool 窗 + + WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗(同 AttachmentPreviewOverlay)。 + + 只覆盖主窗口【客户区】:系统标题栏与窗口控制保持可操作。 + 主窗口 Move / Resize / WindowStateChange(最大化/还原,含多显示器/DPI 变化时 + Qt 合成的 move+resize)通过 eventFilter + mapToGlobal 跟随。 + 行为保持:点空白关闭、Esc 关闭、✕ 关闭、输入框初始全选、Enter 提交、关闭后焦点回主窗口。 + """ renamed = QtCore.pyqtSignal(str) # 发射新标题 def __init__(self, current_title, parent=None): super().__init__(parent) - # 覆盖整个主窗口 - self.setGeometry(parent.rect()) - self.setStyleSheet("background: transparent;") + 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) @@ -1379,7 +1397,6 @@ class RenameOverlay(QtWidgets.QWidget): border-radius: 12px; } """) - self._center_form() lay = QtWidgets.QVBoxLayout(self.form) lay.setContentsMargins(24, 20, 24, 18) @@ -1444,27 +1461,59 @@ class RenameOverlay(QtWidgets.QWidget): 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): - self._opacity_effect = QtWidgets.QGraphicsOpacityEffect(self) - self.setGraphicsEffect(self._opacity_effect) - self._anim = QtCore.QPropertyAnimation(self._opacity_effect, b"opacity") + # 顶层窗口用 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) @@ -1481,7 +1530,14 @@ class RenameOverlay(QtWidgets.QWidget): 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() @@ -1497,11 +1553,6 @@ class RenameOverlay(QtWidgets.QWidget): def _form_release(self, event): self._drag_pos = None - # ---- 主窗口 resize 时跟随 ---- - def resizeEvent(self, event): - self.setGeometry(self.parentWidget().rect()) - self._center_form() - def confirm(self): title = self.input.text().strip() if title: @@ -1510,13 +1561,20 @@ class RenameOverlay(QtWidgets.QWidget): 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 同一套定位逻辑,不依赖主窗口实例)""" @@ -3125,24 +3183,26 @@ class MainWindow(QtWidgets.QMainWindow): self.browser_layout.setSpacing(0) # 创建浏览器(🆕 A′ 双轨:Windows 上优先 WebView2,失败/配置指定时回落 QtWebEngine) + # P1-03:平台矩阵 —— 解析 webview_backend 配置;非法值可见警告 + 回落平台默认,不阻断启动 self._wv2_session = None - if _wv2mod is not None and sys.platform == "win32": - _backend_pref = "auto" - try: - _root_cfg = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) - with open(os.path.join(_root_cfg, "data", "config.json"), "r", encoding="utf-8") as _f: - _backend_pref = json.load(_f).get("webview_backend", "auto") - except Exception: - pass - if _backend_pref in ("auto", "webview2"): - _app = QtWidgets.QApplication.instance() - _env = _wv2mod.get_environment(_app) - if _env is not None: - try: - self._wv2_session = _wv2mod.Wv2Session(_env, int(self.winId()), _app) - except Exception as _ex: - print(f"[WV2] session 创建失败 → 回落 QtWebEngine: {_ex}") - self._wv2_session = None + _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 路径 ---- @@ -3158,9 +3218,19 @@ class MainWindow(QtWidgets.QMainWindow): 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.browser) + custom_page = CustomWebPage(self._qtwe_profile, self.browser) self.browser.setPage(custom_page) # 🌟 v2: 不透明绘制属性 —— 告诉 Qt 无需先擦背景再等 WebEngine 纹理, @@ -3371,6 +3441,17 @@ class MainWindow(QtWidgets.QMainWindow): 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) @@ -3382,6 +3463,8 @@ class MainWindow(QtWidgets.QMainWindow): 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__)) @@ -3484,6 +3567,29 @@ class MainWindow(QtWidgets.QMainWindow): #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; } /* ---- 顶部栏 ---- */ @@ -3694,18 +3800,17 @@ class MainWindow(QtWidgets.QMainWindow): self.set_send_button_state(False) - # ==================== 事件拦截 ==================== + # ==================== 事件拦截(P0-02:MainWindow 唯一 eventFilter) ==================== def eventFilter(self, obj, event): + """统一事件过滤器:只处理输入框的 Enter / Shift+Enter,其余对象与事件全部交给父类。 + 「是否允许发送」的规则是 send_message(from_enter=True) 里的单一实现, + 与发送按钮点击路径共用;一次按键至多触发一次 send_message 调用。""" if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress: if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier: - return False - else: - # 🌟 修改:只有在非生成状态下,按回车才会发送;生成时按回车无效,防止误触 - # 回车发送检查改为: - if self.current_session_id not in self._active_streams: - self.send_message() - return True + return False # Shift+Enter:放行换行 + self.send_message(from_enter=True) + return True # 事件已消费:不往输入框插入换行 return super().eventFilter(obj, event) # ==================== 🌟 核心新增:发送与线程控制 ==================== def init_chat_events(self): @@ -3739,11 +3844,15 @@ class MainWindow(QtWidgets.QMainWindow): self.screenshot_overlay = ScreenCaptureOverlay() self.screenshot_overlay.screenshot_captured.connect(self._on_image_pasted) self.btn_screenshot.clicked.connect(self._start_screenshot) - # Alt+S 全局热键:独立线程注册系统级热键,窗口失焦也能触发 - self.hotkey_screenshot = GlobalHotkeyThread() - self.hotkey_screenshot.triggered.connect(self._start_screenshot) - self.hotkey_screenshot.start() - # 非 Windows 平台回退到应用级快捷键 + # 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) @@ -3760,28 +3869,6 @@ class MainWindow(QtWidgets.QMainWindow): print(f"[UI]: 会话顺序已更新 ({len(ordered_ids)} 条)") - # ==================== 🌟 核心新增:键盘事件拦截 (Enter 发送) ==================== - def eventFilter(self, obj, event): - """全局事件过滤器""" - if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress: - # 捕获回车键 (主键盘 Enter 或 数字键盘 Enter) - if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): - # 如果按住了 Shift 键,则放行 (实现换行) - if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier: - return False - else: - # 如果没按 Shift,则拦截该事件,并触发发送逻辑 - # 注意:只有在发送按钮处于激活状态时才允许发送 - if self.btn_send.isEnabled(): - self.send_message() - return True # 告诉 PyQt: 这个事件我处理过了,不要再往输入框里加回车符了 - - # 其他事件交给父类正常处理 - return super().eventFilter(obj, event) - - # ==================== 🌟 核心新增:长文本折叠检测 ==================== - # ==================== 🌟 核心升级:统一附件 UI 与本地存储处理 ==================== - def _on_upload_clicked(self): """点击上传按钮:支持图片 + 文本/代码文件多选,按扩展名自动分类入库。""" paths, _ = QtWidgets.QFileDialog.getOpenFileNames( @@ -3994,11 +4081,36 @@ class MainWindow(QtWidgets.QMainWindow): overlay.show() def _start_screenshot(self): - """启动截图覆盖层""" + """启动截图(P1-04 平台路由:win32/x11 → 覆盖层;wayland → portal;unknown → 明确告知)""" # 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发) self._screenshot_from_background = not self.isActiveWindow() + _kind = desktop_session.session_kind() + _mode, _msg = desktop_session.capture_plan(_kind) + if _msg: + print(_msg, flush=True) + return + if _mode == "portal": + self._start_portal_screenshot() + return self.screenshot_overlay.start() + def _start_portal_screenshot(self): + """Wayland:经 xdg-desktop-portal 交互截图(用户授权,不绕过 compositor)。""" + from ui.views.system_tools import portal_capture + if not hasattr(self, "_portal_worker"): + self._portal_worker = None + self._portal_worker = portal_capture.PortalScreenshotWorker(parent=self) + self._portal_worker.done.connect(self._on_portal_screenshot_done) + self._portal_worker.start() + + def _on_portal_screenshot_done(self, ok, path_or_reason): + """portal 截图结果回主线程:成功 → 进现有图片附件流程;失败 → 明确日志。""" + if ok: + self._on_image_pasted([path_or_reason]) + else: + print(f"[Screenshot] portal 截图未完成:{path_or_reason} → 当前环境截图能力不可用/已取消;" + f"聊天与其他功能不受影响", flush=True) + def _add_attachment_tag(self, att_data: dict): """🌟 通用附件 UI 渲染(图片和长文本共用这一个精美样式)""" @@ -4105,6 +4217,8 @@ class MainWindow(QtWidgets.QMainWindow): 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, @@ -4300,11 +4414,20 @@ class MainWindow(QtWidgets.QMainWindow): except Exception as e: print(f"[DB]: 中断内容保存失败: {e}", flush=True) - def send_message(self): + 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 @@ -4476,6 +4599,8 @@ class MainWindow(QtWidgets.QMainWindow): 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) @@ -4491,6 +4616,8 @@ class MainWindow(QtWidgets.QMainWindow): 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] = { @@ -4605,6 +4732,8 @@ class MainWindow(QtWidgets.QMainWindow): 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) @@ -5072,8 +5201,148 @@ class MainWindow(QtWidgets.QMainWindow): 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 + 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() # 🆕 会话切换 → 刷新模式按钮 # 🆕 右侧任务面板:跟随会话切换(范围永远是当前会话) @@ -5085,101 +5354,32 @@ class MainWindow(QtWidgets.QMainWindow): # 🌟 统一加载界面:切换会话/启动时盖加载层;内部刷新(重新回答/分支/删除)不触发 if show_loading: self.chat_bridge.show_loading() - self.chat_bridge.clear_chat() - - # 1. 获取当前时间线的消息链 - # 🚀 调用链表查询方法:永远只渲染当前选中的时间线分支 - messages = self.db.get_message_chain(session_id) - visible_messages = [msg for msg in messages - if msg["role"] not in ("system", "compaction")] - + # 🆕 P1-01:rwBegin 内部执行 clearChat + (session, generation) 重同步 + visible = self._rw_visible_chain(session_id) + total = len(visible) + self.chat_bridge.rw_begin(session_id, gen, total) + # 2. 处理空对话状态 - if not visible_messages: + 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. 遍历渲染消息 - for msg in visible_messages: - branch_info = None - - # 🌟 核心逻辑:为 AI 消息计算多分支切换信息 - if msg["role"] == "assistant" and msg.get("parent_id"): - # 获取该父节点下的所有兄弟回复 - siblings = self.db.get_branch_info(msg["parent_id"]) - if len(siblings) > 1: - try: - # 找到当前消息在所有兄弟分支里的排序 (1-based index) - sibling_ids = [s["id"] for s in siblings] - idx = sibling_ids.index(msg["id"]) + 1 - branch_info = { - "current": idx, - "total": len(siblings) - } - except (ValueError, KeyError): - branch_info = None - # 4. 渲染逻辑分发 - # 情况 A: 带有附件的用户消息 - if msg["role"] == "user" and msg.get("attachment_metadata"): - try: - metadata = json.loads(msg["attachment_metadata"]) - user_text = metadata.get("user_text", "") - attachments = metadata.get("attachments", []) - - # 🌟 核心修复:清洗历史脏数据,强制补齐 content 字段,彻底绝杀 JS 崩溃! - # 🌟 核心修复:清洗历史脏数据,强制补齐所有前端需要的字段! - for att in attachments: - # 1. 补齐 content 防崩溃 - if "content" not in att: - if att.get("type") == "image": - att["content"] = f"[图片文件: {att.get('name', '未知图片')}]" - else: - att["content"] = "[附件内容]" - - # 2. 补齐 size_kb 和 lines 防 undefined - if "size_kb" not in att: - att["size_kb"] = 0 - if "lines" not in att: - att["lines"] = 1 + # 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;") - - self.chat_bridge.create_user_message_with_attachments( - msg["id"], user_text, attachments - ) - except (json.JSONDecodeError, TypeError): - - # 如果解析失败,降级渲染为普通历史消息 - self.chat_bridge.render_history_message( - msg_id=msg["id"], - role=msg["role"], - content=msg["content"], - reasoning=msg.get("reasoning", ""), - branch_info=branch_info - ) - # 情况 B: 普通消息或 AI 消息 - else: - tl_json = msg.get("timeline") if msg["role"] == "assistant" else None - if tl_json: - # 🌟 带 agent 时间线的 AI 消息:按序渲染 思考/文本/工具 - self.chat_bridge.create_message( - msg_id=msg["id"], role=msg["role"], - text=msg["content"], - sender_name=self.current_model or "Assistant", - branch_info=branch_info) - self.chat_bridge.render_timeline_history(msg["id"], tl_json) - self.chat_bridge.finish_message(msg["id"]) - else: - self.chat_bridge.render_history_message( - msg_id=msg["id"], - role=msg["role"], - content=msg["content"], - reasoning=msg.get("reasoning", ""), - branch_info=branch_info - ) - # 5. 恢复流式输出状态(处理页面刷新或切换回正在生成的会话) #🌟 如果该会话有正在进行的流式输出,每次都重新推送 if session_id in self._active_streams: @@ -5194,6 +5394,8 @@ class MainWindow(QtWidgets.QMainWindow): 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'])} " @@ -5240,14 +5442,9 @@ class MainWindow(QtWidgets.QMainWindow): def init_model_popup(self): - # 1. 读取配置 - config_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'config.json') - try: - with open(config_path, 'r', encoding='utf-8') as f: - self.config_data = json.load(f) - except Exception as e: - print(f"加载配置失败: {e}") - self.config_data = {"providers": {}} + # 1. 读取配置(P0-01 统一入口:缺失/损坏 → 可见警告 + 安全默认值) + from core.config_paths import load_config as _load_cfg + self.config_data = _load_cfg() or {"providers": {}} # 2. 实例化我们写的自定义弹窗 (先不显示) self.model_popup = ModelSelectPopup(self, self.config_data) @@ -5369,6 +5566,10 @@ class MainWindow(QtWidgets.QMainWindow): """JS 就绪检测的回调""" if is_ready: print("[System]:✅ JS 引擎已就绪,正在加载历史记录...") + # 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载 + if not self._rw_config_pushed: + self._rw_config_pushed = True + self.chat_bridge.rw_config(self._rw_mode, self._rw_size) self.load_messages_to_web(self.current_session_id) # 🌟 触发前端上报一次初始滚动状态,同步自定义滚动条 self.chat_bridge.run_js("setTimeout(reportWebScroll, 60);") @@ -5378,6 +5579,8 @@ class MainWindow(QtWidgets.QMainWindow): 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']}")