feat: add cmhub settings panel
This commit is contained in:
@@ -491,6 +491,30 @@ def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30):
|
||||
return [copy.deepcopy(model) for model in models if isinstance(model, dict)]
|
||||
|
||||
|
||||
def fetch_cmhub_balance(base_url, api_key, connect_timeout=10, read_timeout=30):
|
||||
"""Fetch cmhub point balance for settings UI."""
|
||||
|
||||
base_url = str(base_url or "").strip()
|
||||
api_key = str(api_key or "")
|
||||
if not base_url:
|
||||
raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub Base URL")
|
||||
if not api_key:
|
||||
raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub API Key")
|
||||
data = _cmhub_call_with_retry(
|
||||
"GET",
|
||||
appconfig.cmhub_request_url(base_url, "/api/v1/balance"),
|
||||
api_key,
|
||||
payload=None,
|
||||
connect_timeout=max(1, int(connect_timeout or 10)),
|
||||
read_timeout=max(1, int(read_timeout or 30)),
|
||||
attempts=1,
|
||||
on_retry=None,
|
||||
)
|
||||
if "points_balance" not in data:
|
||||
raise CMHubError("bad_response", "cmhub 余额返回格式错误")
|
||||
return copy.deepcopy(data)
|
||||
|
||||
|
||||
def _ai_backend(ai_cfg):
|
||||
backend = str(ai_cfg.get("backend", "direct") or "direct").strip().lower()
|
||||
if backend not in appconfig.AI_BACKENDS:
|
||||
|
||||
@@ -17,6 +17,7 @@ if QT_IMPORT_ERROR is None:
|
||||
from .workers import (
|
||||
AccountLoginCheckWorker,
|
||||
AIModelTestWorker,
|
||||
CMHubSettingsWorker,
|
||||
ApplyWorker,
|
||||
CollectWorker,
|
||||
GenerateWorker,
|
||||
|
||||
+379
-10
@@ -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)
|
||||
|
||||
|
||||
@@ -1661,6 +1661,152 @@ class AccountLoginCheckWorker(BaseWorker):
|
||||
def _elapsed_ms(self, started):
|
||||
return _elapsed_ms(started)
|
||||
|
||||
class CMHubSettingsWorker(BaseWorker):
|
||||
"""Fetch cmhub aliases and optional balance without blocking the GUI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url,
|
||||
api_key,
|
||||
connect_timeout=10,
|
||||
include_balance=True,
|
||||
db_path=None,
|
||||
diagnostic_log_dir=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.base_url = str(base_url or "").strip()
|
||||
self.api_key = str(api_key or "")
|
||||
self.connect_timeout = max(1, int(connect_timeout or 10))
|
||||
self.include_balance = bool(include_balance)
|
||||
self.db_path = db_path
|
||||
self.diagnostic_log_dir = diagnostic_log_dir
|
||||
self._run_id = None
|
||||
|
||||
def execute(self):
|
||||
self._run_id = self._create_run_log()
|
||||
started = time.monotonic()
|
||||
action = "测试连接/查余额" if self.include_balance else "刷新别名"
|
||||
self._log_run_event(f"step=cmhub_settings result=start detail={action}")
|
||||
try:
|
||||
models = ai.fetch_cmhub_models(
|
||||
self.base_url,
|
||||
self.api_key,
|
||||
connect_timeout=self.connect_timeout,
|
||||
)
|
||||
balance = None
|
||||
if self.include_balance:
|
||||
balance = ai.fetch_cmhub_balance(
|
||||
self.base_url,
|
||||
self.api_key,
|
||||
connect_timeout=self.connect_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
error = self._safe_error(exc)
|
||||
elapsed_ms = self._elapsed_ms(started)
|
||||
self._log_run_event(
|
||||
f"step=cmhub_settings result=failed detail={error} elapsed_ms={elapsed_ms}",
|
||||
level="error",
|
||||
)
|
||||
self._write_diagnostic_log(
|
||||
"cmhub 设置检测失败",
|
||||
level="ERROR",
|
||||
step="cmhub_settings",
|
||||
elapsed_ms=elapsed_ms,
|
||||
payload={"base_url": self.base_url, "error": error},
|
||||
exc=exc,
|
||||
)
|
||||
_safe_finish_run_log(
|
||||
self._run_id,
|
||||
db_path=self.db_path,
|
||||
status="failed",
|
||||
done=0,
|
||||
failed_count=1,
|
||||
summary_json={"ok": False, "error": error},
|
||||
)
|
||||
raise RuntimeError(error) from exc
|
||||
|
||||
elapsed_ms = self._elapsed_ms(started)
|
||||
payload = {
|
||||
"ok": True,
|
||||
"models": appconfig.sanitize_for_log(models),
|
||||
"balance": appconfig.sanitize_for_log(balance or {}),
|
||||
"points_balance": (balance or {}).get("points_balance"),
|
||||
}
|
||||
title_count = self._priced_count(models, "title")
|
||||
image_count = self._priced_count(models, "image")
|
||||
self._log_run_event(
|
||||
"step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format(
|
||||
title_count=title_count,
|
||||
image_count=image_count,
|
||||
points_balance=payload.get("points_balance") if payload.get("points_balance") is not None else "",
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
)
|
||||
_safe_finish_run_log(
|
||||
self._run_id,
|
||||
db_path=self.db_path,
|
||||
status="done",
|
||||
done=1,
|
||||
success_count=1,
|
||||
summary_json=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
def _priced_count(self, models, operation):
|
||||
return sum(
|
||||
1
|
||||
for model in models or []
|
||||
if str(model.get("operation_type") or "").lower() == operation
|
||||
and str(model.get("pricing_status") or "").lower() != "unpriced"
|
||||
and str(model.get("alias") or "").strip()
|
||||
)
|
||||
|
||||
def _create_run_log(self):
|
||||
if not self.db_path:
|
||||
return None
|
||||
return _safe_create_run_log(
|
||||
"cmhub_settings_test",
|
||||
db_path=self.db_path,
|
||||
total=1,
|
||||
options={"base_url": self.base_url, "include_balance": self.include_balance},
|
||||
)
|
||||
|
||||
def _log_run_event(self, message, level="info"):
|
||||
safe_message = _safe_add_run_log_event(
|
||||
self._run_id,
|
||||
message,
|
||||
db_path=self.db_path,
|
||||
level=level,
|
||||
)
|
||||
self.log.emit(str(safe_message))
|
||||
|
||||
def _write_diagnostic_log(
|
||||
self,
|
||||
message,
|
||||
level="INFO",
|
||||
step=None,
|
||||
elapsed_ms=None,
|
||||
payload=None,
|
||||
exc=None,
|
||||
):
|
||||
_safe_write_diagnostic_log(
|
||||
message,
|
||||
level=level,
|
||||
step=step,
|
||||
elapsed_ms=elapsed_ms,
|
||||
payload=payload,
|
||||
exc=exc,
|
||||
log_dir=self.diagnostic_log_dir,
|
||||
)
|
||||
|
||||
def _safe_error(self, exc):
|
||||
raw = str(exc) or exc.__class__.__name__
|
||||
redacted = appconfig.redact_secrets(raw, [self.api_key])
|
||||
return diagnostics.redact_log_text(redacted)
|
||||
|
||||
def _elapsed_ms(self, started):
|
||||
return _elapsed_ms(started)
|
||||
|
||||
class AIModelTestWorker(BaseWorker):
|
||||
"""Test one AI model connection without blocking the GUI thread."""
|
||||
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@
|
||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-526 | `app/ai.py` + `appconfig` 接入 cmhub backend | T-301, T-303, T-520 | 依据 `docs/cmhub-integration-design.md` v3.2。`config.json` 的 `ai` 段加 `backend`(`cmhub`/`direct`)+ `cmhub` 子段(`base_url`/`title_alias`/`image_alias`/`connect_timeout` 等);为保护既有用户,全新配置默认 `backend=direct`、加载既有配置缺 `backend` 时也补 `direct`,`cmhub` 一律由用户在⑤显式 opt-in;`backend=cmhub` 但 `base_url`/Key 缺失时须抛清晰「请去⑤配置 cmhub」错误(`CMHubError`/`AIError`),不崩溃、不静默直连。cmhub API Key 固定存 `config/cmhub.json`(schema `{ "api_key": "..." }`),新增读写/打码 helper 并把该文件加入 `.gitignore`;日志脱敏。`gen_title`/`gen_cover` **返回值不变**,内层按 backend 分流并保留 `direct`;计费元数据不塞进返回值,允许给二者**新增一个可选事件回调参数**(如 `on_meta`/`on_event`)承载,属向后兼容加参,`generate_batch` 显式传回调不受影响。cmhub 分支:生文 `POST /api/v1/generate/title` 体 `{prompt,model:别名,resolution?}`、取 `titles[0]`、空则 `AIError`;生图 `POST /api/v1/generate/image` 体 `{prompt,model:别名,image_base64:<旧封面>,resolution,aspect_ratio:"1:1"}`、拿 `image_url` 后**立即下载**再走 `_save_jpeg`;`resolution` 归一大写 `512/1K/2K/4K`。新增 `CMHubError(AIError)`,带 `code/status/retryable/retry_after`,错误按 `code` 优先分支(`insufficient_points`/`unauthorized`/`account_disabled`/`bad_request`/`model_not_allowed`/`no_pricing_rule`/`content_blocked`/`upstream_error`/`rate_limited`,未知 code 当不可重试);cmhub HTTP helper 需区分 connect/read timeout(优先用 `requests timeout=(connect, read)`),只对 502/429/连接超时重试,生图读超时绝不自动重发,读超时按分辨率封顶 600s。`points_cost`/`points_balance`/`call_id` 不改返回值,通过 `on_step`/事件回调上报;T-526 只保证 metadata 事件完整传出,T-528 再由 GUI worker 脱敏写 run_logs 和余额展示。`image_url` 下载必须限制 http/https、拒绝内网/回环地址、校验域名解析后的 IP 仍不是内网/回环/本机地址,并设置超时和大小上限。新增 `fetch_cmhub_models(base_url, api_key)` helper 调 `GET /api/v1/models` 返回别名清单(`alias/operation_type/requires_image/pricing_status/prices`)供 T-527 渲染下拉,错误脱敏。不碰 editor/cdp/chrome/accounts/excel/db,也不改 ①③④流程。`tests/test_ai.py` 加 cmhub mock(titles 列表、image_url 下载、安全下载、错误码与重试、读超时不重发、metadata 事件)、`tests/test_appconfig.py` 加 schema 和 key 文件 helper,direct 用例保持绿 | DONE |
|
||||
| T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | TODO |
|
||||
| T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | DONE |
|
||||
| T-528 | ② 计费错误提示 + 余额展示 | T-526, T-527, T-303 | 依据 `docs/cmhub-integration-design.md` v3.2。② AI生成页把 cmhub 计费失败态显式化:通过 `CMHubError.code` 识别 `insufficient_points`,弹明确提示「点数不足,请先充值」并引导去网页端充值,本轮未开始任务可提前中止,不靠中文字符串匹配、不淹没在失败计数里;用 T-526 成功响应事件里的 `points_balance` 刷新②页剩余点数显示,`/balance` 仅作手动刷新/可选批量前预检;`points_cost`/`call_id` 记入脱敏 run_logs。只改② UI、`GenerateWorker` 事件处理/文案及 GUI 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO |
|
||||
|
||||
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+12
-1
@@ -1113,4 +1113,15 @@
|
||||
- 文档:同步 `docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-526 标记 DONE,下一步为 T-527。
|
||||
- 测试:`python -m py_compile app\appconfig.py app\ai.py tests\test_appconfig.py tests\test_ai.py` 通过;`python -m unittest discover -s tests -p "test_appconfig.py"` 通过(8 tests);`python -m unittest discover -s tests -p "test_ai.py"` 通过(16 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(179 tests)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
- 决策:全新配置和旧配置缺 `ai.backend` 时都保持 `direct`,避免升级后未配置 cmhub 就破坏现有生成;cmhub Key 固定只进 `config/cmhub.json`,不进 `config.json`;T-526 只传出 metadata,GUI 余额/计费提示留给 T-528。
|
||||
- 下一步:T-527 ⑤设置 cmhub 网关面板。
|
||||
- 下一步:T-527 ⑤设置 cmhub 网关面板。
|
||||
## 【2026-07-04】T-527 完成 · ⑤设置 cmhub 网关面板
|
||||
|
||||
- 状态:DONE
|
||||
- 代码:⑤设置页新增 AI 后端选择,`direct` 模式保留现有 `config/ai_models.json` 模型 master-detail 和直连模型测试;`cmhub` 模式显示网关 Base URL、API Key、生文别名、生图别名、刷新别名、测试连接/查余额和余额结果。cmhub Key 单独保存到 `config/cmhub.json`,保存新 Key 时沿用本地明文保存提示,`config.json` 只保存 backend、Base URL、别名、连接超时和余额预检开关。
|
||||
- 别名:`CMHubSettingsWorker` 后台调用 `fetch_cmhub_models()`,⑤设置页首次显示且已配置 Base URL/Key 时自动刷新一次,也可手动点「刷新别名」;按 `operation_type` 拆分生文/生图,过滤 `pricing_status="unpriced"`,下拉展示点数价格和 `requires_image` 提示;网关暂不可达或已存别名不在可用列表时保留已存值,避免保存设置时清空用户配置。
|
||||
- 余额:`app/ai.py` 新增 `fetch_cmhub_balance()` 调 `GET /api/v1/balance`,设置页「测试连接/查余额」复用同一个 worker 拉模型和余额;worker 失败会用本地 Key 脱敏后再上浮错误。
|
||||
- 边界:未改 Shopee/CDP、Excel、DB schema、账号流程;`config/ai_models.json` 在切换 cmhub 时不删除,仍供 direct 模式回退使用。
|
||||
- 测试:`tests/test_ai.py` 覆盖 cmhub 余额 helper;`tests/test_gui.py` 覆盖 `CMHubSettingsWorker` 成功/失败脱敏、设置页 cmhub backend 切换、保存 `config.json` + `config/cmhub.json`、别名过滤与已存值保留。
|
||||
- 文档:`docs/06-tasks.md` 将 T-527 标为 DONE;同步 `docs/current-state.md`,下一步为 T-528。
|
||||
- 验证:`python -m py_compile tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_ai.py"` 通过(17 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(77 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(184 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
- 下一步:T-528 ②计费错误提示 + 余额展示。
|
||||
@@ -515,6 +515,20 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual("https://cmhub.example.com/api/v1/models", calls[0][1])
|
||||
self.assertEqual("title-standard", models[0]["alias"])
|
||||
|
||||
def test_fetch_cmhub_balance_returns_points(self):
|
||||
calls = []
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
calls.append((method, url, kwargs))
|
||||
return _RequestsResponse({"user": {"id": "u1"}, "points_balance": 42})
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
balance = ai.fetch_cmhub_balance("https://cmhub.example.com", "sk-cmhub-secret")
|
||||
|
||||
self.assertEqual("GET", calls[0][0])
|
||||
self.assertEqual("https://cmhub.example.com/api/v1/balance", calls[0][1])
|
||||
self.assertEqual(42, balance["points_balance"])
|
||||
|
||||
def test_generate_batch_forwards_cmhub_metadata_event(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, key_path = self._cmhub_config(temp_dir)
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.gui import (
|
||||
AccountLoginCheckWorker,
|
||||
AccountsTab,
|
||||
AIModelTestWorker,
|
||||
CMHubSettingsWorker,
|
||||
ApplyTab,
|
||||
ApplyWorker,
|
||||
CollectWorker,
|
||||
@@ -705,6 +706,232 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_cmhub_settings_worker_fetches_models_and_balance(self):
|
||||
worker = CMHubSettingsWorker(
|
||||
"https://cmhub.example.com",
|
||||
"sk-cmhub-secret",
|
||||
connect_timeout=7,
|
||||
include_balance=True,
|
||||
)
|
||||
models = [
|
||||
{
|
||||
"alias": "title-standard",
|
||||
"operation_type": "title",
|
||||
"pricing_status": "priced",
|
||||
}
|
||||
]
|
||||
balance = {"user": {"id": "u1"}, "points_balance": 88}
|
||||
|
||||
with mock.patch("app.gui.ai.fetch_cmhub_models", return_value=models) as fetch_models, \
|
||||
mock.patch("app.gui.ai.fetch_cmhub_balance", return_value=balance) as fetch_balance:
|
||||
result = worker.execute()
|
||||
|
||||
fetch_models.assert_called_once_with(
|
||||
"https://cmhub.example.com",
|
||||
"sk-cmhub-secret",
|
||||
connect_timeout=7,
|
||||
)
|
||||
fetch_balance.assert_called_once_with(
|
||||
"https://cmhub.example.com",
|
||||
"sk-cmhub-secret",
|
||||
connect_timeout=7,
|
||||
)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(models, result["models"])
|
||||
self.assertEqual(88, result["points_balance"])
|
||||
|
||||
def test_cmhub_settings_worker_redacts_key_on_failure(self):
|
||||
worker = CMHubSettingsWorker(
|
||||
"https://cmhub.example.com",
|
||||
"sk-cmhub-secret",
|
||||
connect_timeout=7,
|
||||
include_balance=False,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.ai.fetch_cmhub_models",
|
||||
side_effect=RuntimeError("bad key sk-cmhub-secret"),
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
worker.execute()
|
||||
|
||||
self.assertIn("bad key", str(raised.exception))
|
||||
self.assertNotIn("sk-cmhub-secret", str(raised.exception))
|
||||
|
||||
def test_settings_tab_cmhub_backend_panel_saves_config_and_key(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.default_config()["ai"]
|
||||
cfg["ai"]["backend"] = "cmhub"
|
||||
cfg["ai"]["cmhub"] = {
|
||||
"base_url": "https://cmhub.old",
|
||||
"title_alias": "title-old",
|
||||
"image_alias": "image-old",
|
||||
"connect_timeout": 9,
|
||||
"check_balance_before_batch": False,
|
||||
}
|
||||
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
|
||||
appconfig.save_cmhub_config(
|
||||
{"api_key": "sk-old-secret"},
|
||||
path=cfg["cmhub_config_path"],
|
||||
)
|
||||
appconfig.save_ai_models_config(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "Text A",
|
||||
"category": "text",
|
||||
"enabled": True,
|
||||
"url": "",
|
||||
"model": "",
|
||||
"api_key": "",
|
||||
"api_type": "chat",
|
||||
"connect_timeout_seconds": 30,
|
||||
"timeout_seconds": 0,
|
||||
"extra_body": {},
|
||||
},
|
||||
{
|
||||
"name": "Image A",
|
||||
"category": "image",
|
||||
"enabled": True,
|
||||
"url": "",
|
||||
"model": "",
|
||||
"api_key": "",
|
||||
"api_type": "auto",
|
||||
"connect_timeout_seconds": 30,
|
||||
"timeout_seconds": 0,
|
||||
"extra_body": {},
|
||||
},
|
||||
]
|
||||
},
|
||||
path=cfg["ai_models_path"],
|
||||
)
|
||||
tab = SettingsTab(
|
||||
config=cfg,
|
||||
config_path=cfg["config_path"],
|
||||
ai_models_path=cfg["ai_models_path"],
|
||||
)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
self.assertEqual("cmhub", tab.backend_combo.currentData())
|
||||
self.assertTrue(tab.model_picker_panel.isHidden())
|
||||
self.assertFalse(tab.cmhub_panel.isHidden())
|
||||
self.assertEqual("https://cmhub.old", tab.cmhub_base_url_edit.text())
|
||||
self.assertEqual("sk-old-secret", tab.cmhub_api_key_edit.text())
|
||||
self.assertEqual(QLineEdit.Password, tab.cmhub_api_key_edit.echoMode())
|
||||
self.assertEqual("title-old", tab.cmhub_title_alias_combo.currentData())
|
||||
self.assertEqual("image-old", tab.cmhub_image_alias_combo.currentData())
|
||||
|
||||
tab.cmhub_base_url_edit.setText("https://cmhub.example.com")
|
||||
tab.cmhub_api_key_edit.setText("sk-new-secret")
|
||||
tab.cmhub_connect_timeout_spin.setValue(12)
|
||||
tab.cmhub_check_balance_checkbox.setChecked(True)
|
||||
tab._populate_cmhub_alias_combos(
|
||||
[
|
||||
{
|
||||
"alias": "title-standard",
|
||||
"operation_type": "title",
|
||||
"requires_image": False,
|
||||
"pricing_status": "priced",
|
||||
"prices": [{"resolution": "1K", "points_cost": 1}],
|
||||
},
|
||||
{
|
||||
"alias": "image-standard",
|
||||
"operation_type": "image",
|
||||
"requires_image": True,
|
||||
"pricing_status": "priced",
|
||||
"prices": [{"resolution": "1K", "points_cost": 5}],
|
||||
},
|
||||
],
|
||||
title_selected="title-standard",
|
||||
image_selected="image-standard",
|
||||
)
|
||||
|
||||
with mock.patch("app.gui.QMessageBox.warning") as warning, \
|
||||
mock.patch("app.gui.QMessageBox.information") as info:
|
||||
tab.save_app_settings()
|
||||
|
||||
warning.assert_called_once()
|
||||
self.assertIn("config/cmhub.json", warning.call_args[0][2])
|
||||
info.assert_called_once_with(tab, "保存设置", "设置已保存")
|
||||
saved = appconfig.load_config(cfg["config_path"])
|
||||
self.assertEqual("cmhub", saved["ai"]["backend"])
|
||||
self.assertEqual("https://cmhub.example.com", saved["ai"]["cmhub"]["base_url"])
|
||||
self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"])
|
||||
self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_alias"])
|
||||
self.assertEqual(12, saved["ai"]["cmhub"]["connect_timeout"])
|
||||
self.assertTrue(saved["ai"]["cmhub"]["check_balance_before_batch"])
|
||||
self.assertEqual(
|
||||
"sk-new-secret",
|
||||
appconfig.get_cmhub_api_key(path=cfg["cmhub_config_path"]),
|
||||
)
|
||||
self.assertTrue(os.path.exists(cfg["ai_models_path"]))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_settings_tab_cmhub_alias_refresh_filters_unpriced_and_keeps_saved(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.default_config()["ai"]
|
||||
cfg["ai"]["backend"] = "cmhub"
|
||||
cfg["ai"]["cmhub"] = {
|
||||
"base_url": "https://cmhub.example.com",
|
||||
"title_alias": "title-saved",
|
||||
"image_alias": "image-saved",
|
||||
"connect_timeout": 10,
|
||||
"check_balance_before_batch": False,
|
||||
}
|
||||
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
|
||||
appconfig.save_cmhub_config({"api_key": "sk-cmhub-secret"}, path=cfg["cmhub_config_path"])
|
||||
tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"])
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
tab._on_cmhub_finished(
|
||||
{
|
||||
"ok": True,
|
||||
"points_balance": 55,
|
||||
"models": [
|
||||
{
|
||||
"alias": "title-priced",
|
||||
"operation_type": "title",
|
||||
"requires_image": False,
|
||||
"pricing_status": "priced",
|
||||
"prices": [{"resolution": "512", "points_cost": 1}],
|
||||
},
|
||||
{
|
||||
"alias": "title-free",
|
||||
"operation_type": "title",
|
||||
"pricing_status": "unpriced",
|
||||
"prices": [],
|
||||
},
|
||||
{
|
||||
"alias": "image-priced",
|
||||
"operation_type": "image",
|
||||
"requires_image": True,
|
||||
"pricing_status": "priced",
|
||||
"prices": [{"resolution": "1K", "points_cost": 5}],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
title_aliases = [
|
||||
tab.cmhub_title_alias_combo.itemData(index)
|
||||
for index in range(tab.cmhub_title_alias_combo.count())
|
||||
]
|
||||
image_labels = [
|
||||
tab.cmhub_image_alias_combo.itemText(index)
|
||||
for index in range(tab.cmhub_image_alias_combo.count())
|
||||
]
|
||||
self.assertIn("title-priced", title_aliases)
|
||||
self.assertIn("title-saved", title_aliases)
|
||||
self.assertNotIn("title-free", title_aliases)
|
||||
self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0))
|
||||
self.assertTrue(any("需参考图" in label for label in image_labels))
|
||||
self.assertIn("余额 55", tab.cmhub_result_label.text())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_has_prompt_editors_and_task_table(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")
|
||||
|
||||
Reference in New Issue
Block a user