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
+24
View File
@@ -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:
+1
View File
@@ -17,6 +17,7 @@ if QT_IMPORT_ERROR is None:
from .workers import (
AccountLoginCheckWorker,
AIModelTestWorker,
CMHubSettingsWorker,
ApplyWorker,
CollectWorker,
GenerateWorker,
+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)
+146
View File
@@ -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
View File
@@ -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
+11
View File
@@ -1114,3 +1114,14 @@
- 测试:`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 网关面板。
## 【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 ②计费错误提示 + 余额展示。
+14
View File
@@ -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)
+227
View File
@@ -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")