fix(ui): repair chat and model settings rendering

This commit is contained in:
2026-09-18 15:14:20 +08:00
parent 118f46b872
commit b45a757982
2 changed files with 406 additions and 62 deletions
+394 -56
View File
@@ -1,4 +1,5 @@
import sys import sys
import copy
from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
# 引入我们刚才写的线程工作类 # 引入我们刚才写的线程工作类
@@ -422,20 +423,27 @@ class AttachmentPreviewOverlay(QtWidgets.QWidget):
class SettingsWindow(QtWidgets.QWidget): class SettingsWindow(QtWidgets.QWidget):
"""设置窗口:虚化遮罩居中自适应卡片""" """设置窗口:可覆盖原生 WebView2 的顶层遮罩。"""
close_requested = QtCore.pyqtSignal() close_requested = QtCore.pyqtSignal()
config_changed = QtCore.pyqtSignal(dict)
model_selected = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None, config_data=None): def __init__(self, parent=None, config_data=None):
super().__init__(parent) 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_ui()
self._setup_animation() self._setup_animation()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _setup_ui(self): def _setup_ui(self):
# 全屏透明遮罩(只负责背景展示,不负责关闭) # 全屏透明遮罩(只负责背景展示,不负责关闭)
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True) 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 = QtWidgets.QWidget(self)
self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);") self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);")
@@ -465,7 +473,8 @@ class SettingsWindow(QtWidgets.QWidget):
body_layout.addWidget(self.sidebar) body_layout.addWidget(self.sidebar)
self.content_stack = QtWidgets.QStackedWidget() 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()) self.content_stack.addWidget(self._build_placeholder_section())
body_layout.addWidget(self.content_stack, 1) body_layout.addWidget(self.content_stack, 1)
@@ -473,13 +482,24 @@ class SettingsWindow(QtWidgets.QWidget):
self.resize_to_parent() self.resize_to_parent()
self.content_stack.setCurrentWidget(self.content_stack.widget(0)) self.content_stack.setCurrentWidget(self.content_stack.widget(0))
if self._main is not None:
self._main.installEventFilter(self)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
"""🌟 核心:拦截 main_card 上的鼠标点击,防止冒泡到 overlay 触发关闭""" """阻止卡片点击穿透,并跟随主窗口几何/生命周期。"""
if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress: if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress:
event.ignore() # 阻止冒泡 event.ignore() # 阻止冒泡
return True 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) return super().eventFilter(obj, event)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -573,6 +593,8 @@ class SettingsWindow(QtWidgets.QWidget):
"} " "} "
"QPushButton:hover { background-color: #1565c0; }" "QPushButton:hover { background-color: #1565c0; }"
) )
add_btn.setObjectName("settings_add_provider")
add_btn.clicked.connect(self._prompt_new_provider)
header_layout.addWidget(add_btn) header_layout.addWidget(add_btn)
layout.addWidget(header) layout.addWidget(header)
@@ -590,6 +612,8 @@ class SettingsWindow(QtWidgets.QWidget):
inner_layout.setSpacing(10) inner_layout.setSpacing(10)
providers = self.config_data.get("providers", {}) providers = self.config_data.get("providers", {})
if not isinstance(providers, dict):
providers = {}
if not providers: if not providers:
empty = QtWidgets.QLabel("暂无配置") empty = QtWidgets.QLabel("暂无配置")
empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter) 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: def _build_provider_card(self, name: str, config: dict) -> QtWidgets.QWidget:
config = config if isinstance(config, dict) else {}
card = QtWidgets.QWidget() card = QtWidgets.QWidget()
card.setProperty("providerName", name)
card.setStyleSheet( card.setStyleSheet(
"background-color: #fafafa; border-radius: 10px; " "background-color: #fafafa; border-radius: 10px; "
"border: 1px solid #e8e8e8;" "border: 1px solid #e8e8e8;"
@@ -635,6 +661,9 @@ class SettingsWindow(QtWidgets.QWidget):
"} " "} "
"QPushButton:hover { background-color: #e0e0e0; color: #333; }" "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) row1.addWidget(edit_btn)
add_btn = QtWidgets.QPushButton("+模型") add_btn = QtWidgets.QPushButton("+模型")
@@ -647,11 +676,15 @@ class SettingsWindow(QtWidgets.QWidget):
"} " "} "
"QPushButton:hover { background-color: #bbdefb; }" "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) row1.addWidget(add_btn)
layout.addLayout(row1) layout.addLayout(row1)
# 🌟 第二行:模型垂直排列,加左缩进,不超出边框 # 🌟 第二行:模型垂直排列,加左缩进,不超出边框
models = config.get("models", []) models = self._model_names(config)
if models: if models:
models_wrap = QtWidgets.QWidget() models_wrap = QtWidgets.QWidget()
models_layout = QtWidgets.QVBoxLayout(models_wrap) models_layout = QtWidgets.QVBoxLayout(models_wrap)
@@ -659,7 +692,9 @@ class SettingsWindow(QtWidgets.QWidget):
models_layout.setSpacing(5) models_layout.setSpacing(5)
for model_name in models: 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.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开 mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开
mbtn.setStyleSheet( mbtn.setStyleSheet(
@@ -672,7 +707,8 @@ class SettingsWindow(QtWidgets.QWidget):
"QPushButton:hover { background-color: #d0ebff; }" "QPushButton:hover { background-color: #d0ebff; }"
) )
mbtn.clicked.connect( 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) models_layout.addWidget(mbtn)
@@ -686,6 +722,192 @@ class SettingsWindow(QtWidgets.QWidget):
return card 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: def _build_placeholder_section(self) -> QtWidgets.QWidget:
page = QtWidgets.QWidget() page = QtWidgets.QWidget()
@@ -727,11 +949,14 @@ class SettingsWindow(QtWidgets.QWidget):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def resize_to_parent(self): def resize_to_parent(self):
parent = self.parentWidget() if self._main is None:
if not parent:
return 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) W = int(self.width() * 0.8)
H = int(self.height() * 0.88) H = int(self.height() * 0.88)
@@ -743,72 +968,66 @@ class SettingsWindow(QtWidgets.QWidget):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _setup_animation(self): def _setup_animation(self):
self._anim = QtCore.QPropertyAnimation(self.overlay, b"windowOpacity") self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(500) self._anim.setDuration(160)
self._anim.setStartValue(0.0) self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0) self._anim.setEndValue(1.0)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def show_with_animation(self): def show_with_animation(self):
# 🌟 关键:先 setFixedSize 再 show,否则 show() 时瞬间以极小尺寸闪一下 self.resize_to_parent()
self.setFixedSize(self.parentWidget().size()) self.setWindowOpacity(0.0)
self.overlay.setFixedSize(self.size())
self.overlay.setGraphicsEffect(None) # 重置之前可能的 effect
# 🌟 初始 opacity=0,再 show 出来,然后做正向动画淡入
self._anim.setStartValue(0.0) self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0) self._anim.setEndValue(1.0)
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward) self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward)
self.show() self.show()
self.raise_()
self.activateWindow()
self._anim.start() self._anim.start()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _close(self, immediate=False): def _close(self, immediate=False):
"""关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画""" """关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画"""
if self._closed:
return
if immediate: if immediate:
self._do_close() self._do_close()
else: else:
if self._closing:
return
self._closing = True
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Backward) 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.finished.connect(self._on_close_finished)
self._anim.start() self._anim.start()
def _do_close(self): def _do_close(self):
"""直接关闭,不播放动画""" """直接关闭,不播放动画"""
if self._closed:
return
self._closed = True
try: try:
self._anim.finished.disconnect(self._on_close_finished) self._anim.finished.disconnect(self._on_close_finished)
except TypeError: except TypeError:
pass pass
if self._main is not None:
self._main.removeEventFilter(self)
self.hide() self.hide()
parent = self.parent() parent = self.parent()
if parent: if parent is not None and getattr(parent, "_settings_win", None) is self:
for attr_name in dir(parent): parent._settings_win = None
try:
if getattr(parent, attr_name, None) is self:
setattr(parent, attr_name, None)
break
except Exception:
pass
self.deleteLater() self.deleteLater()
self.close_requested.emit() self.close_requested.emit()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _on_close_finished(self): def _on_close_finished(self):
self._anim.finished.disconnect(self._on_close_finished) self._do_close()
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()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def keyPressEvent(self, event): def keyPressEvent(self, event):
@@ -1725,6 +1944,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
_p.end() _p.end()
self._model_icon = QtGui.QIcon(_pad) self._model_icon = QtGui.QIcon(_pad)
self._groups = [] # [{"provider", "header_item", "model_items", "expanded", "chevron"}] self._groups = [] # [{"provider", "header_item", "model_items", "expanded", "chevron"}]
self._empty_item = None
self._drawer = None # 进行中的抽屉动画状态 {group, t, timer, row_h, anchor_bottom} self._drawer = None # 进行中的抽屉动画状态 {group, t, timer, row_h, anchor_bottom}
self.setup_ui() 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): def populate_data(self):
providers = self.config_data.get("providers", {}) providers = self.config_data.get("providers", {})
if not isinstance(providers, dict):
providers = {}
# 🆕 图标区宽 = 16 + 34px 透明左边(缩进),高 16(调参工具确认) # 🆕 图标区宽 = 16 + 34px 透明左边(缩进),高 16(调参工具确认)
self.list_widget.setIconSize(QtCore.QSize(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE)) self.list_widget.setIconSize(QtCore.QSize(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE))
for provider_name, info in providers.items(): 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 if not models: continue
# --- 🆕 供应商分组头(整行可点:展开/收起抽屉) --- # --- 🆕 供应商分组头(整行可点:展开/收起抽屉) ---
@@ -1922,6 +2191,38 @@ class ModelSelectPopup(QtWidgets.QWidget):
"chevron": lbl_chevron, "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 + 相邻供应商行滑移 ==================== # ==================== 🆕 抽屉 v2fade + 相邻供应商行滑移 ====================
# 收起:A 段(顶部锚定,模型行 27px 不变原地淡出 1→0,下方供应商行整体 # 收起:A 段(顶部锚定,模型行 27px 不变原地淡出 1→0,下方供应商行整体
# 上滑逐渐盖住 → 两供应商行合并)+ B 段(整体下滑回座按钮锚点) # 上滑逐渐盖住 → 两供应商行合并)+ B 段(整体下滑回座按钮锚点)
@@ -1946,7 +2247,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
return max(self.MIN_POPUP_H, min(self.MAX_POPUP_H, natural)) return max(self.MIN_POPUP_H, min(self.MAX_POPUP_H, natural))
def toggle_group(self, provider_name: str): def toggle_group(self, provider_name: str):
"""点击供应商头 → 折叠/展开该供应商的模型组(🆕 抽屉 v2 动画)""" """点击供应商头后立即应用稳定终态,避免列表子控件动画产生残影。"""
g = None g = None
for gg in self._groups: for gg in self._groups:
if gg["provider"] == provider_name: if gg["provider"] == provider_name:
@@ -1960,12 +2261,11 @@ class ModelSelectPopup(QtWidgets.QWidget):
# SVG 箭头切换(展开=朝下 / 收起=朝右) # SVG 箭头切换(展开=朝下 / 收起=朝右)
g["chevron"].setPixmap( g["chevron"].setPixmap(
self._arrow_collapsed if not g["expanded"] else self._arrow_expanded) self._arrow_collapsed if not g["expanded"] else self._arrow_expanded)
if not self.isVisible(): self._clear_fx()
# 防御:弹窗未显示时直接应用终态(不播动画) self._apply_group_state(g)
self._apply_group_state(g) self.list_widget.doItemsLayout()
self._apply_height_and_position() self._apply_height_and_position()
return self.list_widget.viewport().update()
self._start_drawer(g)
def _apply_group_state(self, g): def _apply_group_state(self, g):
"""应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)""" """应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)"""
@@ -5446,6 +5746,13 @@ class MainWindow(QtWidgets.QMainWindow):
from core.config_paths import load_config as _load_cfg from core.config_paths import load_config as _load_cfg
self.config_data = _load_cfg() or {"providers": {}} 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. 实例化我们写的自定义弹窗 (先不显示) # 2. 实例化我们写的自定义弹窗 (先不显示)
self.model_popup = ModelSelectPopup(self, self.config_data) self.model_popup = ModelSelectPopup(self, self.config_data)
@@ -5458,18 +5765,33 @@ class MainWindow(QtWidgets.QMainWindow):
self.model_selector.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) self.model_selector.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
# 5. 设置默认选中 # 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) self.on_model_selected(default_p, default_m)
def show_model_popup(self): 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)) btn_pos = self.model_selector.mapToGlobal(QtCore.QPoint(0, 0))
# 计算弹窗最终应该停留的目标坐标 # 优先放在按钮上方并右对齐;空间不足时放到下方,最终钳制在当前屏幕内。
x = btn_pos.x() + self.model_selector.width() - self.model_popup.width() x = btn_pos.x() + self.model_selector.width() - self.model_popup.width()
y = btn_pos.y() - self.model_popup.height() - 5 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)) self.model_popup.show_with_animation(QtCore.QPoint(x, y))
@@ -5489,6 +5811,16 @@ class MainWindow(QtWidgets.QMainWindow):
#新增:切换模型后刷新上下文显示(因为不同模型上限不同) #新增:切换模型后刷新上下文显示(因为不同模型上限不同)
self.update_context_display() 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): def on_reasoning_received(self, session_id, chunk: str):
"""接收到思考过程""" """接收到思考过程"""
self._diag_think_n += 1 self._diag_think_n += 1
@@ -6106,14 +6438,20 @@ class MainWindow(QtWidgets.QMainWindow):
return widget return widget
def show_settings(self): def show_settings(self):
if hasattr(self, '_settings_win') and self._settings_win is not None: existing = getattr(self, '_settings_win', None)
self._settings_win.deleteLater() if existing is not None:
self._settings_win = None existing._close(immediate=True)
if hasattr(self, "model_popup"):
self.model_popup.hide()
self._settings_win = SettingsWindow( self._settings_win = SettingsWindow(
parent=self, parent=self,
config_data=self.config_data 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() self._settings_win.show_with_animation()
def _on_branch_switch(self, msg_id, direction): def _on_branch_switch(self, msg_id, direction):
"""🌟 核心:处理用户点击左右箭头切换分支""" """🌟 核心:处理用户点击左右箭头切换分支"""
+10 -4
View File
@@ -893,10 +893,16 @@ body, html {
} }
/* ========== 助手正文透明气泡(流式中/完成后一致) ========== /* ========== 助手正文气泡(流式中/完成后一致) ========== */
🐛 修复:原选择器 .message.assistant 是死代码(JS 只挂 message-wrapper 类),背景从未渲染 */ .message-wrapper.assistant .reply-content .md-segment {
/* 🆕 用户决定:助手正文气泡【透明】——不要背景/边框/圆角/内边距(2026-07 像素取证调试后明确: width: 100%;
原死选择器 P0 修复带来的浅灰底不是想要的效果),正文直接裸排在 85% 定宽列内 */ box-sizing: border-box;
padding: 10px 14px;
color: #30343b;
background-color: #f6f7f9;
border: 1px solid #eceff3;
border-radius: 8px;
}
.message-wrapper.assistant .reply-content .md-segment + .md-segment { .message-wrapper.assistant .reply-content .md-segment + .md-segment {
margin-top: 6px; margin-top: 6px;
} }