feat(settings): support gateway source switching

This commit is contained in:
chengma
2026-07-20 16:29:28 +08:00
parent ed1d8ee764
commit 6a0cd1c763
18 changed files with 746 additions and 77 deletions
+124 -5
View File
@@ -64,6 +64,88 @@ CMHUB_HTTP_POOL_SIZE = 32
_CMHUB_SESSION = None
_CMHUB_SESSION_LOCK = threading.Lock()
_RUNTIME_AI_SNAPSHOT_KEY = "_cmshopee_ai_runtime"
def freeze_runtime_config(
config=None,
*,
cmhub_config_path=appconfig.CMHUB_CONFIG_PATH,
models_path=appconfig.AI_MODELS_PATH,
include_cmhub=None,
include_direct_models=None,
):
"""Return an in-memory AI configuration snapshot for one worker run.
Application config does not contain the default gateway key and direct model
definitions are stored in a separate file. Copy only the values used by the
worker into its private config so saving settings cannot change its endpoint,
model or credential. Callers must never persist this result.
"""
source = appconfig.load_config() if config is None else config
configured_cmhub_path = source.get("cmhub_config_path")
configured_models_path = source.get("ai_models_path")
if configured_cmhub_path and (
not cmhub_config_path or cmhub_config_path == appconfig.CMHUB_CONFIG_PATH
):
cmhub_config_path = configured_cmhub_path
if configured_models_path and (
not models_path or models_path == appconfig.AI_MODELS_PATH
):
models_path = configured_models_path
cmhub_config_path = cmhub_config_path or appconfig.cmhub_config_file_path(source)
models_path = models_path or appconfig.ai_models_config_path(source)
snapshot = copy.deepcopy(source)
backend = appconfig.ai_backend(source)
if include_cmhub is None:
include_cmhub = backend == "cmhub"
if include_direct_models is None:
include_direct_models = backend == "direct"
runtime = {}
if include_cmhub:
runtime["cmhub_api_key"] = appconfig.get_cmhub_api_key(path=cmhub_config_path)
if include_direct_models:
runtime["direct_models"] = appconfig.list_ai_models(
path=models_path,
reveal_api_key=True,
)
snapshot[_RUNTIME_AI_SNAPSHOT_KEY] = runtime
return snapshot
def validate_direct_generation_config(
config,
generate_mode,
*,
models_path=appconfig.AI_MODELS_PATH,
):
"""Fail before a direct batch starts when its selected model is unusable."""
cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg)
if _ai_backend(ai_cfg) != "direct":
return
mode = appconfig.normalize_generate_mode(generate_mode)
required = []
if appconfig.generate_mode_includes_title(mode):
required.append(("text", "标题"))
if appconfig.generate_mode_includes_cover(mode):
required.append(("image", "封面"))
models = _runtime_direct_models(cfg)
errors = []
for category, label in required:
try:
_role_model(
category,
ai_cfg.get("default_%s_model" % category),
models_path,
models=models,
)
except Exception as exc:
errors.append("%s模型%s" % (label, str(exc)))
if errors:
raise AIError(";".join(errors))
def _cmhub_session():
@@ -134,7 +216,12 @@ def gen_title(
on_event=on_event,
)
_notify_step(on_step, "load_text_model")
model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
model = _role_model(
"text",
ai_cfg.get("default_text_model"),
models_path,
models=_runtime_direct_models(cfg),
)
_notify_step(on_step, "title_build_request")
payload = _chat_payload(
model,
@@ -248,7 +335,12 @@ def gen_cover(
on_event=on_event,
)
_notify_step(on_step, "load_image_model")
model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
model = _role_model(
"image",
ai_cfg.get("default_image_model"),
models_path,
models=_runtime_direct_models(cfg),
)
resolution = str(resolution or ai_cfg.get("resolution", "1k"))
quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90))
attempts = _attempt_count(ai_cfg, retry)
@@ -1435,7 +1527,11 @@ def _vision_cmhub_error(exc):
def _cmhub_runtime(config, operation, cmhub_config_path):
hub = appconfig.cmhub_config(config)
api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path)
runtime = _runtime_ai_snapshot(config)
if runtime is not None and "cmhub_api_key" in runtime:
api_key = str(runtime.get("cmhub_api_key") or "")
else:
api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path)
operation_config = {
"title": ("title_alias", "生文别名"),
"image": ("image_alias", "生图别名"),
@@ -2171,10 +2267,33 @@ def _assert_public_ip(value):
def _redact_cmhub(text, api_key):
return appconfig.redact_secrets(text, [api_key])
def _role_model(category, name, models_path):
def _runtime_ai_snapshot(config):
if not isinstance(config, dict):
return None
runtime = config.get(_RUNTIME_AI_SNAPSHOT_KEY)
return runtime if isinstance(runtime, dict) else None
def _runtime_direct_models(config):
runtime = _runtime_ai_snapshot(config)
if runtime is None or "direct_models" not in runtime:
return None
models = runtime.get("direct_models")
return copy.deepcopy(models) if isinstance(models, list) else []
def _role_model(category, name, models_path, *, models=None):
if not name:
raise AIError("未配置默认 %s 模型" % category)
model = appconfig.get_model(name, path=models_path)
if models is None:
model = appconfig.get_model(name, path=models_path)
else:
model = next(
(dict(item) for item in models if str(item.get("name") or "") == str(name)),
None,
)
if model is None:
raise AIError("AI 模型不存在: %s" % name)
if model.get("category") != category:
raise AIError("模型 %s 不是 %s 类别" % (name, category))
if not model.get("enabled", True):
+11
View File
@@ -109,6 +109,9 @@ class MainWindow(QMainWindow):
self.tabs.currentChanged.connect(self._on_tab_changed)
for title in TAB_TITLES:
self.tabs.addTab(self._build_tab(title), title)
settings_tab = self._settings_tab()
if hasattr(settings_tab, "settingsSaved"):
settings_tab.settingsSaved.connect(self._on_settings_saved)
self.setCentralWidget(self.tabs)
self.show_status("就绪", level="muted")
if startup_status:
@@ -182,6 +185,14 @@ class MainWindow(QMainWindow):
if hasattr(widget, "refresh_tasks"):
widget.refresh_tasks()
def _on_settings_saved(self, backend):
for index in range(self.tabs.count()):
widget = self.tabs.widget(index)
if hasattr(widget, "refresh_gateway_state"):
widget.refresh_gateway_state()
label = "自定义网关" if str(backend) == "direct" else "默认网关"
self.show_status("设置已保存,当前使用%s" % label, level="success")
def _on_tab_changed(self, index):
if self._reverting_tab_change:
self._last_tab_index = index
+30 -5
View File
@@ -1089,7 +1089,7 @@ class GenerateTab(QWidget):
self.failed_progress_label = QLabel("失败 0")
self.failed_progress_label.setObjectName("generateFailedProgressLabel")
self.failed_progress_label.setVisible(False)
self.cmhub_balance_label = QLabel("cmhub余额:未获取")
self.cmhub_balance_label = QLabel("默认网关余额:未获取")
self.cmhub_balance_label.setObjectName("generateCmhubBalanceLabel")
self.cmhub_balance_label.setVisible(False)
self.title_elapsed_label = QLabel("生标题用时 0 秒")
@@ -1760,6 +1760,21 @@ class GenerateTab(QWidget):
if not self._save_generate_mode_setting(show_status=False):
return
generate_mode = self._current_generate_mode()
if not self._is_cmhub_backend():
try:
ai.validate_direct_generation_config(
self.config,
generate_mode,
models_path=(
self.config.get("ai_models_path")
or appconfig.ai_models_config_path(self.config)
),
)
except Exception as exc:
message = "自定义网关配置不完整:%s。请到⑤设置补齐本轮所需模型的地址、模型ID、API Key、接口类型和启用状态。" % str(exc)
QMessageBox.warning(self, "无法开始生成", message)
self._set_status("自定义网关配置不完整,请到⑤设置补齐", "warning")
return
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
base_candidates = self._generation_candidates(generate_mode)
if not base_candidates:
@@ -1837,7 +1852,12 @@ class GenerateTab(QWidget):
"generate_mode": generate_mode,
}
)
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
if self._is_cmhub_backend():
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
else:
self._set_status(
"开始 AI 生成:%d 条;自定义网关(不计点数,费用由服务商收取)" % len(tasks)
)
thread.start()
def _generation_candidates(self, generate_mode):
@@ -2282,7 +2302,7 @@ class GenerateTab(QWidget):
def _reset_cmhub_balance_label(self):
self.cmhub_balance_label.setVisible(False)
if self._is_cmhub_backend():
self.cmhub_balance_label.setText("cmhub余额:生成后刷新")
self.cmhub_balance_label.setText("默认网关余额:生成后刷新")
else:
self.cmhub_balance_label.setText("")
@@ -2293,9 +2313,14 @@ class GenerateTab(QWidget):
balance = payload.get("points_balance") if isinstance(payload, dict) else None
if balance is None:
if not self.cmhub_balance_label.text():
self.cmhub_balance_label.setText("cmhub余额:未获取")
self.cmhub_balance_label.setText("默认网关余额:未获取")
return
self.cmhub_balance_label.setText(f"cmhub余额:{balance}")
self.cmhub_balance_label.setText(f"默认网关余额:{balance}")
def refresh_gateway_state(self):
"""Clear source-specific feedback after ⑤ saves a new gateway source."""
self._reset_cmhub_balance_label()
def _is_cmhub_backend(self):
try:
+145 -6
View File
@@ -81,6 +81,7 @@ from ..workers import (
CMHubModelCatalogWorker,
ImageStudioDownloadOriginalWorker,
ImageStudioPullImagesWorker,
ImageStudioResumeJobsWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteHistoryExportWorker,
@@ -723,13 +724,20 @@ class SuiteResultCard(QFrame):
status_label.setWordWrap(False)
status_label.setToolTip(status_text)
footer.addWidget(status_label, 1)
self.retry_button = None
if status in {"failed", "expired", "cancelled"}:
retry_button = QPushButton("重试")
retry_button.setMinimumWidth(52)
retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job))
footer.addWidget(retry_button)
self.retry_button = QPushButton("重试")
self.retry_button.setMinimumWidth(52)
self.retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job))
footer.addWidget(self.retry_button)
layout.addLayout(footer)
def set_retry_enabled(self, enabled, tooltip=""):
if self.retry_button is None:
return
self.retry_button.setEnabled(bool(enabled))
self.retry_button.setToolTip(str(tooltip or ""))
class SuiteHistoryImageCard(QFrame):
"""Read-only image card used by the product-suite history dialog."""
@@ -1884,6 +1892,8 @@ class SuiteTaskState:
generation_price_worker: object = None
generation_price_thread: object = None
generation_confirmation_open: bool = False
resume_worker: object = None
resume_thread: object = None
download_queue: list = field(default_factory=list)
downloads: dict = field(default_factory=dict)
download_tokens: dict = field(default_factory=dict)
@@ -1962,6 +1972,10 @@ class ProductSuiteTab(QWidget):
if self._prompt_template_init_error:
self._status(self._prompt_template_init_error, "danger")
def showEvent(self, event):
super().showEvent(event)
self.refresh_gateway_state()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(10, 8, 10, 8)
@@ -2111,6 +2125,11 @@ class ProductSuiteTab(QWidget):
"QPushButton:hover { background: #245fce; }"
)
layout.addWidget(self.generate_button)
self.resume_submitted_button = QPushButton("继续查询已提交图片")
self.resume_submitted_button.setObjectName("suiteResumeSubmittedButton")
self.resume_submitted_button.setToolTip("继续查询已提交到默认网关的图片,不会重新生成或再次扣点")
self.resume_submitted_button.setVisible(False)
layout.addWidget(self.resume_submitted_button)
self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素")
self.generate_helper_label.setWordWrap(True)
self.generate_helper_label.setStyleSheet("color: #6b7280;")
@@ -2396,6 +2415,7 @@ class ProductSuiteTab(QWidget):
self.custom_category_edit.returnPressed.connect(self._commit_custom_category)
self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit)
self.generate_button.clicked.connect(self.toggle_generation)
self.resume_submitted_button.clicked.connect(self.resume_submitted_jobs)
self.history_button.clicked.connect(self.open_history_dialog)
self.undo_button.clicked.connect(self.undo_delete)
self.more_button.clicked.connect(self._show_more_menu)
@@ -4155,7 +4175,105 @@ class ProductSuiteTab(QWidget):
if not state.generation_running():
self.generate_button.setText("生成套图(%d)" % total)
def _is_default_gateway(self):
try:
return appconfig.ai_backend(self.config) == "cmhub"
except Exception:
return False
@staticmethod
def _is_default_gateway_job(job):
return (
str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub"
and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub"
and bool(str(getattr(job, "task_id", "") or "").strip())
)
def _resumable_default_gateway_jobs(self, state):
if state is None or state.project_id is None:
return []
try:
jobs = image_studio.list_resumable_jobs(
project_id=state.project_id,
include_failed_downloads=True,
path=self.db_path,
)
except Exception:
return []
return [job for job in jobs if self._is_default_gateway_job(job)]
def refresh_gateway_state(self):
state = self._displayed_state
if state is not None:
self._apply_running_state(state)
self._refresh_results(state)
def _update_resume_submitted_action(self, state):
jobs = self._resumable_default_gateway_jobs(state)
self.resume_submitted_button.setVisible(bool(jobs))
self.resume_submitted_button.setEnabled(
bool(jobs)
and state.resume_worker is None
and not state.generation_running()
and state.generation_price_worker is None
)
def resume_submitted_jobs(self, checked=False):
state = self._displayed_state
if state is None or state.project_id is None:
return
if state.resume_worker is not None:
self._status("正在继续查询已提交图片", "info")
return
jobs = self._resumable_default_gateway_jobs(state)
if not jobs:
self._status("当前没有可继续查询的默认网关图片", "info")
self._update_resume_submitted_action(state)
return
worker = ImageStudioResumeJobsWorker(
project_id=state.project_id,
aspect_ratio=state.settings.get("ratio", "1:1"),
db_path=self.db_path,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
state.resume_worker = worker
worker.finished.connect(
lambda result, state=state: self._on_resume_submitted_finished(state, result)
)
worker.cancelled.connect(
lambda result, state=state: self._on_resume_submitted_finished(state, result)
)
state.resume_thread = self._start_thread(worker, "商品套图继续查询")
self._apply_running_state(state)
self._status("开始继续查询%d张已提交图片,不会重新生成或再次扣点" % len(jobs), "info")
def _on_resume_submitted_finished(self, state, result):
state.resume_worker = None
state.resume_thread = None
self._refresh_results(state)
self._update_resume_submitted_action(state)
if result.get("ok") is False:
error = str(result.get("error") or "")
if "cmhub_not_configured" in error or "缺少" in error:
self._message(
"继续查询失败",
"默认网关配置不可用,请恢复原默认网关配置后继续查询已提交图片。",
)
else:
self._message("继续查询失败", _user_error(error))
return
self._status("已完成已提交图片查询,请查看生成结果", "success")
def _require_default_gateway(self, action):
if self._is_default_gateway():
return True
self._message("当前不可用", "%s仅支持默认网关,请到⑤设置切换后再使用。" % action)
return False
def start_ai_write(self, checked=False):
if not self._require_default_gateway("商品套图AI帮写"):
return
state = self._displayed_state
if state is None:
return
@@ -4482,6 +4600,8 @@ class ProductSuiteTab(QWidget):
self.start_generation(state)
def start_generation(self, state, specs=None, *, retry_job_id=None):
if not self._require_default_gateway("商品套图生成"):
return False
if state.generation_running():
self._status("当前套图任务仍在生成", "warning")
return False
@@ -5414,6 +5534,7 @@ class ProductSuiteTab(QWidget):
generation_running = state.generation_running()
generation_price_pending = state.generation_price_worker is not None
pull_running = state.pull_running()
default_gateway = self._is_default_gateway()
self.pull_button.setText(
"正在停止..."
if pull_running and state.pull_stop_requested
@@ -5439,7 +5560,9 @@ class ProductSuiteTab(QWidget):
widget.setEnabled(not generation_running)
for row in self.category_rows.values():
row.set_controls_enabled(not generation_running)
self.generate_button.setEnabled(True)
self.generate_button.setEnabled(
generation_running or generation_price_pending or default_gateway
)
if generation_running:
self.generate_button.setText(
"正在停止..."
@@ -5465,8 +5588,17 @@ class ProductSuiteTab(QWidget):
)
self._refresh_totals(state)
ai_running = state.ai_worker is not None or state.ai_price_worker is not None
self.ai_write_button.setEnabled(not ai_running and not generation_running)
self.ai_write_button.setEnabled(
default_gateway and not ai_running and not generation_running
)
if not default_gateway and not ai_running:
self.ai_write_button.setToolTip("商品套图AI帮写仅支持默认网关")
self.generate_button.setToolTip("商品套图生成仅支持默认网关")
else:
self.ai_write_button.setToolTip("")
self.generate_button.setToolTip("")
self.ai_cancel_button.setVisible(ai_running)
self._update_resume_submitted_action(state)
self._update_context_actions(state)
def _refresh_elapsed(self):
@@ -5541,6 +5673,8 @@ class ProductSuiteTab(QWidget):
card.retryRequested.connect(self.retry_job)
card.menuRequested.connect(self._show_job_menu)
card.deleteRequested.connect(self.delete_job_asset)
if not self._is_default_gateway():
card.set_retry_enabled(False, "商品套图重新生成仅支持默认网关")
self.result_grid.addWidget(card, index // columns, index % columns)
self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success))
self.undo_button.setVisible(bool(state.undo_records))
@@ -5611,6 +5745,8 @@ class ProductSuiteTab(QWidget):
ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec()
def retry_job(self, job):
if not self._require_default_gateway("商品套图重新生成"):
return
state = self._displayed_state
if state is None:
return
@@ -5643,6 +5779,9 @@ class ProductSuiteTab(QWidget):
copy_action = menu.addAction("复制路径")
folder_action = menu.addAction("打开文件夹")
retry_action = menu.addAction("重新生成")
if not self._is_default_gateway():
retry_action.setEnabled(False)
retry_action.setToolTip("商品套图重新生成仅支持默认网关")
delete_action = menu.addAction("删除")
action = menu.exec(global_position)
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
+89 -24
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
from contextlib import contextmanager
import os
from PySide6.QtCore import Signal
from ... import ai as ai_module
from ... import chrome
from ... import cmhub_models
@@ -14,7 +16,7 @@ from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
PLAINTEXT_CMHUB_API_KEY_WARNING = (
"cmhub API Key 会以本地明文保存到 data/config/cmhub.json,仅供本机调用 cmhub 网关使用。"
"默认网关 API Key 会以本地明文保存到 data/config/cmhub.json,仅供本机调用默认网关使用。"
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
)
@@ -29,7 +31,9 @@ def CMHubSettingsWorker(*args, **kwargs):
class SettingsTab(QWidget):
"""Tab 5: AI model definitions stored in data/config/ai_models.json."""
BACKEND_ITEMS = [("直连模型", "direct"), ("cmhub 网关", "cmhub")]
settingsSaved = Signal(str)
BACKEND_ITEMS = [("默认网关", "cmhub"), ("自定义网关", "direct")]
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")]
RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"]
@@ -77,13 +81,37 @@ class SettingsTab(QWidget):
for label, value in self.BACKEND_ITEMS:
self.backend_combo.addItem(label, value)
self.backend_combo.setVisible(False)
self.gateway_default_button = QPushButton("默认网关")
self.gateway_default_button.setObjectName("gatewayDefaultButton")
self.gateway_default_button.setCheckable(True)
self.gateway_custom_button = QPushButton("自定义网关")
self.gateway_custom_button.setObjectName("gatewayCustomButton")
self.gateway_custom_button.setCheckable(True)
self.gateway_button_group = QButtonGroup(self)
self.gateway_button_group.setExclusive(True)
self.gateway_button_group.addButton(self.gateway_default_button)
self.gateway_button_group.addButton(self.gateway_custom_button)
self.gateway_selector = QWidget()
self.gateway_selector.setObjectName("gatewaySourceSelector")
gateway_selector_layout = QHBoxLayout(self.gateway_selector)
gateway_selector_layout.setContentsMargins(0, 0, 0, 0)
gateway_selector_layout.setSpacing(0)
gateway_selector_layout.addWidget(self.gateway_default_button)
gateway_selector_layout.addWidget(self.gateway_custom_button)
self.gateway_selector.setStyleSheet(
"QPushButton { min-width: 108px; padding: 6px 12px; border: 1px solid #b8c0ca; "
"background: #f6f8fa; color: #57606a; }"
"QPushButton:first-child { border-top-left-radius: 4px; border-bottom-left-radius: 4px; }"
"QPushButton:last-child { border-left: 0; border-top-right-radius: 4px; border-bottom-right-radius: 4px; }"
"QPushButton:checked { background: #0969da; border-color: #0969da; color: white; font-weight: 600; }"
)
self.cmhub_base_url_edit = QLineEdit()
self.cmhub_base_url_edit.setObjectName("cmhubBaseUrlEdit")
self.cmhub_base_url_edit.setPlaceholderText("https://host(不要带 /api 或 /api/v1)")
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_api_key_edit.setPlaceholderText("从默认网关网页端复制 API Key")
self.cmhub_title_alias_combo = QComboBox()
self.cmhub_title_alias_combo.setObjectName("cmhubTitleAliasCombo")
self.cmhub_image_alias_combo = QComboBox()
@@ -103,10 +131,10 @@ class SettingsTab(QWidget):
self.cmhub_result_label = QLabel("")
self.cmhub_result_label.setObjectName("cmhubResultLabel")
self.cmhub_result_label.setWordWrap(True)
self.cmhub_base_url_hint_label = QLabel("Base URL 只填网关根,如 https://host;不要带 /api 或 /api/v1。")
self.cmhub_base_url_hint_label = QLabel("Base URL 只填默认网关根,如 https://host;不要带 /api 或 /api/v1。")
self.cmhub_base_url_hint_label.setObjectName("cmhubBaseUrlHintLabel")
self.cmhub_base_url_hint_label.setWordWrap(True)
self.cmhub_key_hint_label = QLabel("API Key 仅在 cmhub 网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。")
self.cmhub_key_hint_label = QLabel("API Key 仅在默认网关网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。")
self.cmhub_key_hint_label.setObjectName("cmhubKeyHintLabel")
self.cmhub_key_hint_label.setWordWrap(True)
@@ -235,7 +263,7 @@ class SettingsTab(QWidget):
("状态", self.enabled_checkbox),
("服务商名", self.name_edit),
("类别", self.category_combo),
("api_type", self.api_type_combo),
("接口类型", self.api_type_combo),
("模型ID", self.model_id_edit),
("连接超时(秒)", self.connect_timeout_spin),
("网址", self.url_edit, True),
@@ -296,8 +324,15 @@ class SettingsTab(QWidget):
model_detail_layout.addLayout(action_layout)
model_detail_layout.addWidget(self.test_result_label)
self.direct_gateway_notice = QLabel("图片理解与商品套图仅支持默认网关。")
self.direct_gateway_notice.setObjectName("directGatewayNoticeLabel")
self.direct_gateway_notice.setStyleSheet("color: #6b7280;")
self.direct_role_panel = QWidget()
self.direct_role_panel.setLayout(direct_role_form)
direct_role_layout = QVBoxLayout(self.direct_role_panel)
direct_role_layout.setContentsMargins(0, 0, 0, 0)
direct_role_layout.setSpacing(8)
direct_role_layout.addLayout(direct_role_form)
direct_role_layout.addWidget(self.direct_gateway_notice)
cmhub_form = self._three_column_form(
[
@@ -332,7 +367,7 @@ class SettingsTab(QWidget):
self.settings_panel_layout = panel_layout
panel_layout.setContentsMargins(13, 18, 13, 18)
self.ai_model_section_title = self._section_title(
"cmhub 网关",
"生成网关",
"settingsAiModelSectionTitle",
)
self.model_detail_section_title = self._section_title(
@@ -352,6 +387,8 @@ class SettingsTab(QWidget):
"settingsInfrastructureSectionTitle",
)
panel_layout.addWidget(self.ai_model_section_title)
panel_layout.addWidget(self.gateway_selector)
panel_layout.addSpacing(8)
panel_layout.addWidget(self.model_picker_panel)
panel_layout.addSpacing(14)
panel_layout.addWidget(self.model_detail_section_title)
@@ -394,7 +431,8 @@ 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.gateway_default_button.toggled.connect(self._on_gateway_source_toggled)
self.gateway_custom_button.toggled.connect(self._on_gateway_source_toggled)
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(
@@ -480,7 +518,6 @@ class SettingsTab(QWidget):
self.chrome_path_edit,
)
combos = (
self.backend_combo,
self.cmhub_title_alias_combo,
self.cmhub_image_alias_combo,
self.cmhub_vision_alias_combo,
@@ -516,6 +553,9 @@ class SettingsTab(QWidget):
for widget in checkboxes:
widget.toggled.connect(self._mark_dirty)
self.gateway_default_button.toggled.connect(self._mark_dirty)
self.gateway_custom_button.toggled.connect(self._mark_dirty)
def _mark_dirty(self, *args):
if self._suspend_dirty > 0:
return
@@ -576,13 +616,36 @@ class SettingsTab(QWidget):
self._cmhub_auto_refresh_done = True
self.refresh_cmhub_models()
def _selected_backend(self):
return "direct" if self.gateway_custom_button.isChecked() else "cmhub"
def _set_selected_backend(self, backend):
value = "direct" if str(backend or "").strip().lower() == "direct" else "cmhub"
target = self.gateway_custom_button if value == "direct" else self.gateway_default_button
for button in (self.gateway_default_button, self.gateway_custom_button):
previous = button.blockSignals(True)
button.setChecked(button is target)
button.blockSignals(previous)
previous = self.backend_combo.blockSignals(True)
self._set_combo_by_data(self.backend_combo, value)
self.backend_combo.blockSignals(previous)
def _on_gateway_source_toggled(self, checked):
if checked:
self._on_backend_changed()
def _on_backend_changed(self, index=None):
backend = self._selected_backend()
previous = self.backend_combo.blockSignals(True)
self._set_combo_by_data(self.backend_combo, backend)
self.backend_combo.blockSignals(previous)
self.backend_combo.setVisible(False)
self.model_picker_panel.setVisible(False)
self.model_detail_section_title.setVisible(False)
self.model_detail_panel.setVisible(False)
self.direct_role_panel.setVisible(False)
self.cmhub_panel.setVisible(True)
is_direct = backend == "direct"
self.model_picker_panel.setVisible(is_direct)
self.model_detail_section_title.setVisible(is_direct)
self.model_detail_panel.setVisible(is_direct)
self.direct_role_panel.setVisible(is_direct)
self.cmhub_panel.setVisible(not is_direct)
self._set_cmhub_running(self.cmhub_thread is not None)
self._update_button_state()
@@ -733,8 +796,10 @@ class SettingsTab(QWidget):
self._replace_config(saved)
self._populate_app_settings()
self._set_dirty(False)
self._set_status("设置已保存")
backend_label = "自定义网关" if self._selected_backend() == "direct" else "默认网关"
self._set_status("设置已保存,当前使用%s" % backend_label)
QMessageBox.information(self, "保存设置", "设置已保存")
self.settingsSaved.emit(self._selected_backend())
return True
def _app_settings_values(self):
@@ -748,7 +813,7 @@ class SettingsTab(QWidget):
self._show_error("默认调试端口必须在调试端口范围内")
return None
ai_cfg = appconfig.ai_config(self.config)
backend = "cmhub"
backend = self._selected_backend()
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")
cmhub_cfg = self._cmhub_settings_values(backend)
@@ -847,7 +912,7 @@ class SettingsTab(QWidget):
return
self._populate_role_model_combos()
ai_cfg = appconfig.ai_config(self.config)
self._set_combo_by_data(self.backend_combo, "cmhub")
self._set_selected_backend(ai_cfg.get("backend", "cmhub"))
cmhub_cfg = appconfig.cmhub_config(self.config)
self.cmhub_base_url_edit.setText(appconfig.normalize_cmhub_base_url(cmhub_cfg.get("base_url", "")))
self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key(path=self.cmhub_config_path)
@@ -1092,7 +1157,7 @@ class SettingsTab(QWidget):
def _start_cmhub_worker(self, include_balance):
if self.cmhub_thread is not None:
self._set_status("cmhub 检测正在进行...")
self._set_status("默认网关检测正在进行...")
return
base_url = appconfig.normalize_cmhub_base_url(self.cmhub_base_url_edit.text())
if base_url != self.cmhub_base_url_edit.text().strip():
@@ -1104,7 +1169,7 @@ class SettingsTab(QWidget):
if not api_key:
missing.append("API Key")
if missing:
self._show_error("cmhub 配置不完整:缺少 " + "、".join(missing))
self._show_error("默认网关配置不完整:缺少 " + "、".join(missing))
return
worker = CMHubSettingsWorker(
base_url,
@@ -1121,7 +1186,7 @@ class SettingsTab(QWidget):
self.cmhub_worker = worker
self.cmhub_thread = thread
self._set_cmhub_running(True)
message = "正在测试 cmhub 连接并查询余额..." if include_balance else "正在刷新 cmhub 别名..."
message = "正在测试默认网关连接并查询余额..." if include_balance else "正在刷新默认网关别名..."
self.cmhub_result_label.setText(message)
self._set_status(message)
thread.start()
@@ -1183,8 +1248,8 @@ class SettingsTab(QWidget):
def _cmhub_success_subject(self, payload):
account_name = self._cmhub_account_display_name(payload)
if account_name:
return f"cmhub 账号「{account_name}」连接成功"
return "cmhub 连接成功"
return f"账号「{account_name}」连接默认网关成功"
return "默认网关连接成功"
def _cmhub_account_display_name(self, payload):
if not isinstance(payload, dict):
@@ -1237,7 +1302,7 @@ class SettingsTab(QWidget):
return ""
def _on_cmhub_failed(self, _task_id, error):
message = f"cmhub 连接失败:{error}"
message = "默认网关连接失败:%s" % str(error or "连接失败").replace("cmhub", "默认网关")
self.cmhub_result_label.setText(message)
self._set_status(message)
+39 -5
View File
@@ -285,7 +285,13 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
self.job_type = str(job_type or "main")
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.config = ai.freeze_runtime_config(
config,
cmhub_config_path=cmhub_config_path,
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
include_cmhub=True,
include_direct_models=False,
)
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
@@ -358,7 +364,13 @@ class ProductSuiteGenerateWorker(BaseWorker):
self.generation_round_key = str(generation_round_key or "").strip()
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.config = ai.freeze_runtime_config(
config,
cmhub_config_path=cmhub_config_path,
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
include_cmhub=True,
include_direct_models=False,
)
self.cmhub_config_path = cmhub_config_path
self.job_ids = []
self._done = 0
@@ -366,6 +378,8 @@ class ProductSuiteGenerateWorker(BaseWorker):
self._lock = threading.Lock()
def execute(self):
if appconfig.ai_backend(self.config) != "cmhub":
raise ValueError("商品套图仅支持默认网关,请到⑤设置切换后再生成")
total = len(self.job_specs)
if total <= 0:
raise ValueError("商品套图生成任务不能为空")
@@ -474,12 +488,20 @@ class ProductSuiteAiWriteWorker(BaseWorker):
self.instruction = str(instruction or "")
self.context = str(context or "")
self.image_paths = [str(path or "") for path in list(image_paths or [])]
self.config = config
self.config = ai.freeze_runtime_config(
config,
cmhub_config_path=cmhub_config_path,
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
include_cmhub=True,
include_direct_models=False,
)
self.cmhub_config_path = cmhub_config_path
def execute(self):
if self.should_cancel():
return {"cancelled": True}
if appconfig.ai_backend(self.config) != "cmhub":
raise ValueError("商品套图AI帮写仅支持默认网关,请到⑤设置切换后再使用")
result = ai.analyze_product_images(
self.instruction,
self.context,
@@ -583,7 +605,13 @@ class ImageStudioResumeJobsWorker(BaseWorker):
self.project_id = int(project_id) if project_id is not None else None
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.config = ai.freeze_runtime_config(
config,
cmhub_config_path=cmhub_config_path,
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
include_cmhub=True,
include_direct_models=False,
)
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
@@ -689,7 +717,11 @@ class GenerateWorker(BaseWorker):
self.tasks = list(tasks)
self.prompt_values = dict(prompt_values or {})
self.db_path = db_path
self.config = config
self.config = ai.freeze_runtime_config(
config,
cmhub_config_path=(config or {}).get("cmhub_config_path", appconfig.CMHUB_CONFIG_PATH),
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
)
self.diagnostic_log_dir = diagnostic_log_dir
self.generation_scope = product_status.normalize_scope(generation_scope)
self.product_status_counts = {
@@ -786,6 +818,8 @@ class GenerateWorker(BaseWorker):
),
excluded=self.status_scope_excluded,
)
if appconfig.ai_backend(self.config) == "direct":
start_message += ";自定义网关(不计点数,费用由服务商收取)"
self._log_run_event(start_message)
try:
summary = ai.generate_batch(
+41 -1
View File
@@ -36,6 +36,18 @@ def _runtime(config, cmhub_config_path):
return ai._cmhub_runtime(config, "image", cmhub_config_path)
def _is_default_gateway_job(job):
return (
str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub"
and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub"
)
def _ensure_new_submission_allowed(config):
if appconfig.ai_backend(config) != "cmhub":
raise ImageStudioGenerationError("商品套图仅支持默认网关,请到⑤设置切换后再生成")
def _generated_kind(job_type):
return "generated_detail" if str(job_type) == "detail" else "generated_main"
@@ -85,8 +97,10 @@ def create_generation_jobs(
count,
*,
job_type="main",
config=None,
path=None,
):
_ensure_new_submission_allowed(config)
total = max(0, int(count or 0))
if total <= 0:
raise ImageStudioGenerationError("生成数量必须大于0")
@@ -126,6 +140,7 @@ def generate_image_jobs(
prompt,
count,
job_type=job_type,
config=config,
path=path,
)
return run_jobs(
@@ -179,13 +194,35 @@ def run_jobs(
job_list = list(jobs or [])
if not job_list:
return {"total": 0, "success": 0, "failed": 0, "cancelled": 0, "jobs": []}
rejected = [
job
for job in job_list
if getattr(job, "task_id", None) and not _is_default_gateway_job(job)
]
job_list = [job for job in job_list if job not in rejected]
summary = {
"total": len(job_list) + len(rejected),
"success": 0,
"failed": len(rejected),
"cancelled": 0,
"jobs": [
{
"job": job,
"status": "failed",
"error": "该已提交任务不属于默认网关,不能继续查询",
}
for job in rejected
],
}
if not job_list:
return summary
runtime = _runtime(cfg, cmhub_config_path)
ai_cfg = appconfig.ai_config(cfg)
image_root = appconfig.image_dir(cfg)
max_workers = min(MAX_CMHUB_IMAGE_STUDIO_WORKERS, max(1, int(ai_cfg.get("image_concurrency", 1) or 1)), len(job_list))
should_stop = should_stop or (lambda: False)
lock = threading.Lock()
summary = {"total": len(job_list), "success": 0, "failed": 0, "cancelled": 0, "jobs": []}
def record(result):
with lock:
@@ -373,8 +410,11 @@ def _submit_or_resume_job(
reference_assets=(),
):
if job.task_id:
if not _is_default_gateway_job(job):
raise ImageStudioGenerationError("该已提交任务不属于默认网关,不能继续查询")
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id})
return _request_result(job.task_id, runtime, config)
_ensure_new_submission_allowed(config)
_raise_if_stopped(should_stop)
ai_cfg = appconfig.ai_config(config)
resolution = str(ai_cfg.get("resolution", "1k") or "1k")