Files
cmbot/src/app/widgets/ai_outfit_panel.py
T

915 lines
36 KiB
Python
Raw Normal View History

"""AI 穿搭页签(docs/11 §10)。
以 Excel 为数据源,逐行调 AI 图像 API 生成「人物穿着该衣服」的效果图,
写回 Excel D/E/F。界面分三栏:左设置 / 中(最近结果 + 处理明细)/ 右运行日志。
后台用 QThread + Worker(QObject) 包住 core.outfit_batch.OutfitBatchRunner,
通过 Qt signal 回主线程刷新 UI(子线程不直接碰控件)。
"""
import logging
from PySide6.QtCore import QObject, QSize, Qt, QThread, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QInputDialog,
QDoubleSpinBox,
QFileDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QSplitter,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from services.config_service import (
DEFAULT_OUTFIT_PROMPT,
load_ai_models,
load_outfit_prompts,
save_outfit_prompts,
)
logger = logging.getLogger(__name__)
_RESOLUTIONS = ["512", "1K", "2K", "4K"]
_QUALITIES = ["小文件", "均衡", "高清"]
_COLS = ["行", "标题", "货号", "衣服图", "状态", "结果 / 原因"]
# ---------------------------------------------------------------------------
# Background worker
# ---------------------------------------------------------------------------
class _OutfitWorker(QObject):
"""Runs the batch on a QThread; reports back via queued signals."""
tasks_loaded = Signal(object) # List[OutfitTask]
log = Signal(str)
progress = Signal(int, int, object) # completed, total, OutfitResult
finished = Signal(object) # OutfitBatchSummary
failed = Signal(str) # fatal pre-run error (e.g. Excel locked)
def __init__(self, excel_path, output_dir, model_config, prompt,
options, resolution, quality, retry_failed):
super().__init__()
self._excel_path = excel_path
self._output_dir = output_dir
self._model_config = model_config
self._prompt = prompt
self._options = options
self._resolution = resolution
self._quality = quality
self._retry_failed = retry_failed
self._runner = None
def stop(self):
if self._runner is not None:
self._runner.stop()
def run(self):
# Imported lazily so the UI thread never pulls in Pillow/requests at import.
from core.ai_outfit import generate_outfit_image
from core.outfit_batch import OutfitBatchRunner, OutfitBatchSummary
from services.excel_service import (
ensure_excel_writable,
load_outfit_tasks,
write_outfit_result,
)
try:
ensure_excel_writable(self._excel_path)
tasks = load_outfit_tasks(self._excel_path, retry_failed=self._retry_failed)
except Exception as exc: # noqa: BLE001 - report to UI
self.failed.emit(str(exc))
return
self.tasks_loaded.emit(tasks)
if not tasks:
self.finished.emit(OutfitBatchSummary(total=0))
return
def gen(task):
return generate_outfit_image(
task, self._prompt, self._output_dir, self._model_config,
quality=self._quality, resolution=self._resolution,
)
def on_progress(completed, total, result):
try:
write_outfit_result(self._excel_path, result)
except Exception as exc: # noqa: BLE001 - keep going
self.log.emit("⚠ 写回 Excel 失败(第 {} 行):{}".format(
result.task.row_index, exc))
self.progress.emit(completed, total, result)
self._runner = OutfitBatchRunner(
tasks, gen, options=self._options,
progress_callback=on_progress, log_callback=self.log.emit,
)
try:
summary = self._runner.run()
except Exception as exc: # noqa: BLE001 - report to UI
self.failed.emit(str(exc))
return
self.finished.emit(summary)
# ---------------------------------------------------------------------------
# Main panel
# ---------------------------------------------------------------------------
class AiOutfitPanel(QWidget):
"""The "2 AI 穿搭" page."""
# Emitted when an outfit setting changes; MainWindow merges + persists it.
config_changed = Signal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self._models = [] # list of model dicts from ai_models.json
self._thread = None
self._worker = None
self._row_to_table = {} # excel row_index -> table row
self._failures = [] # list of OutfitResult (failed)
self._last_resolution = "" # for "value actually changed" check (§10.2)
self._last_model = ""
self._prompts = [] # list of {name, text} (§7.2)
self._current_prompt_name = ""
self._saved_text = "" # stored text of the selected template (dirty check)
self._build_ui()
# -- construction ---------------------------------------------------
def _build_ui(self):
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
splitter = QSplitter(Qt.Horizontal)
splitter.setHandleWidth(1)
splitter.addWidget(self._build_left())
splitter.addWidget(self._build_center())
splitter.addWidget(self._build_right())
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setStretchFactor(2, 0)
splitter.setSizes([360, 540, 400])
outer.addWidget(splitter)
def _build_left(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
# AsNeeded (not AlwaysOff): if a child ever exceeds the column it scrolls
# instead of being clipped under the center panel (docs/11 §10.1).
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setMinimumWidth(320)
inner = QWidget()
col = QVBoxLayout(inner)
col.setContentsMargins(12, 12, 12, 12)
col.setSpacing(10)
# 数据源 / 输出:行内一行(标签 + 路径 + 浏览),省纵向空间给话术/预览
self._excel_edit = QLineEdit()
self._excel_edit.setPlaceholderText("选择商品表 .xlsx")
col.addLayout(self._inline_path_row("Excel", self._excel_edit, self._browse_excel))
self._output_edit = QLineEdit()
self._output_edit.setPlaceholderText("默认:程序旁的「合并后的图片」")
col.addLayout(self._inline_path_row("输出", self._output_edit, self._browse_output))
2026-06-22 14:36:38 +08:00
# 穿搭生成话术(多套模板;加大,随窗口高度拉伸)
prm = QGroupBox()
pv = QVBoxLayout(prm)
# 模板选择行:下拉 + 新建/另存为/重命名/删除
2026-06-22 14:36:38 +08:00
prompt_header = QHBoxLayout()
prompt_header.addWidget(QLabel("穿搭生成话术"))
self._prompt_combo = QComboBox()
self._compact_combo(self._prompt_combo)
self._prompt_combo.activated.connect(self._on_prompt_template_activated)
2026-06-22 14:36:38 +08:00
prompt_header.addWidget(self._prompt_combo, stretch=1)
pv.addLayout(prompt_header)
trow = QHBoxLayout()
for text, slot in (("新建", self._prompt_new), ("另存为", self._prompt_save_as),
("重命名", self._prompt_rename), ("删除", self._prompt_delete)):
b = QPushButton(text)
b.clicked.connect(slot)
trow.addWidget(b)
pv.addLayout(trow)
self._prompt_edit = QPlainTextEdit()
self._prompt_edit.setMinimumHeight(150)
self._prompt_edit.textChanged.connect(self._refresh_preview)
pv.addWidget(self._prompt_edit)
prow = QHBoxLayout()
insert_title_btn = QPushButton("插入标题")
insert_title_btn.clicked.connect(
lambda: self._prompt_edit.insertPlainText("{title}"))
prow.addWidget(insert_title_btn)
save_btn = QPushButton("保存话术")
save_btn.clicked.connect(self._save_prompt)
prow.addWidget(save_btn)
pv.addLayout(prow)
col.addWidget(prm, stretch=2)
2026-06-22 14:36:38 +08:00
# 最终生成要求预览(加大;内嵌、实时;替换样本行占位符 + 附加输出要求)
prev = QGroupBox()
pvw = QVBoxLayout(prev)
2026-06-22 14:36:38 +08:00
preview_header = QHBoxLayout()
preview_header.addWidget(QLabel("最终生成要求预览"))
self._sample_combo = QComboBox()
self._compact_combo(self._sample_combo)
self._sample_combo.currentIndexChanged.connect(self._refresh_preview)
2026-06-22 14:36:38 +08:00
preview_header.addWidget(self._sample_combo, stretch=1)
pvw.addLayout(preview_header)
self._preview_warn = QLabel("")
self._preview_warn.setStyleSheet("color:#b87a00;")
self._preview_warn.setVisible(False)
pvw.addWidget(self._preview_warn)
self._preview_view = QPlainTextEdit()
self._preview_view.setReadOnly(True)
self._preview_view.setMinimumHeight(200)
pvw.addWidget(self._preview_view)
col.addWidget(prev, stretch=3)
scroll.setWidget(inner)
return scroll
def _create_settings_group(self):
"""生成设置 group (lives in the right run column); two params per row."""
2026-06-22 14:57:47 +08:00
gen = QGroupBox()
gv = QVBoxLayout(gen)
2026-06-22 14:57:47 +08:00
header = QHBoxLayout()
header.addWidget(QLabel("生成设置"))
header.addStretch(1)
self._retry_failed_chk = QCheckBox("重试上次失败的行")
2026-06-22 14:57:47 +08:00
header.addWidget(self._retry_failed_chk)
gv.addLayout(header)
self._concurrency = QSpinBox()
self._concurrency.setRange(1, 16)
self._interval = QDoubleSpinBox()
self._interval.setRange(0.0, 60.0)
self._interval.setSuffix(" 秒")
self._cooldown = QDoubleSpinBox()
self._cooldown.setRange(0.0, 60.0)
self._cooldown.setSuffix(" 秒")
self._retry_count = QSpinBox()
self._retry_count.setRange(0, 10)
self._resolution = QComboBox()
self._resolution.addItems(_RESOLUTIONS)
self._resolution.currentIndexChanged.connect(self._refresh_preview)
# activated = user click only; programmatic sets won't pop (docs/11 §10.2)
self._resolution.activated.connect(self._on_resolution_activated)
self._quality = QComboBox()
self._quality.addItems(_QUALITIES)
pairs = [
("并发数", self._concurrency), ("新请求间隔", self._interval),
("单任务冷却", self._cooldown), ("失败重试", self._retry_count),
("分辨率", self._resolution), ("JPG 质量", self._quality),
]
grid = QGridLayout()
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(8)
for c in range(3):
grid.setColumnStretch(c, 1)
for i, (label, widget) in enumerate(pairs):
grid.addWidget(self._field(label, widget), i // 3, i % 3)
gv.addLayout(grid)
# AI 模型 下拉(移到生成设置下方)
self._model_combo = QComboBox()
self._compact_combo(self._model_combo)
self._model_combo.activated.connect(self._on_model_activated)
2026-06-22 14:57:47 +08:00
model_row = QHBoxLayout()
model_row.addWidget(QLabel("AI 模型"))
model_row.addWidget(self._model_combo, stretch=1)
gv.addLayout(model_row)
return gen
def _field(self, label_text, widget):
"""A compact label-above-control cell for the settings grid."""
cell = QWidget()
v = QVBoxLayout(cell)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(2)
v.addWidget(QLabel(label_text))
v.addWidget(widget)
return cell
def _compact_combo(self, combo):
"""Keep a combo from dictating column width: elide long items instead of
expanding its minimum size hint (docs/11 §10.1)."""
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
combo.setMinimumContentsLength(6)
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
def _inline_path_row(self, label_text, line_edit, on_browse):
"""Label + path field + 浏览 button on one row."""
row = QHBoxLayout()
lbl = QLabel(label_text)
lbl.setFixedWidth(40)
row.addWidget(lbl)
row.addWidget(line_edit, stretch=1)
btn = QPushButton("浏览…")
btn.clicked.connect(on_browse)
row.addWidget(btn)
return row
def _build_center(self):
wrap = QWidget()
col = QVBoxLayout(wrap)
col.setContentsMargins(12, 12, 12, 12)
col.setSpacing(10)
col.addWidget(QLabel("最近结果(单击查看大图)"))
self._results = QListWidget()
self._results.setViewMode(QListWidget.IconMode)
self._results.setFlow(QListWidget.LeftToRight)
self._results.setWrapping(False)
self._results.setMovement(QListWidget.Static)
self._results.setIconSize(QSize(96, 120))
self._results.setFixedHeight(168)
self._results.itemClicked.connect(self._open_result)
col.addWidget(self._results)
col.addWidget(QLabel("处理明细(按 Excel 行顺序,每完成一行即写回并保存)"))
self._table = QTableWidget(0, len(_COLS))
self._table.setHorizontalHeaderLabels(_COLS)
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
self._table.verticalHeader().setVisible(False)
header = self._table.horizontalHeader()
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(5, QHeaderView.Stretch)
col.addWidget(self._table, stretch=1)
return wrap
def _build_right(self):
wrap = QWidget()
wrap.setMinimumWidth(360)
col = QVBoxLayout(wrap)
col.setContentsMargins(12, 12, 12, 12)
col.setSpacing(10)
col.addWidget(self._create_settings_group())
col.addWidget(QLabel("本次进度"))
self._progress = QProgressBar()
self._progress.setValue(0)
col.addWidget(self._progress)
self._stats = QLabel("完成 0 · 失败 0 · 待处理 0")
col.addWidget(self._stats)
2026-06-22 14:57:47 +08:00
run_row = QHBoxLayout()
self._start_btn = QPushButton("开始生成")
self._start_btn.setObjectName("primaryBtn")
self._start_btn.clicked.connect(self._start)
2026-06-22 14:57:47 +08:00
run_row.addWidget(self._start_btn)
self._stop_btn = QPushButton("停止生成")
self._stop_btn.setEnabled(False)
self._stop_btn.clicked.connect(self._stop)
2026-06-22 14:57:47 +08:00
run_row.addWidget(self._stop_btn)
col.addLayout(run_row)
row = QHBoxLayout()
self._export_fail_btn = QPushButton("导出失败清单")
self._export_fail_btn.setEnabled(False)
self._export_fail_btn.clicked.connect(self._export_failures)
row.addWidget(self._export_fail_btn)
open_btn = QPushButton("打开输出目录")
open_btn.clicked.connect(self._open_output_dir)
row.addWidget(open_btn)
col.addLayout(row)
col.addWidget(QLabel("实时日志"))
self._log = QPlainTextEdit()
self._log.setReadOnly(True)
col.addWidget(self._log, stretch=1)
return wrap
# -- config wiring --------------------------------------------------
def apply_config(self, config):
"""Populate widgets from the merged app config + load models/prompt."""
self._excel_edit.setText(config.get("outfit_excel", ""))
self._output_edit.setText(config.get("outfit_output_dir", ""))
self._concurrency.setValue(int(config.get("outfit_concurrency", 1) or 1))
self._interval.setValue(float(config.get("outfit_request_interval", 2.0) or 0.0))
self._cooldown.setValue(float(config.get("outfit_task_cooldown", 1.0) or 0.0))
self._retry_count.setValue(int(config.get("outfit_retry_count", 2) or 0))
self._retry_failed_chk.setChecked(bool(config.get("outfit_retry_failed", False)))
self._set_combo(self._resolution, config.get("outfit_resolution", "1K"))
self._set_combo(self._quality, config.get("outfit_quality", "均衡"))
# 话术模板:载入多套 + 选中上次(§7.2)
self._prompts = load_outfit_prompts()
names = [p["name"] for p in self._prompts]
name = config.get("outfit_prompt_name", "")
if name not in names:
name = names[0]
self._rebuild_prompt_combo()
self._apply_prompt(name)
self._models = load_ai_models()
self._model_combo.clear()
if not self._models:
self._model_combo.addItem("(未配置模型,请在 ai_models.json 添加)")
self._model_combo.setEnabled(False)
else:
self._model_combo.setEnabled(True)
for m in self._models:
self._model_combo.addItem(m.get("name") or m.get("model") or "(未命名)")
self._set_combo(self._model_combo, config.get("outfit_model", ""))
# Snapshot current dropdown values so a later user re-select of the same
# item doesn't trigger the info popup (§10.2).
self._last_resolution = self._resolution.currentText()
self._last_model = self._model_combo.currentText() if self._models else ""
# Fill the preview's sample-row dropdown from the remembered Excel.
self._reload_sample_rows()
def _set_combo(self, combo, value):
idx = combo.findText(str(value))
if idx >= 0:
combo.setCurrentIndex(idx)
def _emit_config(self):
self.config_changed.emit({
"outfit_excel": self._excel_edit.text(),
"outfit_output_dir": self._output_edit.text(),
"outfit_model": self._model_combo.currentText() if self._models else "",
"outfit_concurrency": self._concurrency.value(),
"outfit_request_interval": self._interval.value(),
"outfit_task_cooldown": self._cooldown.value(),
"outfit_retry_count": self._retry_count.value(),
"outfit_resolution": self._resolution.currentText(),
"outfit_quality": self._quality.currentText(),
"outfit_retry_failed": self._retry_failed_chk.isChecked(),
"outfit_prompt_name": self._current_prompt_name,
})
# -- left actions ---------------------------------------------------
def _browse_excel(self):
path, _ = QFileDialog.getOpenFileName(
self, "选择 Excel 文件", self._excel_edit.text(), "Excel 文件 (*.xlsx)")
if path:
self._excel_edit.setText(path)
self._emit_config()
self._reload_sample_rows()
def _browse_output(self):
path = QFileDialog.getExistingDirectory(
self, "选择输出目录", self._output_edit.text())
if path:
self._output_edit.setText(path)
self._emit_config()
def _save_prompt(self):
self._store_current_text()
self.statusBar_message("话术已保存")
# -- 话术模板(§7.2)------------------------------------------------
def _rebuild_prompt_combo(self):
"""Refill the template dropdown from self._prompts (no signal)."""
self._prompt_combo.blockSignals(True)
self._prompt_combo.clear()
self._prompt_combo.addItems([p["name"] for p in self._prompts])
self._prompt_combo.blockSignals(False)
def _select_prompt_in_combo(self, name):
idx = self._prompt_combo.findText(name)
if idx >= 0:
self._prompt_combo.blockSignals(True)
self._prompt_combo.setCurrentIndex(idx)
self._prompt_combo.blockSignals(False)
def _prompt_text(self, name):
return next((p["text"] for p in self._prompts if p["name"] == name), "")
def _apply_prompt(self, name):
"""Load template *name* into the editor (no persistence)."""
self._current_prompt_name = name
self._saved_text = self._prompt_text(name)
self._select_prompt_in_combo(name)
self._prompt_edit.setPlainText(self._saved_text) # fires _refresh_preview
def _store_current_text(self):
"""Save the editor text into the current template + persist to disk."""
text = self._prompt_edit.toPlainText()
for p in self._prompts:
if p["name"] == self._current_prompt_name:
p["text"] = text
break
save_outfit_prompts(self._prompts)
self._saved_text = text
def _is_dirty(self):
return self._prompt_edit.toPlainText() != self._saved_text
def _maybe_save_dirty(self):
"""Handle unsaved edits before switching away. Return False = cancel."""
if not self._is_dirty():
return True
ans = QMessageBox.question(
self, "未保存", "当前话术「{}」有未保存的修改,是否保存?".format(
self._current_prompt_name),
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
QMessageBox.Save)
if ans == QMessageBox.Cancel:
return False
if ans == QMessageBox.Save:
self._store_current_text()
return True
def _name_exists(self, name):
return any(p["name"] == name for p in self._prompts)
def _ask_name(self, title, default=""):
"""Prompt for a unique non-empty template name; None if cancelled/invalid."""
name, ok = QInputDialog.getText(self, title, "模板名称:", text=default)
if not ok:
return None
name = name.strip()
if not name:
QMessageBox.warning(self, "名称无效", "模板名称不能为空。")
return None
if self._name_exists(name):
QMessageBox.warning(self, "名称重复", "已存在同名模板:{}".format(name))
return None
return name
def _on_prompt_template_activated(self, index):
name = self._prompt_combo.itemText(index)
if name == self._current_prompt_name:
return
if not self._maybe_save_dirty():
self._select_prompt_in_combo(self._current_prompt_name) # cancel: revert
return
self._apply_prompt(name)
self._emit_config()
def _prompt_new(self):
if not self._maybe_save_dirty():
return
name = self._ask_name("新建话术")
if name is None:
return
self._prompts.append({"name": name, "text": DEFAULT_OUTFIT_PROMPT})
save_outfit_prompts(self._prompts)
self._rebuild_prompt_combo()
self._apply_prompt(name)
self._emit_config()
def _prompt_save_as(self):
name = self._ask_name("另存为", default=self._current_prompt_name)
if name is None:
return
self._prompts.append({"name": name, "text": self._prompt_edit.toPlainText()})
save_outfit_prompts(self._prompts)
self._rebuild_prompt_combo()
self._apply_prompt(name)
self._emit_config()
def _prompt_rename(self):
new = self._ask_name("重命名", default=self._current_prompt_name)
if new is None:
return
for p in self._prompts:
if p["name"] == self._current_prompt_name:
p["name"] = new
break
self._current_prompt_name = new
save_outfit_prompts(self._prompts)
self._rebuild_prompt_combo()
self._select_prompt_in_combo(new)
self._emit_config()
def _prompt_delete(self):
if len(self._prompts) <= 1:
QMessageBox.information(self, "无法删除", "至少保留一套话术。")
return
ans = QMessageBox.question(
self, "删除话术", "确定删除话术「{}」?".format(self._current_prompt_name),
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if ans != QMessageBox.Yes:
return
idx = next((i for i, p in enumerate(self._prompts)
if p["name"] == self._current_prompt_name), 0)
self._prompts.pop(idx)
save_outfit_prompts(self._prompts)
self._rebuild_prompt_combo()
self._apply_prompt(self._prompts[min(idx, len(self._prompts) - 1)]["name"])
self._emit_config()
# -- inline prompt preview ------------------------------------------
def _fill_sample_combo(self, tasks):
"""Populate the sample-row dropdown (None data = no sample)."""
self._sample_combo.blockSignals(True)
self._sample_combo.clear()
if tasks:
for t in tasks:
self._sample_combo.addItem(
"第 {} 行 · {} · {}".format(t.row_index, t.product_id, t.title), t)
else:
self._sample_combo.addItem("(选 Excel 后显示替换效果)", None)
self._sample_combo.blockSignals(False)
self._refresh_preview()
def _reload_sample_rows(self):
"""Best-effort: read the chosen Excel to fill the sample dropdown.
Uses read_all_rows (status-independent) so preview still works after the
whole sheet is 完成 (docs/11 §10.3); generation still uses load_outfit_tasks.
"""
excel = self._excel_edit.text().strip()
rows = []
if excel:
try:
from services.excel_service import read_all_rows
rows = read_all_rows(excel)
except Exception as exc: # noqa: BLE001 - silent for preview
logger.info("Sample rows unavailable: %s", exc)
self._fill_sample_combo(rows)
def _refresh_preview(self):
if not hasattr(self, "_preview_view"):
return
from core.ai_outfit import build_output_requirements, render_prompt
template = self._prompt_edit.toPlainText()
self._preview_warn.setVisible("{title}" not in template)
if "{title}" not in template:
self._preview_warn.setText("⚠ 话术缺少 {title} 占位符")
# Mirror what actually gets sent: append the output-requirements block
# for the currently selected resolution (docs/11 §7.1).
resolution = self._resolution.currentText() if hasattr(self, "_resolution") else None
task = self._sample_combo.currentData() if hasattr(self, "_sample_combo") else None
if task is None:
self._preview_view.setPlainText(template + build_output_requirements(resolution))
else:
self._preview_view.setPlainText(render_prompt(template, task, resolution))
# -- switch info popups (user-only; §10.2) --------------------------
def _on_resolution_activated(self, _index):
res = self._resolution.currentText()
if res == self._last_resolution:
return
self._last_resolution = res
from services.ai_image_service import resolution_timeout
QMessageBox.information(
self, "分辨率已切换",
"已切换分辨率到 {}。\n\n"
"· 单任务超时约 {} 秒,分辨率越高越慢。\n"
"· 仅在下次「开始生成」生效,不影响正在进行的批次。".format(
res, resolution_timeout(res)))
def _on_model_activated(self, _index):
name = self._model_combo.currentText()
if name == self._last_model:
return
self._last_model = name
api_type = "auto"
idx = self._model_combo.currentIndex()
if self._models and 0 <= idx < len(self._models):
api_type = self._models[idx].get("api_type", "auto") or "auto"
QMessageBox.information(
self, "AI 模型已切换",
"已切换模型到 {}。\n\n"
"· 调用方式:{}。\n"
"· 不同模型的计费与效果可能不同。\n"
"· 仅在下次「开始生成」生效。".format(name, api_type))
# -- run control ----------------------------------------------------
def _start(self):
if self._thread is not None:
return
excel = self._excel_edit.text().strip()
if not excel:
QMessageBox.information(self, "提示", "请先选择 Excel 文件。")
return
model_config = self._selected_model_config()
if model_config is None:
return
prompt = self._prompt_edit.toPlainText()
if "{title}" not in prompt:
answer = QMessageBox.question(
self, "缺少占位符",
"话术中没有 {title} 占位符,生成时不会带入商品标题。仍要继续吗?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if answer != QMessageBox.Yes:
return
self._store_current_text() # persist editor into the selected template
self._emit_config()
output_dir = self._output_edit.text().strip()
if not output_dir:
from services.file_service import get_output_dir
output_dir = str(get_output_dir())
from core.outfit_batch import OutfitBatchOptions
options = OutfitBatchOptions(
concurrency=self._concurrency.value(),
request_interval=self._interval.value(),
task_cooldown=self._cooldown.value(),
retry_count=self._retry_count.value(),
)
# reset run state
self._table.setRowCount(0)
self._results.clear()
self._row_to_table = {}
self._failures = []
self._progress.setValue(0)
self._log.clear()
self._worker = _OutfitWorker(
excel, output_dir, model_config, prompt, options,
self._resolution.currentText(), self._quality.currentText(),
self._retry_failed_chk.isChecked(),
)
self._thread = QThread(self)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.tasks_loaded.connect(self._on_tasks_loaded)
self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress)
self._worker.finished.connect(self._on_finished)
self._worker.failed.connect(self._on_failed)
self._worker.finished.connect(self._thread.quit)
self._worker.failed.connect(self._thread.quit)
self._thread.finished.connect(self._cleanup_thread)
self._thread.start()
self._set_running(True)
def _stop(self):
if self._worker is not None:
self._worker.stop()
self._append_log("已请求停止:不再提交新任务,进行中的任务会收尾。")
self._stop_btn.setEnabled(False)
def _selected_model_config(self):
if not self._models:
QMessageBox.warning(
self, "未配置模型",
"尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。")
return None
data = self._models[self._model_combo.currentIndex()]
from services.ai_image_service import AiModelConfig, api_config_errors
errors = api_config_errors(data)
if errors:
QMessageBox.warning(self, "模型配置有误", ";".join(errors))
return None
return AiModelConfig.from_dict(data)
def _set_running(self, running):
self._start_btn.setEnabled(not running)
self._stop_btn.setEnabled(running)
self._excel_edit.setEnabled(not running)
self._model_combo.setEnabled(not running and bool(self._models))
# -- worker callbacks (UI thread) -----------------------------------
def _on_tasks_loaded(self, tasks):
self._table.setRowCount(len(tasks))
for row, task in enumerate(tasks):
self._row_to_table[task.row_index] = row
self._set_cell(row, 0, str(task.row_index))
self._set_cell(row, 1, task.title)
self._set_cell(row, 2, task.product_id)
self._set_cell(row, 3, _basename(task.garment_path))
self._set_cell(row, 4, "待处理")
self._set_cell(row, 5, "—")
self._progress.setMaximum(max(1, len(tasks)))
self._update_stats(0, 0, len(tasks))
self._fill_sample_combo(tasks)
self._append_log("已加载 {} 行待处理任务".format(len(tasks)))
def _on_progress(self, completed, total, result):
row = self._row_to_table.get(result.task.row_index)
if row is not None:
if result.success:
self._set_cell(row, 4, "完成")
self._set_cell(row, 5, result.output_path)
else:
self._set_cell(row, 4, "失败")
self._set_cell(row, 5, result.error)
self._progress.setValue(completed)
if result.success:
self._add_result_thumb(result)
else:
self._failures.append(result)
self._export_fail_btn.setEnabled(True)
failed = len(self._failures)
self._update_stats(completed - failed, failed, total - completed)
def _on_finished(self, summary):
self._set_running(False)
msg = "完成 {},失败 {}{}".format(
summary.success_count, summary.failure_count,
"(已停止)" if getattr(summary, "stopped", False) else "")
self._append_log("批量结束:" + msg)
QMessageBox.information(self, "AI 穿搭", "本次生成结束。\n" + msg)
def _on_failed(self, message):
self._set_running(False)
self._append_log("无法开始:" + message)
QMessageBox.warning(self, "无法开始", message)
def _cleanup_thread(self):
self._thread = None
self._worker = None
# -- helpers --------------------------------------------------------
def _add_result_thumb(self, result):
pix = QPixmap(result.output_path)
item = QListWidgetItem(result.task.product_id)
if not pix.isNull():
item.setIcon(QIcon(pix))
item.setData(Qt.UserRole, result.output_path)
self._results.insertItem(0, item)
def _open_result(self, item):
path = item.data(Qt.UserRole)
if path:
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
def _open_output_dir(self):
path = self._output_edit.text().strip()
if not path:
from services.file_service import get_output_dir
path = str(get_output_dir())
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
def _export_failures(self):
if not self._failures:
return
path, _ = QFileDialog.getSaveFileName(
self, "导出失败清单", "失败清单.csv", "CSV 文件 (*.csv)")
if not path:
return
try:
with open(path, "w", encoding="utf-8-sig", newline="") as f:
f.write("行,标题,货号,原因\n")
for r in self._failures:
f.write("{},{},{},{}\n".format(
r.task.row_index,
_csv(r.task.title), _csv(r.task.product_id), _csv(r.error)))
self.statusBar_message("失败清单已导出")
except OSError as exc:
QMessageBox.warning(self, "导出失败", str(exc))
def _append_log(self, message):
self._log.appendPlainText(message)
def _update_stats(self, done, failed, pending):
self._stats.setText("完成 {} · 失败 {} · 待处理 {}".format(
max(0, done), max(0, failed), max(0, pending)))
def _set_cell(self, row, col, text):
self._table.setItem(row, col, QTableWidgetItem(str(text)))
def statusBar_message(self, text):
win = self.window()
if hasattr(win, "statusBar"):
win.statusBar().showMessage(text, 4000)
def _basename(path):
import os
return os.path.basename(str(path))
def _csv(value):
text = str(value).replace('"', '""')
if any(c in text for c in (",", "\n", '"')):
return '"' + text + '"'
return text