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:
@@ -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 静态析构段错误(不影响结果)
|
||||||
+21
-9
@@ -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
@@ -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 的独立顶层窗(独立 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) # 发射新标题
|
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 置 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; }
|
#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-02:MainWindow 唯一 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 → portal;unknown → 明确告知)"""
|
||||||
# 记录截图发起时主窗口是否处于非激活状态(如 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-01:rwBegin 内部执行 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']}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user