fix(ui): repair chat and model settings rendering
This commit is contained in:
+396
-58
@@ -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()
|
||||
|
||||
# ==================== 🆕 抽屉 v2:fade + 相邻供应商行滑移 ====================
|
||||
# 收起: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):
|
||||
"""🌟 核心:处理用户点击左右箭头切换分支"""
|
||||
|
||||
+10
-4
@@ -893,10 +893,16 @@ body, html {
|
||||
}
|
||||
|
||||
|
||||
/* ========== 助手正文透明气泡(流式中/完成后一致) ==========
|
||||
🐛 修复:原选择器 .message.assistant 是死代码(JS 只挂 message-wrapper 类),背景从未渲染 */
|
||||
/* 🆕 用户决定:助手正文气泡【透明】——不要背景/边框/圆角/内边距(2026-07 像素取证调试后明确:
|
||||
原死选择器 P0 修复带来的浅灰底不是想要的效果),正文直接裸排在 85% 定宽列内 */
|
||||
/* ========== 助手正文气泡(流式中/完成后一致) ========== */
|
||||
.message-wrapper.assistant .reply-content .md-segment {
|
||||
width: 100%;
|
||||
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 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user