feat: 完成T-501设置页AI模型管理
- 新增SettingsTab,接入AI模型下拉、新增、删除、详情编辑和保存 - 新增AIModelTestWorker,后台调用appconfig.test_ai_model测试连接 - 密钥输入使用密码框打码,删除保护保持text/image模型类别约束 - 补充GUI测试覆盖模型加载、保存删除和测试连接worker - 同步任务看板、current-state、routes、api和progress
This commit is contained in:
+408
-7
@@ -11,6 +11,7 @@ try:
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -2416,6 +2417,402 @@ if QT_IMPORT_ERROR is None:
|
||||
return {"alias": self.account.alias, "status": status}
|
||||
|
||||
|
||||
class AIModelTestWorker(BaseWorker):
|
||||
"""Test one AI model connection without blocking the GUI thread."""
|
||||
|
||||
def __init__(self, model_name, ai_models_path=None):
|
||||
super().__init__()
|
||||
self.model_name = model_name
|
||||
self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH
|
||||
|
||||
def execute(self):
|
||||
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
|
||||
payload = dict(result or {})
|
||||
payload["name"] = self.model_name
|
||||
return payload
|
||||
|
||||
|
||||
class SettingsTab(QWidget):
|
||||
"""Tab 5: AI model definitions stored in config/ai_models.json."""
|
||||
|
||||
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
|
||||
API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config=None,
|
||||
ai_models_path=None,
|
||||
status_callback=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.config = appconfig.load_config() if config is None else config
|
||||
self.ai_models_path = (
|
||||
ai_models_path
|
||||
or self.config.get("ai_models_path")
|
||||
or appconfig.AI_MODELS_PATH
|
||||
)
|
||||
self.status_callback = status_callback
|
||||
self.models = []
|
||||
self.current_model_name = None
|
||||
self.test_worker = None
|
||||
self.test_thread = None
|
||||
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setObjectName("aiModelCombo")
|
||||
self.add_model_button = QPushButton("新增")
|
||||
self.delete_model_button = QPushButton("删除")
|
||||
|
||||
left_panel = QWidget()
|
||||
left_layout = QVBoxLayout(left_panel)
|
||||
left_layout.setContentsMargins(0, 0, 12, 0)
|
||||
left_layout.addWidget(QLabel("AI 模型"))
|
||||
left_layout.addWidget(self.model_combo)
|
||||
left_toolbar = QHBoxLayout()
|
||||
left_toolbar.addWidget(self.add_model_button)
|
||||
left_toolbar.addWidget(self.delete_model_button)
|
||||
left_toolbar.addStretch(1)
|
||||
left_layout.addLayout(left_toolbar)
|
||||
left_layout.addStretch(1)
|
||||
|
||||
self.enabled_checkbox = QCheckBox("启用")
|
||||
self.name_edit = QLineEdit()
|
||||
self.name_edit.setObjectName("modelNameEdit")
|
||||
self.category_combo = QComboBox()
|
||||
self.category_combo.setObjectName("modelCategoryCombo")
|
||||
for label, value in self.CATEGORY_ITEMS:
|
||||
self.category_combo.addItem(label, value)
|
||||
self.api_type_combo = QComboBox()
|
||||
self.api_type_combo.setObjectName("modelApiTypeCombo")
|
||||
for label, value in self.API_TYPE_ITEMS:
|
||||
self.api_type_combo.addItem(label, value)
|
||||
self.model_id_edit = QLineEdit()
|
||||
self.model_id_edit.setObjectName("modelIdEdit")
|
||||
self.url_edit = QLineEdit()
|
||||
self.url_edit.setObjectName("modelUrlEdit")
|
||||
self.api_key_edit = QLineEdit()
|
||||
self.api_key_edit.setObjectName("modelApiKeyEdit")
|
||||
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
||||
self.connect_timeout_spin = QSpinBox()
|
||||
self.connect_timeout_spin.setObjectName("connectTimeoutSpin")
|
||||
self.connect_timeout_spin.setRange(1, 3600)
|
||||
self.connect_timeout_spin.setValue(30)
|
||||
self.save_model_button = QPushButton("保存")
|
||||
self.test_connection_button = QPushButton("测试连接")
|
||||
self.test_result_label = QLabel("")
|
||||
self.test_result_label.setWordWrap(True)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow("", self.enabled_checkbox)
|
||||
form.addRow("服务商名", self.name_edit)
|
||||
form.addRow("类别", self.category_combo)
|
||||
form.addRow("api_type", self.api_type_combo)
|
||||
form.addRow("模型ID", self.model_id_edit)
|
||||
form.addRow("网址", self.url_edit)
|
||||
form.addRow("密钥", self.api_key_edit)
|
||||
form.addRow("连接超时(秒)", self.connect_timeout_spin)
|
||||
|
||||
action_layout = QHBoxLayout()
|
||||
action_layout.addWidget(self.save_model_button)
|
||||
action_layout.addWidget(self.test_connection_button)
|
||||
action_layout.addStretch(1)
|
||||
|
||||
right_panel = QWidget()
|
||||
right_layout = QVBoxLayout(right_panel)
|
||||
right_layout.setContentsMargins(12, 0, 0, 0)
|
||||
right_layout.addWidget(QLabel("模型详情"))
|
||||
right_layout.addLayout(form)
|
||||
right_layout.addLayout(action_layout)
|
||||
right_layout.addWidget(self.test_result_label)
|
||||
right_layout.addStretch(1)
|
||||
|
||||
self.splitter = QSplitter(Qt.Horizontal)
|
||||
self.splitter.addWidget(left_panel)
|
||||
self.splitter.addWidget(right_panel)
|
||||
self.splitter.setStretchFactor(0, 1)
|
||||
self.splitter.setStretchFactor(1, 3)
|
||||
self.splitter.setSizes([280, 860])
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(18, 18, 18, 18)
|
||||
layout.addWidget(self.splitter, 1)
|
||||
|
||||
self.model_combo.currentIndexChanged.connect(self.load_selected_model)
|
||||
self.add_model_button.clicked.connect(self.add_model)
|
||||
self.delete_model_button.clicked.connect(self.delete_model)
|
||||
self.save_model_button.clicked.connect(self.save_model)
|
||||
self.test_connection_button.clicked.connect(self.test_connection)
|
||||
|
||||
self.refresh_models()
|
||||
|
||||
def _set_status(self, message):
|
||||
if self.status_callback is not None:
|
||||
self.status_callback(message)
|
||||
|
||||
def refresh_models(self, selected=None):
|
||||
try:
|
||||
self.models = appconfig.list_ai_models(
|
||||
path=self.ai_models_path,
|
||||
reveal_api_key=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.models = []
|
||||
self.current_model_name = None
|
||||
self._show_error(exc)
|
||||
|
||||
current = selected or self.current_model_name
|
||||
self.model_combo.blockSignals(True)
|
||||
self.model_combo.clear()
|
||||
for model in self.models:
|
||||
label = f"{model['name']} · {self._category_label(model['category'])}"
|
||||
if not model.get("enabled", True):
|
||||
label += " · 已停用"
|
||||
self.model_combo.addItem(label, model["name"])
|
||||
index = self.model_combo.findData(current)
|
||||
self.model_combo.setCurrentIndex(index if index >= 0 else (0 if self.models else -1))
|
||||
self.model_combo.blockSignals(False)
|
||||
self.load_selected_model()
|
||||
|
||||
def load_selected_model(self, index=None):
|
||||
name = self.model_combo.currentData()
|
||||
model = self._model_by_name(name)
|
||||
self.current_model_name = model["name"] if model else None
|
||||
self._populate_form(model)
|
||||
self._update_button_state()
|
||||
|
||||
def add_model(self, checked=False):
|
||||
name = self._unique_model_name("新文本模型")
|
||||
model = {
|
||||
"name": name,
|
||||
"category": "text",
|
||||
"enabled": True,
|
||||
"url": "",
|
||||
"model": "",
|
||||
"api_key": "",
|
||||
"api_type": "chat",
|
||||
"connect_timeout_seconds": 30,
|
||||
"timeout_seconds": 0,
|
||||
"extra_body": {},
|
||||
}
|
||||
try:
|
||||
appconfig.add_ai_model(model, path=self.ai_models_path)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
self.refresh_models(selected=name)
|
||||
self._set_status(f"AI 模型已新增:{name}")
|
||||
|
||||
def save_model(self, checked=False):
|
||||
model = self._form_values()
|
||||
if model is None:
|
||||
return
|
||||
try:
|
||||
if self.current_model_name is None:
|
||||
appconfig.add_ai_model(model, path=self.ai_models_path)
|
||||
else:
|
||||
appconfig.update_ai_model(
|
||||
self.current_model_name,
|
||||
path=self.ai_models_path,
|
||||
**model,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
self.refresh_models(selected=model["name"])
|
||||
self._set_status(f"AI 模型已保存:{model['name']}")
|
||||
|
||||
def delete_model(self, checked=False):
|
||||
model = self._current_model()
|
||||
if model is None:
|
||||
return
|
||||
if not self._can_delete_model(model):
|
||||
self._set_status("每个类别至少保留一个模型,当前模型不能删除")
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"删除 AI 模型",
|
||||
f"确认删除模型「{model['name']}」?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
appconfig.delete_ai_model(model["name"], path=self.ai_models_path)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
self.refresh_models()
|
||||
self._set_status(f"AI 模型已删除:{model['name']}")
|
||||
|
||||
def test_connection(self, checked=False):
|
||||
if self.test_thread is not None:
|
||||
self._set_status("模型连接测试正在进行...")
|
||||
return
|
||||
model = self._current_model()
|
||||
if model is None:
|
||||
return
|
||||
if self.name_edit.text().strip() != model["name"]:
|
||||
self._set_status("请先保存模型名称变更后再测试连接")
|
||||
return
|
||||
worker = AIModelTestWorker(model["name"], ai_models_path=self.ai_models_path)
|
||||
worker.finished.connect(self._on_test_finished)
|
||||
worker.failed.connect(self._on_test_failed)
|
||||
thread = run_worker(worker, thread_name="AIModelTestWorker", start=False)
|
||||
thread.finished.connect(lambda: self._forget_test_thread(thread))
|
||||
self.test_worker = worker
|
||||
self.test_thread = thread
|
||||
self._set_test_running(True)
|
||||
self.test_result_label.setText("正在测试连接...")
|
||||
self._set_status(f"正在测试 AI 模型连接:{model['name']}")
|
||||
thread.start()
|
||||
|
||||
def _form_values(self):
|
||||
current = self._current_model() or {}
|
||||
name = self.name_edit.text().strip()
|
||||
if not name:
|
||||
self._show_error("AI 模型服务商名不能为空")
|
||||
return None
|
||||
extra_body = current.get("extra_body", {})
|
||||
if not isinstance(extra_body, dict):
|
||||
extra_body = {}
|
||||
return {
|
||||
"name": name,
|
||||
"category": self.category_combo.currentData() or "text",
|
||||
"enabled": self.enabled_checkbox.isChecked(),
|
||||
"url": self.url_edit.text().strip(),
|
||||
"model": self.model_id_edit.text().strip(),
|
||||
"api_key": self.api_key_edit.text(),
|
||||
"api_type": self.api_type_combo.currentData() or "auto",
|
||||
"connect_timeout_seconds": self.connect_timeout_spin.value(),
|
||||
"timeout_seconds": int(current.get("timeout_seconds", 0) or 0),
|
||||
"extra_body": dict(extra_body),
|
||||
}
|
||||
|
||||
def _populate_form(self, model):
|
||||
widgets = [
|
||||
self.enabled_checkbox,
|
||||
self.name_edit,
|
||||
self.category_combo,
|
||||
self.api_type_combo,
|
||||
self.model_id_edit,
|
||||
self.url_edit,
|
||||
self.api_key_edit,
|
||||
self.connect_timeout_spin,
|
||||
]
|
||||
for widget in widgets:
|
||||
widget.blockSignals(True)
|
||||
if model is None:
|
||||
self.enabled_checkbox.setChecked(False)
|
||||
self.name_edit.clear()
|
||||
self.category_combo.setCurrentIndex(0)
|
||||
self.api_type_combo.setCurrentIndex(0)
|
||||
self.model_id_edit.clear()
|
||||
self.url_edit.clear()
|
||||
self.api_key_edit.clear()
|
||||
self.connect_timeout_spin.setValue(30)
|
||||
else:
|
||||
self.enabled_checkbox.setChecked(bool(model.get("enabled", True)))
|
||||
self.name_edit.setText(model.get("name", ""))
|
||||
self._set_combo_by_data(self.category_combo, model.get("category", "text"))
|
||||
self._set_combo_by_data(self.api_type_combo, model.get("api_type", "auto"))
|
||||
self.model_id_edit.setText(model.get("model", ""))
|
||||
self.url_edit.setText(model.get("url", ""))
|
||||
self.api_key_edit.setText(model.get("api_key", ""))
|
||||
self.connect_timeout_spin.setValue(
|
||||
int(model.get("connect_timeout_seconds", 30) or 30)
|
||||
)
|
||||
for widget in widgets:
|
||||
widget.blockSignals(False)
|
||||
|
||||
def _set_combo_by_data(self, combo, value):
|
||||
index = combo.findData(value)
|
||||
combo.setCurrentIndex(index if index >= 0 else 0)
|
||||
|
||||
def _update_button_state(self):
|
||||
has_model = self._current_model() is not None
|
||||
testing = self.test_thread is not None
|
||||
for widget in (
|
||||
self.enabled_checkbox,
|
||||
self.name_edit,
|
||||
self.category_combo,
|
||||
self.api_type_combo,
|
||||
self.model_id_edit,
|
||||
self.url_edit,
|
||||
self.api_key_edit,
|
||||
self.connect_timeout_spin,
|
||||
self.save_model_button,
|
||||
):
|
||||
widget.setEnabled(has_model and not testing)
|
||||
self.add_model_button.setEnabled(not testing)
|
||||
self.delete_model_button.setEnabled(
|
||||
has_model and not testing and self._can_delete_model(self._current_model())
|
||||
)
|
||||
self.test_connection_button.setEnabled(has_model and not testing)
|
||||
|
||||
def _set_test_running(self, running):
|
||||
self._update_button_state()
|
||||
self.test_connection_button.setEnabled(
|
||||
not running and self._current_model() is not None
|
||||
)
|
||||
|
||||
def _forget_test_thread(self, thread):
|
||||
if self.test_thread is thread:
|
||||
self.test_thread = None
|
||||
self.test_worker = None
|
||||
self._set_test_running(False)
|
||||
|
||||
def _on_test_finished(self, payload):
|
||||
if payload.get("ok"):
|
||||
status = payload.get("status")
|
||||
suffix = f"(HTTP {status})" if status else ""
|
||||
message = f"测试连接成功:{payload.get('name')}{suffix}"
|
||||
else:
|
||||
error = payload.get("error") or "连接失败"
|
||||
status = payload.get("status")
|
||||
status_text = f"HTTP {status}," if status else ""
|
||||
message = f"测试连接失败:{status_text}{error}"
|
||||
self.test_result_label.setText(message)
|
||||
self._set_status(message)
|
||||
|
||||
def _on_test_failed(self, _task_id, error):
|
||||
message = f"测试连接失败:{error}"
|
||||
self.test_result_label.setText(message)
|
||||
self._set_status(message)
|
||||
|
||||
def _show_error(self, error):
|
||||
message = str(error)
|
||||
QMessageBox.warning(self, "设置", message)
|
||||
self._set_status(message)
|
||||
|
||||
def _current_model(self):
|
||||
return self._model_by_name(self.current_model_name)
|
||||
|
||||
def _model_by_name(self, name):
|
||||
for model in self.models:
|
||||
if model.get("name") == name:
|
||||
return model
|
||||
return None
|
||||
|
||||
def _unique_model_name(self, base):
|
||||
names = {model.get("name") for model in self.models}
|
||||
if base not in names:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base} {counter}" in names:
|
||||
counter += 1
|
||||
return f"{base} {counter}"
|
||||
|
||||
def _can_delete_model(self, model):
|
||||
if model is None:
|
||||
return False
|
||||
category = model.get("category")
|
||||
return sum(1 for item in self.models if item.get("category") == category) > 1
|
||||
|
||||
def _category_label(self, category):
|
||||
return {"text": "文本", "image": "图像"}.get(category, category)
|
||||
|
||||
|
||||
class AccountsTab(QWidget):
|
||||
COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"]
|
||||
|
||||
@@ -2672,10 +3069,15 @@ if QT_IMPORT_ERROR is None:
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with the fixed five-tab workflow."""
|
||||
|
||||
def __init__(self, db_path=None, config=None):
|
||||
def __init__(self, db_path=None, config=None, ai_models_path=None):
|
||||
super().__init__()
|
||||
self.config = appconfig.load_config() if config is None else config
|
||||
self.db_path = _database_path(db_path, self.config)
|
||||
self.ai_models_path = (
|
||||
ai_models_path
|
||||
or self.config.get("ai_models_path")
|
||||
or appconfig.AI_MODELS_PATH
|
||||
)
|
||||
self.setWindowTitle("cmshopee")
|
||||
self.resize(1180, 760)
|
||||
self.tabs = QTabWidget()
|
||||
@@ -2714,12 +3116,11 @@ if QT_IMPORT_ERROR is None:
|
||||
config=self.config,
|
||||
status_callback=self.statusBar().showMessage,
|
||||
)
|
||||
widget = QWidget()
|
||||
widget.setObjectName(title)
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(18, 18, 18, 18)
|
||||
layout.addStretch(1)
|
||||
return widget
|
||||
return SettingsTab(
|
||||
config=self.config,
|
||||
ai_models_path=self.ai_models_path,
|
||||
status_callback=self.statusBar().showMessage,
|
||||
)
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
|
||||
|
||||
Reference in New Issue
Block a user