fix(ui): anchor popup and gate stream diagnostics

This commit is contained in:
2026-09-19 23:45:16 +08:00
parent 97e8545b50
commit 75da435f71
+95 -56
View File
@@ -9,12 +9,23 @@ import json
from ui.views.custom_web_page import CustomWebPage from ui.views.custom_web_page import CustomWebPage
from PyQt6.QtCore import QUrl # 🌟 新增:用于加载本地 HTML from PyQt6.QtCore import QUrl # 🌟 新增:用于加载本地 HTML
# ========== 流式诊断日志(排查显示问题的根本手段,写 stream_diag.log ========== # ========== 流式诊断日志(仅显式开启时写 stream_diag.log ==========
import time as _diag_time import time as _diag_time
def _stream_diagnostics_enabled() -> bool:
"""仅在显式要求时开启高开销流式诊断。"""
return (os.environ.get("HAOCODE_STREAM_DIAG") == "1"
or os.environ.get("HAOCODE_SHOT") == "1")
DIAG_LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), DIAG_LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"stream_diag.log") "stream_diag.log")
def diag_log(line: str): def diag_log(line: str):
if not _stream_diagnostics_enabled():
return
try: try:
t = _diag_time.time() t = _diag_time.time()
with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f: with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f:
@@ -1577,7 +1588,7 @@ class SessionContextPopup(QtWidgets.QWidget):
self._anim_group.addAnimation(opacity_anim) self._anim_group.addAnimation(opacity_anim)
self._anim_group.addAnimation(pos_anim) self._anim_group.addAnimation(pos_anim)
self._anim_group.start() self._anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
class RenameOverlay(QtWidgets.QWidget): class RenameOverlay(QtWidgets.QWidget):
"""无边框重命名 — 独立顶层透明窗口(P2-03 修复) """无边框重命名 — 独立顶层透明窗口(P2-03 修复)
@@ -2077,7 +2088,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
def reload_config(self, config_data): def reload_config(self, config_data):
"""按最新配置重建列表;配置缺失时仍保留可理解、可绘制的空状态。""" """按最新配置重建列表;配置缺失时仍保留可理解、可绘制的空状态。"""
self._finish_drawer() self._finish_drawer(preserve_top=True)
self.config_data = config_data if isinstance(config_data, dict) else {} self.config_data = config_data if isinstance(config_data, dict) else {}
self.list_widget.clear() self.list_widget.clear()
self._groups.clear() self._groups.clear()
@@ -2256,7 +2267,9 @@ class ModelSelectPopup(QtWidgets.QWidget):
if g is None: if g is None:
return return
# 上一个抽屉动画进行中 → 先立即收敛到终态(允许快速连点切换) # 上一个抽屉动画进行中 → 先立即收敛到终态(允许快速连点切换)
self._finish_drawer() # 折叠/展开只改变高度时锁定当前顶部;否则按底边贴按钮会把
# 用户正在看的内容整体向下挤。
self._finish_drawer(preserve_top=True)
g["expanded"] = not g["expanded"] g["expanded"] = not g["expanded"]
# SVG 箭头切换(展开=朝下 / 收起=朝右) # SVG 箭头切换(展开=朝下 / 收起=朝右)
g["chevron"].setPixmap( g["chevron"].setPixmap(
@@ -2264,7 +2277,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
self._clear_fx() self._clear_fx()
self._apply_group_state(g) self._apply_group_state(g)
self.list_widget.doItemsLayout() self.list_widget.doItemsLayout()
self._apply_height_and_position() self._apply_height_and_position(preserve_top=True)
self.list_widget.viewport().update() self.list_widget.viewport().update()
def _apply_group_state(self, g): def _apply_group_state(self, g):
@@ -2404,12 +2417,16 @@ class ModelSelectPopup(QtWidgets.QWidget):
if d["t"] >= 1.0: if d["t"] >= 1.0:
self._finish_drawer() self._finish_drawer()
def _finish_drawer(self): def _finish_drawer(self, preserve_top=False):
"""抽屉动画收敛到终态(动画完成 / 连点打断共用)""" """抽屉动画收敛到终态(动画完成 / 连点打断共用)"""
d = self._drawer d = self._drawer
if d is None: if d is None:
return return
top_anchor = self.y() if preserve_top and self.isVisible() else None
d["timer"].stop() d["timer"].stop()
# 每次动画都会创建一个 timer;停用后立即释放,避免反复折叠后
# 大量已停止的 QObject 留在弹窗树中。
d["timer"].deleteLater()
self._drawer = None self._drawer = None
# 🆕 恢复滑移供应商头的透明背景(_start_drawer 里铺的白底) # 🆕 恢复滑移供应商头的透明背景(_start_drawer 里铺的白底)
for it in d["after_items"]: for it in d["after_items"]:
@@ -2425,7 +2442,9 @@ class ModelSelectPopup(QtWidgets.QWidget):
return return
parent = self.parentWidget() parent = self.parentWidget()
btn = getattr(parent, "model_selector", None) if parent else None btn = getattr(parent, "model_selector", None) if parent else None
if btn is not None: if top_anchor is not None:
self.move(self.x(), top_anchor)
elif btn is not None:
# 真实 App:回到“按钮正上方右对齐”锚点 # 真实 App:回到“按钮正上方右对齐”锚点
btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0)) btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0))
self.move(btn_pos.x() + btn.width() - self.width(), self.move(btn_pos.x() + btn.width() - self.width(),
@@ -2434,8 +2453,9 @@ class ModelSelectPopup(QtWidgets.QWidget):
# 无锚定按钮(如调参工具宿主):保持动画起点底缘不漂 # 无锚定按钮(如调参工具宿主):保持动画起点底缘不漂
self.move(self.x(), d["bottom0"] - self.minimumHeight()) self.move(self.x(), d["bottom0"] - self.minimumHeight())
def _apply_height_and_position(self): def _apply_height_and_position(self, preserve_top=False):
"""折叠/展开后:按可见项重算高度;若弹窗正显示中,保持“按钮正上方右对齐”锚点""" """重算高度;折叠时可锁定顶部,避免当前列表位置被挤走。"""
top_anchor = self.y() if preserve_top and self.isVisible() else None
self.adjust_popup_height() self.adjust_popup_height()
if not self.isVisible(): if not self.isVisible():
return return
@@ -2445,7 +2465,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
return return
btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0)) btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0))
x = btn_pos.x() + btn.width() - self.width() x = btn_pos.x() + btn.width() - self.width()
y = btn_pos.y() - self.height() - 5 y = top_anchor if top_anchor is not None else btn_pos.y() - self.height() - 5
self.move(x, y) self.move(x, y)
def adjust_popup_height(self): def adjust_popup_height(self):
@@ -2494,7 +2514,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
self.anim_group.addAnimation(self.opacity_anim) self.anim_group.addAnimation(self.opacity_anim)
self.anim_group.addAnimation(self.pos_anim) self.anim_group.addAnimation(self.pos_anim)
self.anim_group.start() self.anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
def on_item_clicked(self, item): def on_item_clicked(self, item):
data = item.data(QtCore.Qt.ItemDataRole.UserRole) data = item.data(QtCore.Qt.ItemDataRole.UserRole)
@@ -2680,7 +2700,7 @@ class SessionModePopup(QtWidgets.QWidget):
self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
self.anim_group.addAnimation(self.opacity_anim) self.anim_group.addAnimation(self.opacity_anim)
self.anim_group.addAnimation(self.pos_anim) self.anim_group.addAnimation(self.pos_anim)
self.anim_group.start() self.anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
# ==================== 🌟 PDF 图片提取子线程工作者 ==================== # ==================== 🌟 PDF 图片提取子线程工作者 ====================
@@ -3064,7 +3084,7 @@ class PdfModePopup(QtWidgets.QWidget):
ps.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) ps.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
self.anim_group.addAnimation(op) self.anim_group.addAnimation(op)
self.anim_group.addAnimation(ps) self.anim_group.addAnimation(ps)
self.anim_group.start() self.anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
# 关闭方式:依赖 Qt.Popup 原生行为——点击弹窗以外任意区域即关闭(与 ModelSelectPopup 一致), # 关闭方式:依赖 Qt.Popup 原生行为——点击弹窗以外任意区域即关闭(与 ModelSelectPopup 一致),
# 鼠标移出不再关闭。 # 鼠标移出不再关闭。
@@ -3292,24 +3312,29 @@ class MainWindow(QtWidgets.QMainWindow):
# 🌟 提前初始化(init_model_popup 内部会触发 update_context_display 用到) # 🌟 提前初始化(init_model_popup 内部会触发 update_context_display 用到)
self._active_streams = {} self._active_streams = {}
self._stream_diagnostics = _stream_diagnostics_enabled()
self._diag_chunk_n = 0 self._diag_chunk_n = 0
self._diag_think_n = 0 self._diag_think_n = 0
try: if self._stream_diagnostics:
open(DIAG_LOG_PATH, "w").close() try:
diag_log("APP_START") open(DIAG_LOG_PATH, "w").close()
except Exception: diag_log("APP_START")
pass except Exception:
pass
# 🌟 渲染看门狗:若前端 rAF/定时器被浏览器节流(窗口隐藏/GPU 问题), # 🌟 渲染看门狗:若前端 rAF/定时器被浏览器节流(窗口隐藏/GPU 问题),
# 由 Qt 侧定时器每 200ms 主动 forceRenderNow,保证流式正文一定上屏 # 由 Qt 侧定时器每 200ms 主动 forceRenderNow,保证流式正文一定上屏
self._render_watchdog = QtCore.QTimer(self) self._render_watchdog = QtCore.QTimer(self)
self._render_watchdog.setInterval(200) self._render_watchdog.setInterval(200)
self._render_watchdog.timeout.connect(self._render_watchdog_tick) self._render_watchdog.timeout.connect(self._render_watchdog_tick)
self._render_watchdog.start() self._render_watchdog.start()
# 人肉 debugJS console 桥抽取器(JS console.log → Python 控制台) # 人肉 debugJS console 桥抽取器(JS console.log → Python 控制台)
self._jslog_timer = QtCore.QTimer(self) # 这是诊断通道,不应在普通运行中每 500ms 跨进程执行一次 JS。
self._jslog_timer.setInterval(500) self._jslog_timer = None
self._jslog_timer.timeout.connect(self._jslog_drain_tick) if self._stream_diagnostics:
self._jslog_timer.start() self._jslog_timer = QtCore.QTimer(self)
self._jslog_timer.setInterval(500)
self._jslog_timer.timeout.connect(self._jslog_drain_tick)
self._jslog_timer.start()
# 🆕 Fix E: 上下文标签防抖定时器(工具/思考/正文任一显现 → 400ms 内合并刷新一次) # 🆕 Fix E: 上下文标签防抖定时器(工具/思考/正文任一显现 → 400ms 内合并刷新一次)
self._ctx_refresh_timer = QtCore.QTimer(self) self._ctx_refresh_timer = QtCore.QTimer(self)
self._ctx_refresh_timer.setSingleShot(True) self._ctx_refresh_timer.setSingleShot(True)
@@ -4977,7 +5002,8 @@ class MainWindow(QtWidgets.QMainWindow):
if session_id == self.current_session_id: if session_id == self.current_session_id:
self.chat_bridge.finish_message(msg_id) self.chat_bridge.finish_message(msg_id)
self.browser.page().runJavaScript("dumpDiag()", self._on_diag_dumped) if self._stream_diagnostics:
self.browser.page().runJavaScript("dumpDiag()", self._on_diag_dumped)
print("\n[系统]:✅ 回复完毕。") print("\n[系统]:✅ 回复完毕。")
else: else:
print(f"\n[系统]: ✅ 会话 {session_id[:8]} 回复完毕(后台)") print(f"\n[系统]: ✅ 会话 {session_id[:8]} 回复完毕(后台)")
@@ -4993,21 +5019,27 @@ class MainWindow(QtWidgets.QMainWindow):
f"usage_output={_du.get('output')} timeline={len(_tl0)}") f"usage_output={_du.get('output')} timeline={len(_tl0)}")
except Exception: except Exception:
pass pass
# 完成时刻屏幕快照 + DOM 体检 # 完成时刻屏幕快照 + DOM 体检均为诊断操作;尤其是 WebView2 grab()
try: # 会同步读回原生窗口,可能阻塞渲染器数百毫秒,普通运行绝不执行。
self._shot_n = getattr(self, "_shot_n", 0) + 1 if self._stream_diagnostics:
import os as _os if os.environ.get("HAOCODE_SHOT") == "1":
_shot_path = _os.path.join( try:
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))), self._shot_n = getattr(self, "_shot_n", 0) + 1
f"diag_shot_{self._shot_n:02d}_FINISH.png") import os as _os
self.browser.grab().save(_shot_path) _shot_path = _os.path.join(
print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True) _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
except Exception as _e: f"diag_shot_{self._shot_n:02d}_FINISH.png")
print(f"[画面] 完成截图失败: {_e}", flush=True) self.browser.grab().save(_shot_path)
def _finish_dom_probe(_res): print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True)
diag_log(f"FINISH_DOM {_res}") except Exception as _e:
print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True) print(f"[画面] 完成截图失败: {_e}", flush=True)
self.browser.page().runJavaScript(f"probeStream('{msg_id}')", _finish_dom_probe)
def _finish_dom_probe(_res):
diag_log(f"FINISH_DOM {_res}")
print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True)
self.browser.page().runJavaScript(
f"probeStream('{msg_id}')", _finish_dom_probe)
# 🚀 入库,自动成为时间线新叶子! # 🚀 入库,自动成为时间线新叶子!
if (stream_state["content"] or stream_state["reasoning"] if (stream_state["content"] or stream_state["reasoning"]
or stream_state.get("timeline")): or stream_state.get("timeline")):
@@ -5389,7 +5421,7 @@ class MainWindow(QtWidgets.QMainWindow):
now = _t.time() now = _t.time()
if not hasattr(self, "_last_probe_at"): if not hasattr(self, "_last_probe_at"):
self._last_probe_at = 0 self._last_probe_at = 0
if now - self._last_probe_at >= 2.0: if self._stream_diagnostics and now - self._last_probe_at >= 2.0:
self._last_probe_at = now self._last_probe_at = now
self.browser.page().runJavaScript( self.browser.page().runJavaScript(
f"probeStream('{st['msg_id']}')", self._on_probe_result) f"probeStream('{st['msg_id']}')", self._on_probe_result)
@@ -5438,7 +5470,8 @@ class MainWindow(QtWidgets.QMainWindow):
"""接收到 Token""" """接收到 Token"""
self._diag_chunk_n += 1 self._diag_chunk_n += 1
_match = session_id == self.current_session_id _match = session_id == self.current_session_id
if self._diag_chunk_n <= 3 or self._diag_chunk_n % 50 == 0 or not _match: if self._stream_diagnostics and (
self._diag_chunk_n <= 3 or self._diag_chunk_n % 50 == 0 or not _match):
_st0 = self._active_streams.get(session_id) _st0 = self._active_streams.get(session_id)
diag_log(f"CHUNK n={self._diag_chunk_n} match={_match} " diag_log(f"CHUNK n={self._diag_chunk_n} match={_match} "
f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c") f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c")
@@ -5458,13 +5491,14 @@ class MainWindow(QtWidgets.QMainWindow):
else: else:
st["timeline"][-1]["text"] += chunk st["timeline"][-1]["text"] += chunk
# 人肉 debug:每个正文 token 控制台打印 # 人肉 debug:每个正文 token 控制台打印(仅显式诊断时开启)。
try: if self._stream_diagnostics:
_mid = self._active_streams[session_id]["msg_id"] try:
_flat = " ".join(chunk.split()) _mid = self._active_streams[session_id]["msg_id"]
print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True) _flat = " ".join(chunk.split())
except Exception: print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True)
pass except Exception:
pass
# 只有当前激活的会话才推送到前端显示 # 只有当前激活的会话才推送到前端显示
if session_id == self.current_session_id: if session_id == self.current_session_id:
@@ -5825,7 +5859,8 @@ class MainWindow(QtWidgets.QMainWindow):
"""接收到思考过程""" """接收到思考过程"""
self._diag_think_n += 1 self._diag_think_n += 1
_match = session_id == self.current_session_id _match = session_id == self.current_session_id
if self._diag_think_n <= 3 or self._diag_think_n % 50 == 0 or not _match: if self._stream_diagnostics and (
self._diag_think_n <= 3 or self._diag_think_n % 50 == 0 or not _match):
_st0 = self._active_streams.get(session_id) _st0 = self._active_streams.get(session_id)
diag_log(f"THINK n={self._diag_think_n} match={_match} " diag_log(f"THINK n={self._diag_think_n} match={_match} "
f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c") f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c")
@@ -5845,13 +5880,14 @@ class MainWindow(QtWidgets.QMainWindow):
else: else:
st["timeline"][-1]["text"] += chunk st["timeline"][-1]["text"] += chunk
# 人肉 debug:每个思考 token 控制台打印 # 人肉 debug:每个思考 token 控制台打印(仅显式诊断时开启)。
try: if self._stream_diagnostics:
_mid = self._active_streams[session_id]["msg_id"] try:
_flat = " ".join(chunk.split()) _mid = self._active_streams[session_id]["msg_id"]
print(f"[思考] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:60]}", flush=True) _flat = " ".join(chunk.split())
except Exception: print(f"[思考] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:60]}", flush=True)
pass except Exception:
pass
# 只有当前激活的会话才推送到前端显示 # 只有当前激活的会话才推送到前端显示
if session_id == self.current_session_id: if session_id == self.current_session_id:
@@ -5898,6 +5934,9 @@ class MainWindow(QtWidgets.QMainWindow):
"""JS 就绪检测的回调""" """JS 就绪检测的回调"""
if is_ready: if is_ready:
print("[System]:✅ JS 引擎已就绪,正在加载历史记录...") print("[System]:✅ JS 引擎已就绪,正在加载历史记录...")
if self._stream_diagnostics:
self.chat_bridge.run_js(
"if (window.setStreamDiagnostics) window.setStreamDiagnostics(true);")
# 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载 # 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载
if not self._rw_config_pushed: if not self._rw_config_pushed:
self._rw_config_pushed = True self._rw_config_pushed = True