feat: add cmhub settings panel

This commit is contained in:
chengma
2026-07-04 16:29:15 +08:00
parent 1efb095767
commit 27f23a4738
9 changed files with 813 additions and 21 deletions
+379 -10
View File
@@ -4,14 +4,26 @@ from __future__ import annotations
from ..widgets import *
from ..workers import AIModelTestWorker as _RealAIModelTestWorker
from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
PLAINTEXT_CMHUB_API_KEY_WARNING = (
"cmhub API Key 会以本地明文保存到 config/cmhub.json,仅供本机调用 cmhub 网关使用。"
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
)
def AIModelTestWorker(*args, **kwargs):
return _call_package_attr("AIModelTestWorker", _RealAIModelTestWorker, *args, **kwargs)
def CMHubSettingsWorker(*args, **kwargs):
return _call_package_attr("CMHubSettingsWorker", _RealCMHubSettingsWorker, *args, **kwargs)
class SettingsTab(QWidget):
"""Tab 5: AI model definitions stored in config/ai_models.json."""
BACKEND_ITEMS = [("直连模型", "direct"), ("cmhub 网关", "cmhub")]
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")]
RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"]
@@ -36,13 +48,54 @@ class SettingsTab(QWidget):
or self.config.get("ai_models_path")
or appconfig.AI_MODELS_PATH
)
self.cmhub_config_path = (
self.config.get("cmhub_config_path")
or self._default_cmhub_config_path(self.config_path)
)
self.status_callback = status_callback
self.models = []
self.current_model_name = None
self.test_worker = None
self.test_thread = None
self.cmhub_models = []
self.cmhub_worker = None
self.cmhub_thread = None
self._loaded_cmhub_api_key = ""
self._cmhub_auto_refresh_done = False
self._compat_test_item_id = ""
self.backend_combo = QComboBox()
self.backend_combo.setObjectName("aiBackendCombo")
for label, value in self.BACKEND_ITEMS:
self.backend_combo.addItem(label, value)
self.cmhub_base_url_edit = QLineEdit()
self.cmhub_base_url_edit.setObjectName("cmhubBaseUrlEdit")
self.cmhub_base_url_edit.setPlaceholderText("https://<cmhub>")
self.cmhub_api_key_edit = QLineEdit()
self.cmhub_api_key_edit.setObjectName("cmhubApiKeyEdit")
self.cmhub_api_key_edit.setEchoMode(QLineEdit.Password)
self.cmhub_api_key_edit.setPlaceholderText("从 cmhub 网页端复制 API Key")
self.cmhub_title_alias_combo = QComboBox()
self.cmhub_title_alias_combo.setObjectName("cmhubTitleAliasCombo")
self.cmhub_image_alias_combo = QComboBox()
self.cmhub_image_alias_combo.setObjectName("cmhubImageAliasCombo")
self.cmhub_connect_timeout_spin = QSpinBox()
self.cmhub_connect_timeout_spin.setObjectName("cmhubConnectTimeoutSpin")
self.cmhub_connect_timeout_spin.setRange(1, 3600)
self.cmhub_connect_timeout_spin.setValue(10)
self.cmhub_check_balance_checkbox = QCheckBox("批量生成前检查余额")
self.cmhub_check_balance_checkbox.setObjectName("cmhubCheckBalanceCheckbox")
self.cmhub_refresh_button = QPushButton("刷新别名")
self.cmhub_refresh_button.setObjectName("cmhubRefreshButton")
self.cmhub_test_button = QPushButton("测试连接/查余额")
self.cmhub_test_button.setObjectName("cmhubTestButton")
self.cmhub_result_label = QLabel("")
self.cmhub_result_label.setObjectName("cmhubResultLabel")
self.cmhub_result_label.setWordWrap(True)
self.cmhub_key_hint_label = QLabel("API Key 仅在 cmhub 网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。")
self.cmhub_key_hint_label.setObjectName("cmhubKeyHintLabel")
self.cmhub_key_hint_label.setWordWrap(True)
self.model_combo = QComboBox()
self.model_combo.setObjectName("aiModelCombo")
self.add_model_button = QPushButton("新增")
@@ -165,10 +218,15 @@ class SettingsTab(QWidget):
]
)
ai_form = self._three_column_form(
direct_role_form = self._three_column_form(
[
("标题大模型", self.default_text_model_combo),
("图片大模型", self.default_image_model_combo),
]
)
ai_form = self._three_column_form(
[
("标题并发数", self.title_concurrency_spin),
("图片并发数", self.image_concurrency_spin),
("失败重试次数", self.retry_spin),
@@ -208,6 +266,47 @@ class SettingsTab(QWidget):
]
)
backend_form = self._three_column_form([("AI 后端", self.backend_combo)])
self.model_picker_panel = QWidget()
self.model_picker_panel.setLayout(model_picker_layout)
self.model_detail_panel = QWidget()
model_detail_layout = QVBoxLayout(self.model_detail_panel)
model_detail_layout.setContentsMargins(0, 0, 0, 0)
model_detail_layout.setSpacing(8)
model_detail_layout.addLayout(form)
model_detail_layout.addLayout(action_layout)
model_detail_layout.addWidget(self.test_result_label)
self.direct_role_panel = QWidget()
self.direct_role_panel.setLayout(direct_role_form)
cmhub_form = self._three_column_form(
[
("网关 Base URL", self.cmhub_base_url_edit, True),
("API Key", self.cmhub_api_key_edit, True),
("连接超时(秒)", self.cmhub_connect_timeout_spin),
("生文别名", self.cmhub_title_alias_combo),
("生图别名", self.cmhub_image_alias_combo),
("", self.cmhub_check_balance_checkbox),
]
)
cmhub_action_layout = QHBoxLayout()
cmhub_action_layout.setContentsMargins(0, 0, 0, 0)
cmhub_action_layout.addWidget(self.cmhub_refresh_button)
cmhub_action_layout.addWidget(self.cmhub_test_button)
cmhub_action_layout.addStretch(1)
self.cmhub_panel = QWidget()
self.cmhub_panel.setObjectName("cmhubSettingsPanel")
cmhub_panel_layout = QVBoxLayout(self.cmhub_panel)
cmhub_panel_layout.setContentsMargins(0, 0, 0, 0)
cmhub_panel_layout.setSpacing(8)
cmhub_panel_layout.addLayout(cmhub_form)
cmhub_panel_layout.addWidget(self.cmhub_key_hint_label)
cmhub_panel_layout.addLayout(cmhub_action_layout)
cmhub_panel_layout.addWidget(self.cmhub_result_label)
panel = QWidget()
panel.setMaximumWidth(1800)
panel_layout = QVBoxLayout(panel)
@@ -234,14 +333,15 @@ class SettingsTab(QWidget):
"settingsInfrastructureSectionTitle",
)
panel_layout.addWidget(self.ai_model_section_title)
panel_layout.addLayout(model_picker_layout)
panel_layout.addLayout(backend_form)
panel_layout.addWidget(self.model_picker_panel)
panel_layout.addSpacing(14)
panel_layout.addWidget(self.model_detail_section_title)
panel_layout.addLayout(form)
panel_layout.addLayout(action_layout)
panel_layout.addWidget(self.test_result_label)
panel_layout.addWidget(self.model_detail_panel)
panel_layout.addWidget(self.cmhub_panel)
panel_layout.addSpacing(18)
panel_layout.addWidget(self.generation_section_title)
panel_layout.addWidget(self.direct_role_panel)
panel_layout.addLayout(ai_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(self.shopee_update_section_title)
@@ -271,6 +371,9 @@ class SettingsTab(QWidget):
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.backend_combo.currentIndexChanged.connect(self._on_backend_changed)
self.cmhub_refresh_button.clicked.connect(self.refresh_cmhub_models)
self.cmhub_test_button.clicked.connect(self.test_cmhub_connection)
self.resolution_combo.currentIndexChanged.connect(
self._update_response_timeout_label
)
@@ -323,10 +426,43 @@ class SettingsTab(QWidget):
label.setStyleSheet("color: #24292f; font-weight: 600; padding-top: 4px;")
return label
def showEvent(self, event):
super().showEvent(event)
self._maybe_auto_refresh_cmhub_models()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _maybe_auto_refresh_cmhub_models(self):
if self._cmhub_auto_refresh_done:
return
if (self.backend_combo.currentData() or "direct") != "cmhub":
return
if not self.cmhub_base_url_edit.text().strip() or not self.cmhub_api_key_edit.text():
return
self._cmhub_auto_refresh_done = True
self.refresh_cmhub_models()
def _default_cmhub_config_path(self, config_path):
if config_path and config_path != appconfig.CONFIG_PATH:
return os.path.join(
os.path.dirname(os.path.abspath(config_path)),
"config",
"cmhub.json",
)
return appconfig.CMHUB_CONFIG_PATH
def _on_backend_changed(self, index=None):
is_cmhub = (self.backend_combo.currentData() or "direct") == "cmhub"
self.model_picker_panel.setVisible(not is_cmhub)
self.model_detail_section_title.setVisible(not is_cmhub)
self.model_detail_panel.setVisible(not is_cmhub)
self.direct_role_panel.setVisible(not is_cmhub)
self.cmhub_panel.setVisible(is_cmhub)
self._set_cmhub_running(self.cmhub_thread is not None)
self._update_button_state()
def refresh_models(self, selected=None):
try:
self.models = appconfig.list_ai_models(
@@ -459,7 +595,14 @@ class SettingsTab(QWidget):
settings = self._app_settings_values()
if settings is None:
return
cmhub_key = self.cmhub_api_key_edit.text()
if self._should_warn_plaintext_cmhub_api_key(cmhub_key):
self._show_plaintext_cmhub_api_key_warning()
try:
appconfig.save_cmhub_config(
{"api_key": cmhub_key},
path=self.cmhub_config_path,
)
saved = appconfig.save_config(settings, path=self.config_path)
except Exception as exc:
self._show_error(exc)
@@ -479,17 +622,23 @@ class SettingsTab(QWidget):
if not (start_port <= default_port <= end_port):
self._show_error("默认调试端口必须在调试端口范围内")
return None
text_model = self.default_text_model_combo.currentData()
image_model = self.default_image_model_combo.currentData()
if not text_model or not image_model:
ai_cfg = appconfig.ai_config(self.config)
backend = self.backend_combo.currentData() or "direct"
text_model = self.default_text_model_combo.currentData() or ai_cfg.get("default_text_model")
image_model = self.default_image_model_combo.currentData() or ai_cfg.get("default_image_model")
if backend == "direct" and (not text_model or not image_model):
self._show_error("标题大模型和图片大模型不能为空")
return None
cmhub_cfg = self._cmhub_settings_values(backend)
if cmhub_cfg is None:
return None
ai_cfg = appconfig.ai_config(self.config)
ai_cfg.update(
{
"backend": backend,
"default_text_model": text_model,
"default_image_model": image_model,
"cmhub": cmhub_cfg,
"title_concurrency": self.title_concurrency_spin.value(),
"image_concurrency": self.image_concurrency_spin.value(),
"retry": self.retry_spin.value(),
@@ -502,7 +651,7 @@ class SettingsTab(QWidget):
settings = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path"}
if key not in {"config_path", "ai_models_path", "cmhub_config_path"}
}
settings.update(
{
@@ -528,12 +677,41 @@ class SettingsTab(QWidget):
)
return settings
def _cmhub_settings_values(self, backend):
current = appconfig.cmhub_config(self.config)
values = {
"base_url": self.cmhub_base_url_edit.text().strip(),
"title_alias": self.cmhub_title_alias_combo.currentData() or "",
"image_alias": self.cmhub_image_alias_combo.currentData() or "",
"connect_timeout": self.cmhub_connect_timeout_spin.value(),
"check_balance_before_batch": self.cmhub_check_balance_checkbox.isChecked(),
}
if backend != "cmhub":
merged = dict(current)
merged.update(values)
return merged
missing = []
if not values["base_url"]:
missing.append("Base URL")
if not self.cmhub_api_key_edit.text():
missing.append("API Key")
if not values["title_alias"]:
missing.append("生文别名")
if not values["image_alias"]:
missing.append("生图别名")
if missing:
self._show_error("cmhub 配置不完整:缺少 " + "、".join(missing))
return None
return values
def _replace_config(self, saved):
internal = {}
if self.config_path != appconfig.CONFIG_PATH:
internal["config_path"] = self.config_path
if self.ai_models_path != appconfig.AI_MODELS_PATH:
internal["ai_models_path"] = self.ai_models_path
if self.cmhub_config_path != appconfig.CMHUB_CONFIG_PATH:
internal["cmhub_config_path"] = self.cmhub_config_path
self.config.clear()
self.config.update(saved)
self.config.update(internal)
@@ -541,6 +719,23 @@ class SettingsTab(QWidget):
def _populate_app_settings(self):
self._populate_role_model_combos()
ai_cfg = appconfig.ai_config(self.config)
backend = ai_cfg.get("backend", "direct")
self._set_combo_by_data(self.backend_combo, backend)
cmhub_cfg = appconfig.cmhub_config(self.config)
self.cmhub_base_url_edit.setText(cmhub_cfg.get("base_url", ""))
self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key(path=self.cmhub_config_path)
self.cmhub_api_key_edit.setText(self._loaded_cmhub_api_key)
self.cmhub_connect_timeout_spin.setValue(
max(1, int(cmhub_cfg.get("connect_timeout", 10) or 10))
)
self.cmhub_check_balance_checkbox.setChecked(
bool(cmhub_cfg.get("check_balance_before_batch", False))
)
self._populate_cmhub_alias_combos(
self.cmhub_models,
title_selected=cmhub_cfg.get("title_alias", ""),
image_selected=cmhub_cfg.get("image_alias", ""),
)
self._set_combo_by_data(
self.default_text_model_combo,
ai_cfg.get("default_text_model", ""),
@@ -595,6 +790,7 @@ class SettingsTab(QWidget):
max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2))
)
self._update_response_timeout_label()
self._on_backend_changed()
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
@@ -753,6 +949,168 @@ class SettingsTab(QWidget):
self.test_result_label.setText(message)
self._set_status(message)
def refresh_cmhub_models(self, checked=False):
self._start_cmhub_worker(include_balance=False)
def test_cmhub_connection(self, checked=False):
self._start_cmhub_worker(include_balance=True)
def _start_cmhub_worker(self, include_balance):
if self.cmhub_thread is not None:
self._set_status("cmhub 检测正在进行...")
return
base_url = self.cmhub_base_url_edit.text().strip()
api_key = self.cmhub_api_key_edit.text()
missing = []
if not base_url:
missing.append("Base URL")
if not api_key:
missing.append("API Key")
if missing:
self._show_error("cmhub 配置不完整:缺少 " + "、".join(missing))
return
worker = CMHubSettingsWorker(
base_url,
api_key,
connect_timeout=self.cmhub_connect_timeout_spin.value(),
include_balance=include_balance,
db_path=_database_path(config=self.config),
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.finished.connect(self._on_cmhub_finished)
worker.failed.connect(self._on_cmhub_failed)
thread = run_worker(worker, thread_name="CMHubSettingsWorker", start=False)
thread.finished.connect(lambda: self._forget_cmhub_thread(thread))
self.cmhub_worker = worker
self.cmhub_thread = thread
self._set_cmhub_running(True)
message = "正在测试 cmhub 连接并查询余额..." if include_balance else "正在刷新 cmhub 别名..."
self.cmhub_result_label.setText(message)
self._set_status(message)
thread.start()
def _set_cmhub_running(self, running):
enabled = not running
for widget in (
self.cmhub_base_url_edit,
self.cmhub_api_key_edit,
self.cmhub_title_alias_combo,
self.cmhub_image_alias_combo,
self.cmhub_connect_timeout_spin,
self.cmhub_check_balance_checkbox,
self.cmhub_refresh_button,
self.cmhub_test_button,
):
widget.setEnabled(enabled)
def _forget_cmhub_thread(self, thread):
if self.cmhub_thread is thread:
self.cmhub_thread = None
self.cmhub_worker = None
self._set_cmhub_running(False)
def _on_cmhub_finished(self, payload):
if not payload.get("ok", True):
self._on_cmhub_failed(-1, payload.get("error") or "连接失败")
return
models = [model for model in payload.get("models", []) if isinstance(model, dict)]
self.cmhub_models = models
current_cfg = appconfig.cmhub_config(self.config)
title_selected = self.cmhub_title_alias_combo.currentData() or current_cfg.get("title_alias", "")
image_selected = self.cmhub_image_alias_combo.currentData() or current_cfg.get("image_alias", "")
self._populate_cmhub_alias_combos(
models,
title_selected=title_selected,
image_selected=image_selected,
)
title_count = self._cmhub_alias_count("title")
image_count = self._cmhub_alias_count("image")
balance = payload.get("points_balance")
balance_text = f";余额 {balance}" if balance is not None else ""
message = f"cmhub 连接成功:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text}"
self.cmhub_result_label.setText(message)
self._set_status(message)
def _on_cmhub_failed(self, _task_id, error):
message = f"cmhub 连接失败:{error}"
self.cmhub_result_label.setText(message)
self._set_status(message)
def _populate_cmhub_alias_combos(self, models, title_selected="", image_selected=""):
self._populate_cmhub_alias_combo(
self.cmhub_title_alias_combo,
models,
"title",
title_selected,
)
self._populate_cmhub_alias_combo(
self.cmhub_image_alias_combo,
models,
"image",
image_selected,
)
def _populate_cmhub_alias_combo(self, combo, models, operation, selected):
combo.blockSignals(True)
combo.clear()
selected = str(selected or "").strip()
added = set()
for model in self._cmhub_priced_models(models, operation):
alias = str(model.get("alias") or "").strip()
if not alias or alias in added:
continue
combo.addItem(self._cmhub_alias_label(model), alias)
added.add(alias)
if selected and selected not in added:
combo.addItem(f"{selected}(已保存)", selected)
if combo.count() == 0:
combo.addItem("无可用别名", None)
index = combo.findData(selected)
combo.setCurrentIndex(index if index >= 0 else 0)
combo.blockSignals(False)
def _cmhub_priced_models(self, models, operation):
items = []
for model in models or []:
if not isinstance(model, dict):
continue
alias = str(model.get("alias") or "").strip()
op = str(model.get("operation_type") or "").lower()
pricing_status = str(model.get("pricing_status") or "").lower()
if alias and op == operation and pricing_status != "unpriced":
items.append(model)
return items
def _cmhub_alias_label(self, model):
alias = str(model.get("alias") or "").strip()
price_text = self._cmhub_price_text(model.get("prices"))
parts = [alias]
if price_text:
parts.append(price_text)
if model.get("requires_image"):
parts.append("需参考图")
return " · ".join(parts)
def _cmhub_price_text(self, prices):
if not isinstance(prices, list):
return ""
parts = []
for item in prices[:3]:
if not isinstance(item, dict):
continue
cost = item.get("points_cost")
if cost is None:
cost = item.get("cost")
if cost is None:
continue
resolution = item.get("resolution") or item.get("name") or ""
parts.append(f"{resolution}:{cost}点" if resolution else f"{cost}点")
return "/".join(parts)
def _cmhub_alias_count(self, operation):
combo = self.cmhub_title_alias_combo if operation == "title" else self.cmhub_image_alias_combo
return sum(1 for index in range(combo.count()) if combo.itemData(index))
def _show_error(self, error):
message = str(error)
QMessageBox.warning(self, "设置", message)
@@ -763,6 +1121,10 @@ class SettingsTab(QWidget):
current_key = str((current or {}).get("api_key") or "")
return bool(new_key) and new_key != current_key
def _should_warn_plaintext_cmhub_api_key(self, key):
new_key = str(key or "")
return bool(new_key) and new_key != str(self._loaded_cmhub_api_key or "")
def _show_plaintext_api_key_warning(self):
QMessageBox.warning(
self,
@@ -770,6 +1132,13 @@ class SettingsTab(QWidget):
PLAINTEXT_API_KEY_WARNING,
)
def _show_plaintext_cmhub_api_key_warning(self):
QMessageBox.warning(
self,
PLAINTEXT_SECRET_TITLE,
PLAINTEXT_CMHUB_API_KEY_WARNING,
)
def _current_model(self):
return self._model_by_name(self.current_model_name)