chore: re-submit 2026-09-18 changes to verify webhook

- feat(config): add atomic configuration persistence
- fix(ui): repair chat and model settings rendering
- fix(capture): map selections across high-DPI displays
- fix(webview2): scale native bounds for fractional DPI
- chore: remove test scripts from distribution
This commit is contained in:
2026-09-18 16:13:05 +08:00
parent bc0b92bcdc
commit 5ae660574e
52 changed files with 496 additions and 10492 deletions
+396 -58
View File
@@ -1,4 +1,5 @@
import sys
import copy
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtWebEngineWidgets import QWebEngineView
# 引入我们刚才写的线程工作类
@@ -422,20 +423,27 @@ class AttachmentPreviewOverlay(QtWidgets.QWidget):
class SettingsWindow(QtWidgets.QWidget):
"""设置窗口:虚化遮罩居中自适应卡片"""
"""设置窗口:可覆盖原生 WebView2 的顶层遮罩。"""
close_requested = QtCore.pyqtSignal()
config_changed = QtCore.pyqtSignal(dict)
model_selected = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None, config_data=None):
super().__init__(parent)
self.config_data = config_data or {}
self._main = parent.window() if parent is not None else None
self.config_data = copy.deepcopy(config_data) if isinstance(config_data, dict) else {}
self._closed = False
self._closing = False
self._setup_ui()
self._setup_animation()
# ------------------------------------------------------------------
def _setup_ui(self):
# 全屏透明遮罩(只负责背景展示,不负责关闭)
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.overlay = QtWidgets.QWidget(self)
self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);")
@@ -465,7 +473,8 @@ class SettingsWindow(QtWidgets.QWidget):
body_layout.addWidget(self.sidebar)
self.content_stack = QtWidgets.QStackedWidget()
self.content_stack.addWidget(self._build_provider_section())
self.provider_page = self._build_provider_section()
self.content_stack.addWidget(self.provider_page)
self.content_stack.addWidget(self._build_placeholder_section())
body_layout.addWidget(self.content_stack, 1)
@@ -473,13 +482,24 @@ class SettingsWindow(QtWidgets.QWidget):
self.resize_to_parent()
self.content_stack.setCurrentWidget(self.content_stack.widget(0))
if self._main is not None:
self._main.installEventFilter(self)
# ------------------------------------------------------------------
def eventFilter(self, obj, event):
"""🌟 核心:拦截 main_card 上的鼠标点击,防止冒泡到 overlay 触发关闭"""
"""阻止卡片点击穿透,并跟随主窗口几何/生命周期。"""
if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress:
event.ignore() # 阻止冒泡
return True
if self._main is not None and obj is self._main:
event_type = event.type()
if event_type in (
QtCore.QEvent.Type.Move,
QtCore.QEvent.Type.Resize,
QtCore.QEvent.Type.WindowStateChange):
self.resize_to_parent()
elif event_type in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
self._close(immediate=True)
return super().eventFilter(obj, event)
# ------------------------------------------------------------------
@@ -573,6 +593,8 @@ class SettingsWindow(QtWidgets.QWidget):
"} "
"QPushButton:hover { background-color: #1565c0; }"
)
add_btn.setObjectName("settings_add_provider")
add_btn.clicked.connect(self._prompt_new_provider)
header_layout.addWidget(add_btn)
layout.addWidget(header)
@@ -590,6 +612,8 @@ class SettingsWindow(QtWidgets.QWidget):
inner_layout.setSpacing(10)
providers = self.config_data.get("providers", {})
if not isinstance(providers, dict):
providers = {}
if not providers:
empty = QtWidgets.QLabel("暂无配置")
empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
@@ -608,7 +632,9 @@ class SettingsWindow(QtWidgets.QWidget):
# ------------------------------------------------------------------
def _build_provider_card(self, name: str, config: dict) -> QtWidgets.QWidget:
config = config if isinstance(config, dict) else {}
card = QtWidgets.QWidget()
card.setProperty("providerName", name)
card.setStyleSheet(
"background-color: #fafafa; border-radius: 10px; "
"border: 1px solid #e8e8e8;"
@@ -635,6 +661,9 @@ class SettingsWindow(QtWidgets.QWidget):
"} "
"QPushButton:hover { background-color: #e0e0e0; color: #333; }"
)
edit_btn.setProperty("providerName", name)
edit_btn.clicked.connect(
lambda checked=False, provider=name: self._prompt_edit_provider(provider))
row1.addWidget(edit_btn)
add_btn = QtWidgets.QPushButton("+模型")
@@ -647,11 +676,15 @@ class SettingsWindow(QtWidgets.QWidget):
"} "
"QPushButton:hover { background-color: #bbdefb; }"
)
add_btn.setObjectName("settings_add_model")
add_btn.setProperty("providerName", name)
add_btn.clicked.connect(
lambda checked=False, provider=name: self._prompt_add_model(provider))
row1.addWidget(add_btn)
layout.addLayout(row1)
# 🌟 第二行:模型垂直排列,加左缩进,不超出边框
models = config.get("models", [])
models = self._model_names(config)
if models:
models_wrap = QtWidgets.QWidget()
models_layout = QtWidgets.QVBoxLayout(models_wrap)
@@ -659,7 +692,9 @@ class SettingsWindow(QtWidgets.QWidget):
models_layout.setSpacing(5)
for model_name in models:
mbtn = QtWidgets.QPushButton(model_name)
mbtn = QtWidgets.QPushButton(str(model_name))
mbtn.setProperty("providerName", name)
mbtn.setProperty("modelName", model_name)
mbtn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开
mbtn.setStyleSheet(
@@ -672,7 +707,8 @@ class SettingsWindow(QtWidgets.QWidget):
"QPushButton:hover { background-color: #d0ebff; }"
)
mbtn.clicked.connect(
lambda checked, m=model_name: print(f"[设置] 选中模型: {m}")
lambda checked=False, provider=name, model=model_name:
self._select_model(provider, model)
)
models_layout.addWidget(mbtn)
@@ -686,6 +722,192 @@ class SettingsWindow(QtWidgets.QWidget):
return card
@staticmethod
def _model_names(config: dict) -> list:
"""兼容字符串、对象列表、映射及旧 model_contexts 配置。"""
if not isinstance(config, dict):
return []
raw = config.get("models", [])
if isinstance(raw, str):
raw = [raw]
elif isinstance(raw, dict):
raw = list(raw.keys())
elif not isinstance(raw, (list, tuple)):
raw = []
names = []
for entry in raw:
if isinstance(entry, str):
name = entry.strip()
elif isinstance(entry, dict):
name = next((str(entry.get(key, "")).strip()
for key in ("id", "name", "model", "value")
if str(entry.get(key, "")).strip()), "")
else:
name = ""
if name and name not in names:
names.append(name)
for key in ("model_contexts", "model_max_tokens"):
mapping = config.get(key, {})
if isinstance(mapping, dict):
for name in mapping:
name = str(name).strip()
if name and name not in names:
names.append(name)
return names
def _refresh_provider_section(self):
"""用当前内存配置重建供应商页,避免旧控件残留。"""
old_page = self.provider_page
index = self.content_stack.indexOf(old_page)
new_page = self._build_provider_section()
self.content_stack.insertWidget(max(0, index), new_page)
self.provider_page = new_page
self.content_stack.setCurrentWidget(new_page)
self.content_stack.removeWidget(old_page)
old_page.deleteLater()
def _commit_config(self, candidate: dict) -> bool:
"""先原子落盘,成功后再更新界面和主窗口。"""
from core.config_paths import save_config
if not save_config(candidate):
return False
self.config_data = copy.deepcopy(candidate)
self._refresh_provider_section()
self.config_changed.emit(copy.deepcopy(self.config_data))
return True
def _create_provider(self, name: str, base_url: str = "", api_key: str = "") -> bool:
"""创建供应商;供按钮路径与自动化测试共用。"""
provider_name = str(name).strip()
if not provider_name:
return False
candidate = copy.deepcopy(self.config_data)
providers = candidate.get("providers")
if not isinstance(providers, dict):
providers = {}
candidate["providers"] = providers
if provider_name in providers:
return False
providers[provider_name] = {
"api_key": str(api_key).strip(),
"base_url": str(base_url).strip(),
"models": [],
"model_contexts": {},
}
return self._commit_config(candidate)
def _update_provider(self, name: str, base_url: str, api_key: str) -> bool:
candidate = copy.deepcopy(self.config_data)
providers = candidate.get("providers")
info = providers.get(name) if isinstance(providers, dict) else None
if not isinstance(info, dict):
return False
info["base_url"] = str(base_url).strip()
info["api_key"] = str(api_key).strip()
return self._commit_config(candidate)
def _add_model(self, provider: str, model: str) -> bool:
"""向供应商添加模型,并立即重建设置列表与主模型弹窗。"""
model_name = str(model).strip()
if not model_name:
return False
candidate = copy.deepcopy(self.config_data)
providers = candidate.get("providers")
info = providers.get(provider) if isinstance(providers, dict) else None
if not isinstance(info, dict) or model_name in self._model_names(info):
return False
raw = info.get("models")
if isinstance(raw, list):
raw.append(model_name)
elif isinstance(raw, dict):
raw[model_name] = {}
elif isinstance(raw, str) and raw.strip():
info["models"] = [raw.strip(), model_name]
else:
info["models"] = [model_name]
contexts = info.get("model_contexts")
if not isinstance(contexts, dict):
contexts = {}
info["model_contexts"] = contexts
contexts.setdefault(model_name, 128000)
if not candidate.get("default_provider") or not candidate.get("default_model"):
candidate["default_provider"] = provider
candidate["default_model"] = model_name
return self._commit_config(candidate)
def _select_model(self, provider: str, model: str):
providers = self.config_data.get("providers", {})
info = providers.get(provider) if isinstance(providers, dict) else None
if model not in self._model_names(info):
return
candidate = copy.deepcopy(self.config_data)
candidate["default_provider"] = provider
candidate["default_model"] = model
if self._commit_config(candidate):
self.model_selected.emit(provider, model)
def _provider_dialog(self, title: str, name: str = "", config=None, editing=False):
config = config if isinstance(config, dict) else {}
dialog = QtWidgets.QDialog(self)
dialog.setWindowTitle(title)
dialog.setModal(True)
dialog.setMinimumWidth(430)
layout = QtWidgets.QFormLayout(dialog)
layout.setContentsMargins(22, 18, 22, 18)
layout.setSpacing(12)
name_input = QtWidgets.QLineEdit(name)
name_input.setReadOnly(editing)
base_input = QtWidgets.QLineEdit(str(config.get("base_url", "")))
base_input.setPlaceholderText("https://example.com/v1")
key_input = QtWidgets.QLineEdit(str(config.get("api_key", "")))
key_input.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
layout.addRow("名称", name_input)
layout.addRow("Base URL", base_input)
layout.addRow("API Key", key_input)
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Save
| QtWidgets.QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addRow(buttons)
name_input.setFocus()
if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return None
return name_input.text().strip(), base_input.text().strip(), key_input.text().strip()
def _prompt_new_provider(self):
result = self._provider_dialog("新建模型提供商")
if result is None:
return
if not self._create_provider(*result):
QtWidgets.QMessageBox.warning(self, "无法创建", "提供商名称为空、已存在或配置保存失败。")
def _prompt_edit_provider(self, provider: str):
providers = self.config_data.get("providers", {})
info = providers.get(provider) if isinstance(providers, dict) else None
if not isinstance(info, dict):
return
result = self._provider_dialog(
"编辑模型提供商", provider, info, editing=True)
if result is None:
return
_, base_url, api_key = result
if not self._update_provider(provider, base_url, api_key):
QtWidgets.QMessageBox.warning(self, "保存失败", "未能保存提供商配置。")
def _prompt_add_model(self, provider: str):
model, accepted = QtWidgets.QInputDialog.getText(
self, "添加模型", f"{provider} 的模型名称:")
if accepted and not self._add_model(provider, model):
QtWidgets.QMessageBox.warning(self, "无法添加", "模型名称为空、已存在或配置保存失败。")
# ------------------------------------------------------------------
def _build_placeholder_section(self) -> QtWidgets.QWidget:
page = QtWidgets.QWidget()
@@ -727,11 +949,14 @@ class SettingsWindow(QtWidgets.QWidget):
# ------------------------------------------------------------------
def resize_to_parent(self):
parent = self.parentWidget()
if not parent:
if self._main is None:
return
self.setFixedSize(parent.size())
self.overlay.setFixedSize(self.size())
# SettingsWindow 是顶层 Tool 窗口,geometry 必须使用屏幕坐标;
# 主窗口的 rect 则准确表示不含系统标题栏的客户区。
top_left = self._main.mapToGlobal(self._main.rect().topLeft())
self.setGeometry(QtCore.QRect(top_left, self._main.rect().size()))
self.overlay.setGeometry(self.rect())
W = int(self.width() * 0.8)
H = int(self.height() * 0.88)
@@ -743,72 +968,66 @@ class SettingsWindow(QtWidgets.QWidget):
# ------------------------------------------------------------------
def _setup_animation(self):
self._anim = QtCore.QPropertyAnimation(self.overlay, b"windowOpacity")
self._anim.setDuration(500)
self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(160)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
# ------------------------------------------------------------------
def show_with_animation(self):
# 🌟 关键:先 setFixedSize 再 show,否则 show() 时瞬间以极小尺寸闪一下
self.setFixedSize(self.parentWidget().size())
self.overlay.setFixedSize(self.size())
self.overlay.setGraphicsEffect(None) # 重置之前可能的 effect
# 🌟 初始 opacity=0,再 show 出来,然后做正向动画淡入
self.resize_to_parent()
self.setWindowOpacity(0.0)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward)
self.show()
self.raise_()
self.activateWindow()
self._anim.start()
# ------------------------------------------------------------------
def _close(self, immediate=False):
"""关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画"""
if self._closed:
return
if immediate:
self._do_close()
else:
if self._closing:
return
self._closing = True
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Backward)
try:
self._anim.finished.disconnect(self._on_close_finished)
except TypeError:
pass
self._anim.finished.connect(self._on_close_finished)
self._anim.start()
def _do_close(self):
"""直接关闭,不播放动画"""
if self._closed:
return
self._closed = True
try:
self._anim.finished.disconnect(self._on_close_finished)
except TypeError:
pass
if self._main is not None:
self._main.removeEventFilter(self)
self.hide()
parent = self.parent()
if parent:
for attr_name in dir(parent):
try:
if getattr(parent, attr_name, None) is self:
setattr(parent, attr_name, None)
break
except Exception:
pass
if parent is not None and getattr(parent, "_settings_win", None) is self:
parent._settings_win = None
self.deleteLater()
self.close_requested.emit()
# ------------------------------------------------------------------
def _on_close_finished(self):
self._anim.finished.disconnect(self._on_close_finished)
self.hide()
parent = self.parent()
if parent:
for attr_name in dir(parent):
try:
if getattr(parent, attr_name, None) is self:
setattr(parent, attr_name, None)
break
except Exception:
pass
self.deleteLater()
self.close_requested.emit()
self._do_close()
# ------------------------------------------------------------------
def keyPressEvent(self, event):
@@ -1725,6 +1944,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
_p.end()
self._model_icon = QtGui.QIcon(_pad)
self._groups = [] # [{"provider", "header_item", "model_items", "expanded", "chevron"}]
self._empty_item = None
self._drawer = None # 进行中的抽屉动画状态 {group, t, timer, row_h, anchor_bottom}
self.setup_ui()
@@ -1820,13 +2040,62 @@ class ModelSelectPopup(QtWidgets.QWidget):
""")
@staticmethod
def _model_names(info):
"""兼容字符串、对象列表和以模型名为键的映射,返回去重后的模型名。"""
if not isinstance(info, dict):
return []
raw_models = info.get("models", [])
if isinstance(raw_models, dict):
raw_models = list(raw_models.keys())
elif isinstance(raw_models, str):
raw_models = [raw_models]
elif not isinstance(raw_models, (list, tuple)):
raw_models = []
names = []
for raw in raw_models:
name = raw.strip() if isinstance(raw, str) else ""
if isinstance(raw, dict):
for key in ("id", "name", "model", "value"):
value = raw.get(key)
if isinstance(value, str) and value.strip():
name = value.strip()
break
if name and name not in names:
names.append(name)
# 某些旧配置只保存模型参数映射,没有显式 models 数组。
for key in ("model_contexts", "model_max_tokens"):
mapping = info.get(key)
if not isinstance(mapping, dict):
continue
for name in mapping:
if isinstance(name, str) and name.strip() and name.strip() not in names:
names.append(name.strip())
return names
def reload_config(self, config_data):
"""按最新配置重建列表;配置缺失时仍保留可理解、可绘制的空状态。"""
self._finish_drawer()
self.config_data = config_data if isinstance(config_data, dict) else {}
self.list_widget.clear()
self._groups.clear()
self._empty_item = None
self.populate_data()
self.adjust_popup_height()
def populate_data(self):
providers = self.config_data.get("providers", {})
if not isinstance(providers, dict):
providers = {}
# 🆕 图标区宽 = 16 + 34px 透明左边(缩进),高 16(调参工具确认)
self.list_widget.setIconSize(QtCore.QSize(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE))
for provider_name, info in providers.items():
models = info.get("models", [])
if not isinstance(provider_name, str) or not provider_name.strip():
continue
models = self._model_names(info)
if not models: continue
# --- 🆕 供应商分组头(整行可点:展开/收起抽屉) ---
@@ -1922,6 +2191,38 @@ class ModelSelectPopup(QtWidgets.QWidget):
"chevron": lbl_chevron,
})
if self._groups:
return
default_provider = self.config_data.get("default_provider")
default_model = self.config_data.get("default_model")
if isinstance(default_provider, str) and default_provider.strip() \
and isinstance(default_model, str) and default_model.strip():
self.config_data = dict(self.config_data)
self.config_data["providers"] = {
default_provider.strip(): {"models": [default_model.strip()]}
}
self.populate_data()
return
self._empty_item = QtWidgets.QListWidgetItem("未配置可用模型")
self._empty_item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags)
self._empty_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._empty_item.setForeground(QtGui.QColor("#8a94a6"))
self._empty_item.setSizeHint(QtCore.QSize(0, 42))
self.list_widget.addItem(self._empty_item)
def mark_selected(self, provider, model):
"""打开弹窗时高亮当前模型,并确保滚动到可见位置。"""
for group in self._groups:
for item in group["model_items"]:
if item.data(QtCore.Qt.ItemDataRole.UserRole) == (provider, model):
self.list_widget.setCurrentItem(item)
self.list_widget.scrollToItem(
item, QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible)
return
self.list_widget.clearSelection()
# ==================== 🆕 抽屉 v2fade + 相邻供应商行滑移 ====================
# 收起:A 段(顶部锚定,模型行 27px 不变原地淡出 1→0,下方供应商行整体
# 上滑逐渐盖住 → 两供应商行合并)+ B 段(整体下滑回座按钮锚点)
@@ -1946,7 +2247,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
return max(self.MIN_POPUP_H, min(self.MAX_POPUP_H, natural))
def toggle_group(self, provider_name: str):
"""点击供应商头 → 折叠/展开该供应商的模型组(🆕 抽屉 v2 动画)"""
"""点击供应商头后立即应用稳定终态,避免列表子控件动画产生残影。"""
g = None
for gg in self._groups:
if gg["provider"] == provider_name:
@@ -1960,12 +2261,11 @@ class ModelSelectPopup(QtWidgets.QWidget):
# SVG 箭头切换(展开=朝下 / 收起=朝右)
g["chevron"].setPixmap(
self._arrow_collapsed if not g["expanded"] else self._arrow_expanded)
if not self.isVisible():
# 防御:弹窗未显示时直接应用终态(不播动画)
self._apply_group_state(g)
self._apply_height_and_position()
return
self._start_drawer(g)
self._clear_fx()
self._apply_group_state(g)
self.list_widget.doItemsLayout()
self._apply_height_and_position()
self.list_widget.viewport().update()
def _apply_group_state(self, g):
"""应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)"""
@@ -5446,6 +5746,13 @@ class MainWindow(QtWidgets.QMainWindow):
from core.config_paths import load_config as _load_cfg
self.config_data = _load_cfg() or {"providers": {}}
default_p = self.config_data.get("default_provider", "GPTBest")
default_m = self.config_data.get("default_model", "gemini-3.1-pro-preview-thinking-high")
# 即使配置缺失,也让弹窗与按钮共享同一默认项,避免按钮有文字而列表为空。
self.config_data = dict(self.config_data)
self.config_data.setdefault("default_provider", default_p)
self.config_data.setdefault("default_model", default_m)
# 2. 实例化我们写的自定义弹窗 (先不显示)
self.model_popup = ModelSelectPopup(self, self.config_data)
@@ -5458,19 +5765,34 @@ class MainWindow(QtWidgets.QMainWindow):
self.model_selector.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
# 5. 设置默认选中
default_p = self.config_data.get("default_provider", "GPTBest")
default_m = self.config_data.get("default_model", "gemini-3.1-pro-preview-thinking-high")
self.on_model_selected(default_p, default_m)
def show_model_popup(self):
"""计算位置并执行动画弹出"""
"""刷新模型列表,在当前屏幕内计算位置并显示。"""
from core.config_paths import load_config as _load_cfg
latest_config = _load_cfg() or {}
latest_config = dict(latest_config)
latest_config.setdefault("default_provider", self.current_provider or "GPTBest")
latest_config.setdefault(
"default_model", self.current_model or "gemini-3.1-pro-preview-thinking-high")
self.config_data = latest_config
self.model_popup.reload_config(latest_config)
self.model_popup.mark_selected(self.current_provider, self.current_model)
# 获取按钮在屏幕上的全局坐标
btn_pos = self.model_selector.mapToGlobal(QtCore.QPoint(0, 0))
# 计算弹窗最终应该停留的目标坐标
# 优先放在按钮上方并右对齐;空间不足时放到下方,最终钳制在当前屏幕内。
x = btn_pos.x() + self.model_selector.width() - self.model_popup.width()
y = btn_pos.y() - self.model_popup.height() - 5
screen = QtGui.QGuiApplication.screenAt(btn_pos) or self.screen()
if screen is not None:
area = screen.availableGeometry()
x = max(area.left(), min(x, area.right() - self.model_popup.width() + 1))
if y < area.top():
y = btn_pos.y() + self.model_selector.height() + 5
y = max(area.top(), min(y, area.bottom() - self.model_popup.height() + 1))
# 🌟 调用动画显示方法,传入目标坐标
self.model_popup.show_with_animation(QtCore.QPoint(x, y))
@@ -5489,6 +5811,16 @@ class MainWindow(QtWidgets.QMainWindow):
#新增:切换模型后刷新上下文显示(因为不同模型上限不同)
self.update_context_display()
def _on_settings_config_changed(self, config_data):
"""设置页保存后同步刷新主窗口和模型弹窗。"""
if not isinstance(config_data, dict):
return
self.config_data = copy.deepcopy(config_data)
self.model_popup.reload_config(self.config_data)
self.model_popup.mark_selected(
self.current_provider, self.current_model)
self.update_context_display()
def on_reasoning_received(self, session_id, chunk: str):
"""接收到思考过程"""
self._diag_think_n += 1
@@ -6106,14 +6438,20 @@ class MainWindow(QtWidgets.QMainWindow):
return widget
def show_settings(self):
if hasattr(self, '_settings_win') and self._settings_win is not None:
self._settings_win.deleteLater()
self._settings_win = None
existing = getattr(self, '_settings_win', None)
if existing is not None:
existing._close(immediate=True)
if hasattr(self, "model_popup"):
self.model_popup.hide()
self._settings_win = SettingsWindow(
parent=self,
config_data=self.config_data
)
self._settings_win.config_changed.connect(
self._on_settings_config_changed)
self._settings_win.model_selected.connect(self.on_model_selected)
self._settings_win.show_with_animation()
def _on_branch_switch(self, msg_id, direction):
"""🌟 核心:处理用户点击左右箭头切换分支"""
+42 -12
View File
@@ -74,13 +74,44 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
self.activateWindow()
self.raise_()
@staticmethod
def _scale_rect(rect, logical_size, native_size):
"""把 Qt 逻辑坐标选区映射到抓屏位图的原生像素坐标。"""
if (logical_size.width() <= 0 or logical_size.height() <= 0
or native_size.width() <= 0 or native_size.height() <= 0):
return QtCore.QRect()
logical_bounds = QtCore.QRect(QtCore.QPoint(), logical_size)
clipped = rect.normalized().intersected(logical_bounds)
if clipped.isEmpty():
return QtCore.QRect()
scale_x = native_size.width() / logical_size.width()
scale_y = native_size.height() / logical_size.height()
left = round(clipped.x() * scale_x)
top = round(clipped.y() * scale_y)
right = round((clipped.x() + clipped.width()) * scale_x)
bottom = round((clipped.y() + clipped.height()) * scale_y)
left = max(0, min(native_size.width(), left))
top = max(0, min(native_size.height(), top))
right = max(left, min(native_size.width(), right))
bottom = max(top, min(native_size.height(), bottom))
return QtCore.QRect(left, top, right - left, bottom - top)
def _native_rect(self, rect):
"""返回选区在当前抓屏位图中的原生像素矩形。"""
if self._full_pixmap is None or self._full_pixmap.isNull():
return QtCore.QRect()
return self._scale_rect(rect, self.size(), self._full_pixmap.size())
def paintEvent(self, event):
if not self._full_pixmap:
return
painter = QtGui.QPainter(self)
# 1. 绘制屏幕截图作为背景
painter.drawPixmap(0, 0, self._full_pixmap)
painter.drawPixmap(self.rect(), self._full_pixmap, self._full_pixmap.rect())
# 2. 半透明遮罩
painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100))
@@ -92,15 +123,17 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
rect = self._current_rect
if rect.width() > 0 and rect.height() > 0:
native_rect = self._native_rect(rect)
# 3. 选区内重绘原图(去掉遮罩,形成高亮效果)
painter.drawPixmap(rect, self._full_pixmap, rect)
painter.drawPixmap(rect, self._full_pixmap, native_rect)
# 4. 蓝色边框
pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
painter.drawRect(rect)
# 5. 尺寸标注
size_text = f"{rect.width()} x {rect.height()}"
# 标签显示最终文件的真实像素,而不是受系统缩放影响的逻辑尺寸。
size_text = f"{native_rect.width()} x {native_rect.height()} px"
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
@@ -177,15 +210,12 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
def _on_confirm(self):
"""确认截图:裁剪并发射信号"""
if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5:
dpr = self._full_pixmap.devicePixelRatio()
phys_rect = QtCore.QRect(
int(self._current_rect.x() * dpr),
int(self._current_rect.y() * dpr),
int(self._current_rect.width() * dpr),
int(self._current_rect.height() * dpr),
)
captured = self._full_pixmap.toImage().copy(phys_rect)
self.screenshot_captured.emit(captured)
native_rect = self._native_rect(self._current_rect)
if not native_rect.isEmpty():
captured = self._full_pixmap.toImage().copy(native_rect)
# 截图是普通位图;避免下游再次按桌面 DPR 缩小显示。
captured.setDevicePixelRatio(1.0)
self.screenshot_captured.emit(captured)
self.close()
def _on_cancel(self):
+16 -3
View File
@@ -152,19 +152,32 @@ class WebView2View(QtWidgets.QWidget):
# ---------- 几何同步 ----------
def _dpr(self):
try:
d = self.dpr()
d = float(self.devicePixelRatioF())
return d if d > 0 else 1.0
except Exception:
return 1.0
@staticmethod
def _physical_bounds(x, y, width, height, dpr):
"""按矩形边界换算物理像素,避免分数 DPR 产生相邻白缝。"""
def pixel(value):
scaled = value * dpr
return int(scaled + 0.5) if scaled >= 0 else int(scaled - 0.5)
left = pixel(x)
top = pixel(y)
right = pixel(x + width)
bottom = pixel(y + height)
return left, top, max(1, right - left), max(1, bottom - top)
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)
L, T, W, H = self._physical_bounds(
p.x(), p.y(), self.width(), self.height(), d)
# 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏)
# 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 ——
# 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上;