chore: import original project baseline

Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
"""
WebView2View —— 外观兼容 QWebEngineView 的 QWidget(仅实现 main_window 用到的 API 面):
setUrl / loadFinished / page().runJavaScript / page().setBackgroundColor / grab
内部由 core.webview2.Wv2Session 驱动:
- 30ms timer + resizeEvent 双路 SetBoundsAndZoomFactorslot 矩形 → 父窗客户区物理像素)
- WebMessageReceived → ChatBridge 的 JS→Python slot
- run_js → ExecuteScriptAsync(与 QWebChannel 路径生成的 JS 文本同构)
"""
import json
import ctypes
import ctypes.wintypes as wintypes
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QUrl
# JS → Python 消息名 → ChatBridge slot 名(与 QWebChannel 注册的对象方法一一对应)
_BRIDGE_METHODS = (
"onRegenerateClicked",
"onBranchSwitch",
"onDeleteMessageClicked",
"onAttachmentClicked",
"onScrollChanged",
)
class _PageShim:
"""QWebEnginePage 的最小替身"""
def __init__(self, view):
self._view = view
def runJavaScript(self, script, callback=None):
if callback is None:
self._view.run_js(script)
else:
self._view.execute_js_async(script, callback)
def setBackgroundColor(self, color):
pass # WebView2 底色由页面 CSS 决定(页面本身就是白底)
class WebView2View(QtWidgets.QWidget):
loadFinished = pyqtSignal(bool)
def __init__(self, session, parent=None):
super().__init__(parent)
self._session = session
self._page = _PageShim(self)
self._bridge = None
self._session.on_load_finished = self._on_page_loaded
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
# 子窗口发现(异步创建)
self._child_timer = QTimer(self)
self._child_timer.setInterval(100)
self._child_timer.timeout.connect(self._try_find_child)
self._child_timer.start()
# bounds 同步兜底(resizeEvent 主路 + 30ms 兜底,与 P1 实测一致)
self._sync_timer = QTimer(self)
self._sync_timer.setInterval(30)
self._sync_timer.timeout.connect(self.sync_bounds)
self._sync_timer.start()
# 几何去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞)
self._last_sent_bounds = None
# ---------- 页面加载完成 ----------
def _on_page_loaded(self):
# 立即启动 JS 管线(子窗口全程可见,无节流/竞态)。
# 首帧前的"透底色"问题由 Qt 侧白色 paintEvent 解决(透明子窗表面露出白底)。
self.loadFinished.emit(True)
def paintEvent(self, ev):
# 子窗口隐藏期间(启动首帧前)槽位必须自绘白底,
# 否则 WA_OpaquePaintEvent + 无 paintEvent = 未绘制表面 = 纯黑
p = QtGui.QPainter(self)
p.fillRect(self.rect(), QtGui.QColor("#ffffff"))
p.end()
# ---------- QWebEngineView 兼容 API ----------
def page(self):
return self._page
def setUrl(self, url):
# 接受 QUrl 或 str
s = url.toString() if isinstance(url, QUrl) else str(url)
self._session.navigate(s)
def grab(self):
"""子窗口区域截图(调试工具用)"""
h = self._session.child_hwnd
if not h:
return QtGui.QPixmap(self.size())
r = wintypes.RECT()
if not ctypes.windll.user32.GetWindowRect(wintypes.HWND(h), ctypes.byref(r)):
return QtGui.QPixmap(self.size())
w = max(1, r.right - r.left)
hgt = max(1, r.bottom - r.top)
screen_dc = ctypes.windll.user32.GetDC(0)
mem_dc = ctypes.windll.gdi32.CreateCompatibleDC(screen_dc)
bmp = ctypes.windll.gdi32.CreateCompatibleBitmap(screen_dc, w, hgt)
ctypes.windll.gdi32.SelectObject(mem_dc, bmp)
ctypes.windll.gdi32.BitBlt(mem_dc, 0, 0, w, hgt, screen_dc, r.left, r.top, 0x00CC0020)
# BITMAPINFO
class _BMI(ctypes.Structure):
_fields_ = [("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long),
("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long),
("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD)]
bmi = _BMI()
bmi.biSize = ctypes.sizeof(_BMI)
bmi.biWidth = w
bmi.biHeight = -hgt # 顶向下
bmi.biPlanes = 1
bmi.biBitCount = 32
bmi.biCompression = 0
buf = ctypes.create_string_buffer(w * hgt * 4)
ctypes.windll.gdi32.GetDIBits(mem_dc, bmp, 0, hgt, buf, ctypes.byref(bmi), 0)
ctypes.windll.gdi32.DeleteObject(bmp)
ctypes.windll.gdi32.DeleteDC(mem_dc)
ctypes.windll.user32.ReleaseDC(0, screen_dc)
img = QtGui.QImage(buf, w, hgt, w * 4, QtGui.QImage.Format.Format_RGBA8888).copy()
return QtGui.QPixmap.fromImage(img)
# ---------- 桥接 ----------
def attach_bridge(self, bridge):
self._bridge = bridge
self._session.on_message = self._dispatch_message
def _dispatch_message(self, data):
if not isinstance(data, dict) or not self._bridge:
return
name = data.get("m")
args = data.get("a", [])
if name in _BRIDGE_METHODS:
fn = getattr(self._bridge, name, None)
if fn:
try:
fn(*args)
except Exception as ex:
print(f"[WV2] bridge {name} error:", ex)
# ---------- JS → Python 用的执行通道 ----------
def run_js(self, script):
self._session.execute_js(script)
def execute_js_async(self, script, cb):
self._session.execute_js_async(script, cb)
# ---------- 几何同步 ----------
def _dpr(self):
try:
d = self.dpr()
return d if d > 0 else 1.0
except Exception:
return 1.0
def sync_bounds(self):
if not self.isVisible() or not self._session.child_hwnd:
return
top = self.window()
p = self.mapTo(top, QtCore.QPoint(0, 0))
d = self._dpr()
L, T = p.x() * d, p.y() * d
W, H = max(1, self.width() * d), max(1, self.height() * d)
# 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏)
# 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 ——
# 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上;
# 用户实测:立即发送 = 缩小/放大都实时渲染、无阻塞。
if self._last_sent_bounds == (L, T, W, H):
return
self._session.set_bounds(L, T, W, H)
self._last_sent_bounds = (L, T, W, H)
def resizeEvent(self, ev):
super().resizeEvent(ev)
self.sync_bounds()
def showEvent(self, ev):
super().showEvent(ev)
# 确保渲染层点亮(SDK 初始状态下控制器可能不可见)
self._session.set_visible(True)
self.sync_bounds()
def _force_repaint(self):
"""WebView2 子窗口创建会打断首帧合成 → 强制整窗重绘(治启动时控件渲染丢失)"""
top = self.window()
if top is None:
return
top.update()
QtWidgets.QApplication.processEvents()
def _try_find_child(self):
if self._session.child_hwnd:
self._child_timer.stop()
self._session.set_visible(True)
self.sync_bounds()
self._force_repaint()
QtCore.QTimer.singleShot(300, self._force_repaint)
return
if self._session.find_child_once():
self._child_timer.stop()
self._session.set_visible(True)
self.sync_bounds()
self._force_repaint()
QtCore.QTimer.singleShot(300, self._force_repaint)
# ---------- 生命周期 ----------
def closeEvent(self, ev):
try:
self._child_timer.stop()
self._sync_timer.stop()
self._session.close()
except Exception:
pass
super().closeEvent(ev)