话术组小改版(保留 {title} 占位符、不自动前置):
- 「保存话术」→「保存」,从编辑框下方上移到模板按钮行「重命名」之后
(行变 新建/另存为/重命名/保存/删除)
- 保留「插入标题」按钮 + {title} 占位符(用户自定标题位置,不与旧话术重复)
- 最终提示词预览改按需弹窗:编辑框下方「预览最终提示词」按钮 → 非模态
_OutfitPreviewDialog,含数据行下拉(read_all_rows,状态无关)+ 只读替换后
提示词(含 §7.1 输出要求);话术/分辨率/数据行变化时实时刷新
- 左栏空间理由:标题生成组 + 话术组并存,常驻预览会逼出滚动;弹窗零常驻高度
render_prompt 与 DEFAULT_OUTFIT_PROMPT 不改(纯面板改动)。
测试:面板断言按钮行含「保存」、保留「插入标题」+「预览最终提示词」、
预览弹窗按选中行替换 {title} 并附输出要求。全套 py37 通过
(test_config_service 的 ai_models.json 顺序失败属并行 §19.13,与本改动无关)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1311 lines
53 KiB
Python
1311 lines
53 KiB
Python
"""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,
|
||
QDialog,
|
||
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,
|
||
load_title_prompt,
|
||
save_outfit_prompts,
|
||
save_title_prompt,
|
||
)
|
||
|
||
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):
|
||
# request_interval/image_log pace and narrate directory rows that
|
||
# fan out into many images (docs/11 §9.1); single-file rows ignore them.
|
||
return generate_outfit_image(
|
||
task, self._prompt, self._output_dir, self._model_config,
|
||
quality=self._quality, resolution=self._resolution,
|
||
request_interval=self._options.request_interval,
|
||
image_concurrency=self._options.concurrency,
|
||
image_log=self.log.emit,
|
||
)
|
||
|
||
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)
|
||
|
||
|
||
class _TitleWorker(QObject):
|
||
"""Generates titles row-by-row on a QThread (docs/11 §17); queued signals.
|
||
|
||
Sequential by row: each row's garment image + the title prompt -> one title,
|
||
written back to that row's A column immediately. Failures are logged and skipped.
|
||
"""
|
||
|
||
tasks_loaded = Signal(object) # List[OutfitTask]
|
||
log = Signal(str)
|
||
progress = Signal(int, int, object) # completed, total, TitleResult
|
||
finished = Signal(int, int) # success_count, fail_count
|
||
failed = Signal(str) # fatal pre-run error (e.g. Excel locked)
|
||
|
||
def __init__(self, excel_path, model_config, prompt, request_interval):
|
||
super().__init__()
|
||
self._excel_path = excel_path
|
||
self._model_config = model_config
|
||
self._prompt = prompt
|
||
self._interval = float(request_interval or 0.0)
|
||
self._stop = False
|
||
|
||
def stop(self):
|
||
self._stop = True
|
||
|
||
def run(self):
|
||
import time
|
||
|
||
from core.ai_title import generate_title
|
||
from core.models import TitleResult
|
||
from services.ai_text_service import AiTextClient
|
||
from services.excel_service import (
|
||
ensure_excel_writable,
|
||
read_all_rows,
|
||
write_title_result,
|
||
)
|
||
|
||
try:
|
||
ensure_excel_writable(self._excel_path)
|
||
rows = read_all_rows(self._excel_path)
|
||
client = AiTextClient(self._model_config) # one client for the run
|
||
except Exception as exc: # noqa: BLE001 - report to UI
|
||
self.failed.emit(str(exc))
|
||
return
|
||
|
||
self.tasks_loaded.emit(rows)
|
||
total = len(rows)
|
||
if total == 0:
|
||
self.finished.emit(0, 0)
|
||
return
|
||
|
||
success = fail = 0
|
||
for index, task in enumerate(rows, start=1):
|
||
if self._stop:
|
||
break
|
||
if index > 1 and self._interval > 0:
|
||
time.sleep(self._interval)
|
||
result = generate_title(task, self._prompt, self._model_config,
|
||
api_client=client)
|
||
if result.success:
|
||
try:
|
||
write_title_result(self._excel_path, task.row_index,
|
||
result.generated_title)
|
||
except Exception as exc: # noqa: BLE001 - keep going
|
||
result = TitleResult(task=task, success=False,
|
||
error="写回失败:{}".format(exc), attempts=1)
|
||
if result.success:
|
||
success += 1
|
||
self.log.emit("第 {} 行标题:{}".format(
|
||
task.row_index, result.generated_title))
|
||
else:
|
||
fail += 1
|
||
self.log.emit("第 {} 行标题失败:{}".format(
|
||
task.row_index, result.error))
|
||
self.progress.emit(index, total, result)
|
||
|
||
self.finished.emit(success, fail)
|
||
|
||
|
||
class _OutfitPreviewDialog(QDialog):
|
||
"""Non-modal 最终提示词预览(docs/11 §7.3)。
|
||
|
||
数据行下拉 + 只读结果;内容 = render_prompt(话术, 选中行, 当前分辨率)。
|
||
话术/分辨率/数据行变化时由面板调 refresh() 刷新。下拉用 read_all_rows
|
||
(状态无关)填,整表完成后仍可选。
|
||
"""
|
||
|
||
def __init__(self, panel):
|
||
super().__init__(panel)
|
||
self._panel = panel
|
||
self.setWindowTitle("最终提示词预览")
|
||
self.setModal(False)
|
||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||
self.resize(440, 380)
|
||
|
||
v = QVBoxLayout(self)
|
||
row = QHBoxLayout()
|
||
row.addWidget(QLabel("数据行"))
|
||
self._combo = QComboBox()
|
||
panel._compact_combo(self._combo)
|
||
self._combo.currentIndexChanged.connect(self.refresh)
|
||
row.addWidget(self._combo, stretch=1)
|
||
v.addLayout(row)
|
||
|
||
self._warn = QLabel("")
|
||
self._warn.setStyleSheet("color:#b87a00;")
|
||
self._warn.setVisible(False)
|
||
v.addWidget(self._warn)
|
||
|
||
self._view = QPlainTextEdit()
|
||
self._view.setReadOnly(True)
|
||
v.addWidget(self._view, stretch=1)
|
||
|
||
self.reload_rows()
|
||
|
||
def reload_rows(self):
|
||
"""(Re)fill the data-row dropdown from the panel's Excel (status-independent)."""
|
||
excel = self._panel._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("Preview rows unavailable: %s", exc)
|
||
self._combo.blockSignals(True)
|
||
self._combo.clear()
|
||
if rows:
|
||
for t in rows:
|
||
pid = t.product_id if (t.product_id and t.product_id.strip()) else "(无货号)"
|
||
self._combo.addItem(
|
||
"第 {} 行 · {} · {}".format(t.row_index, pid, t.title), t)
|
||
else:
|
||
self._combo.addItem("(选 Excel 后显示替换效果)", None)
|
||
self._combo.blockSignals(False)
|
||
self.refresh()
|
||
|
||
def refresh(self):
|
||
from core.ai_outfit import build_output_requirements, render_prompt
|
||
template = self._panel._prompt_edit.toPlainText()
|
||
resolution = self._panel._resolution.currentText()
|
||
missing = "{title}" not in template
|
||
self._warn.setVisible(missing)
|
||
if missing:
|
||
self._warn.setText("⚠ 话术缺少 {title} 占位符,标题不会带入")
|
||
task = self._combo.currentData()
|
||
if task is None:
|
||
self._view.setPlainText(template + build_output_requirements(resolution))
|
||
else:
|
||
self._view.setPlainText(render_prompt(template, task, resolution))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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._title_thread = None # 标题生成线程(§17)
|
||
self._title_worker = None
|
||
self._title_fail = 0 # running failure count for title stats
|
||
self._title_model_name = "" # configured 标题模型 name (§17.3 配置定名)
|
||
self._preview_dialog = None # 最终提示词预览弹窗(§7.3,按需)
|
||
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):
|
||
self.setObjectName("aiOutfitPanel")
|
||
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)
|
||
self._apply_styles()
|
||
|
||
def _apply_styles(self):
|
||
"""Button palette mirroring the 添加印花 widgets (docs/11 §10.5).
|
||
|
||
Scoped under #aiOutfitPanel so QMessageBox/QInputDialog buttons keep the
|
||
native look. Blue = the single primary action (开始生成); red = stop/delete;
|
||
everything else is the neutral secondary used across the print panels.
|
||
"""
|
||
self.setStyleSheet("""
|
||
#aiOutfitPanel QPushButton {
|
||
font-size: 12px;
|
||
padding: 4px 10px;
|
||
border: 1px solid #d6d6d6;
|
||
border-radius: 3px;
|
||
background: #f0f0f0;
|
||
color: #202020;
|
||
}
|
||
#aiOutfitPanel QPushButton:hover { background: #e0e0e0; }
|
||
#aiOutfitPanel QPushButton:pressed { background: #d0d0d0; }
|
||
#aiOutfitPanel QPushButton:disabled { color: #aaaaaa; background: #f7f7f7; }
|
||
|
||
#aiOutfitPanel QPushButton#aiStartBtn {
|
||
font-weight: bold;
|
||
color: #ffffff;
|
||
border: none;
|
||
background: #0078d4;
|
||
}
|
||
#aiOutfitPanel QPushButton#aiStartBtn:hover { background: #106ebe; }
|
||
#aiOutfitPanel QPushButton#aiStartBtn:pressed { background: #005a9e; }
|
||
#aiOutfitPanel QPushButton#aiStartBtn:disabled {
|
||
color: #aaaaaa; background: #f7f7f7;
|
||
}
|
||
|
||
#aiOutfitPanel QPushButton#aiStopBtn,
|
||
#aiOutfitPanel QPushButton#aiPromptDeleteBtn {
|
||
color: #c42b1c;
|
||
background: #f0f0f0;
|
||
border: 1px solid #d6d6d6;
|
||
}
|
||
#aiOutfitPanel QPushButton#aiStopBtn:hover,
|
||
#aiOutfitPanel QPushButton#aiPromptDeleteBtn:hover {
|
||
background: #fde7e9; border-color: #c42b1c;
|
||
}
|
||
#aiOutfitPanel QPushButton#aiStopBtn:pressed,
|
||
#aiOutfitPanel QPushButton#aiPromptDeleteBtn:pressed { background: #f9d4d7; }
|
||
#aiOutfitPanel QPushButton#aiStopBtn:disabled,
|
||
#aiOutfitPanel QPushButton#aiPromptDeleteBtn:disabled {
|
||
color: #aaaaaa; background: #f7f7f7; border-color: #d6d6d6;
|
||
}
|
||
""")
|
||
|
||
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))
|
||
|
||
# 标题生成(§17):看衣服图 + 提示词 → AI 文字标题 → 写回 Excel A 列
|
||
col.addWidget(self._build_title_group(), stretch=2)
|
||
|
||
# 穿搭生成话术(多套模板;加大,随窗口高度拉伸)
|
||
prm = QGroupBox()
|
||
pv = QVBoxLayout(prm)
|
||
# 模板选择行:下拉 + 新建/另存为/重命名/删除
|
||
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)
|
||
prompt_header.addWidget(self._prompt_combo, stretch=1)
|
||
pv.addLayout(prompt_header)
|
||
trow = QHBoxLayout()
|
||
# 「保存」插在「重命名」「删除」之间(§7.2,原编辑框下方的「保存话术」上移改名)
|
||
for text, slot in (("新建", self._prompt_new), ("另存为", self._prompt_save_as),
|
||
("重命名", self._prompt_rename), ("保存", self._save_prompt),
|
||
("删除", self._prompt_delete)):
|
||
b = QPushButton(text)
|
||
if text == "删除":
|
||
b.setObjectName("aiPromptDeleteBtn")
|
||
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_dialog)
|
||
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)
|
||
preview_btn = QPushButton("预览最终提示词")
|
||
preview_btn.clicked.connect(self._open_preview)
|
||
prow.addWidget(preview_btn)
|
||
pv.addLayout(prow)
|
||
col.addWidget(prm, stretch=3)
|
||
|
||
scroll.setWidget(inner)
|
||
return scroll
|
||
|
||
def _build_title_group(self):
|
||
"""标题生成组(§17):提示词 + 保存 + 生成标题。
|
||
|
||
无标题模型下拉——模型由 app_config.title_model「配置定名」(§17.3/§17.6)。
|
||
"""
|
||
box = QGroupBox()
|
||
v = QVBoxLayout(box)
|
||
v.addWidget(QLabel("标题生成提示词"))
|
||
self._title_prompt_edit = QPlainTextEdit()
|
||
self._title_prompt_edit.setMinimumHeight(110)
|
||
v.addWidget(self._title_prompt_edit)
|
||
|
||
btn_row = QHBoxLayout()
|
||
save_title_btn = QPushButton("保存")
|
||
save_title_btn.clicked.connect(self._save_title_prompt)
|
||
btn_row.addWidget(save_title_btn)
|
||
self._title_btn = QPushButton("生成标题")
|
||
self._title_btn.clicked.connect(self._start_title)
|
||
btn_row.addWidget(self._title_btn)
|
||
v.addLayout(btn_row)
|
||
return box
|
||
|
||
def _create_settings_group(self):
|
||
"""生成设置 group (lives in the right run column); two params per row."""
|
||
gen = QGroupBox()
|
||
gv = QVBoxLayout(gen)
|
||
|
||
header = QHBoxLayout()
|
||
header.addWidget(QLabel("生成设置"))
|
||
header.addStretch(1)
|
||
self._retry_failed_chk = QCheckBox("重试上次失败的行")
|
||
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)
|
||
# Keep the preview dialog's output-requirements block in sync (§7.3).
|
||
self._resolution.currentIndexChanged.connect(self._refresh_preview_dialog)
|
||
# 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)
|
||
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)
|
||
|
||
run_row = QHBoxLayout()
|
||
self._start_btn = QPushButton("开始生成")
|
||
self._start_btn.setObjectName("aiStartBtn")
|
||
self._start_btn.clicked.connect(self._start)
|
||
run_row.addWidget(self._start_btn)
|
||
self._stop_btn = QPushButton("停止生成")
|
||
self._stop_btn.setObjectName("aiStopBtn")
|
||
self._stop_btn.setEnabled(False)
|
||
self._stop_btn.clicked.connect(self._stop)
|
||
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", ""))
|
||
outfit_output_dir = str(config.get("outfit_output_dir", "") or "").strip()
|
||
if not outfit_output_dir:
|
||
from services.file_service import get_outfit_output_dir
|
||
outfit_output_dir = str(get_outfit_output_dir())
|
||
self._output_edit.setText(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)
|
||
|
||
# 标题生成提示词(单份,§17.3)
|
||
self._title_prompt_edit.setPlainText(load_title_prompt())
|
||
# 标题模型「配置定名」:记下名字,运行时按名查 ai_models.json(§17.3,无下拉)
|
||
self._title_model_name = str(config.get("title_model", "") or "")
|
||
|
||
self._models = load_ai_models()
|
||
self._fill_model_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 ""
|
||
|
||
def _fill_model_combo(self, combo, selected_name):
|
||
"""Fill a model dropdown from self._models (shared by 图片/标题 model, §17.3)."""
|
||
combo.clear()
|
||
if not self._models:
|
||
combo.addItem("(未配置模型,请在 ai_models.json 添加)")
|
||
combo.setEnabled(False)
|
||
return
|
||
combo.setEnabled(True)
|
||
for m in self._models:
|
||
combo.addItem(m.get("name") or m.get("model") or "(未命名)")
|
||
self._set_combo(combo, selected_name)
|
||
|
||
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,
|
||
# title_model 由 app_config 配置定名(§17.3),UI 不写、不覆盖。
|
||
})
|
||
|
||
# -- 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()
|
||
if self._preview_dialog is not None:
|
||
self._preview_dialog.reload_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.3)--------------------------------------
|
||
|
||
def _open_preview(self):
|
||
"""Open (or raise) the non-modal 最终提示词预览 dialog."""
|
||
if self._preview_dialog is not None:
|
||
self._preview_dialog.raise_()
|
||
self._preview_dialog.activateWindow()
|
||
return
|
||
dialog = _OutfitPreviewDialog(self)
|
||
dialog.finished.connect(self._on_preview_closed)
|
||
self._preview_dialog = dialog
|
||
dialog.show()
|
||
|
||
def _on_preview_closed(self, _result):
|
||
self._preview_dialog = None
|
||
|
||
def _refresh_preview_dialog(self):
|
||
"""Refresh the preview if open (话术/分辨率/数据行变化时;否则 no-op)."""
|
||
if self._preview_dialog is not None:
|
||
self._preview_dialog.refresh()
|
||
|
||
# -- 话术模板(§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)
|
||
|
||
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()
|
||
|
||
# -- 标题生成(§17)-------------------------------------------------
|
||
|
||
def _save_title_prompt(self, silent=False):
|
||
save_title_prompt(self._title_prompt_edit.toPlainText())
|
||
if not silent:
|
||
self.statusBar_message("标题提示词已保存")
|
||
|
||
def _find_title_model(self):
|
||
"""Resolve the configured 标题模型 → (AiModelConfig|None, error|None). Pure.
|
||
|
||
Looks up app_config.title_model (a name) in ai_models.json by 'name'
|
||
(matching how models are labelled), so 换模型 is a config edit (§17.3).
|
||
"""
|
||
if not self._models:
|
||
return None, ("尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json "
|
||
"添加可生成文字的模型(chat/gemini)后重试。")
|
||
name = (self._title_model_name or "").strip()
|
||
data = next((m for m in self._models
|
||
if (m.get("name") or m.get("model")) == name), None)
|
||
if data is None:
|
||
return None, ("未找到标题模型「{}」。请在 ai_models.json 添加可生成文字的"
|
||
"模型(chat/gemini),并把 app_config 的 title_model 设为它的 "
|
||
"name。".format(name or "(未配置)"))
|
||
from services.ai_image_service import AiModelConfig, api_config_errors
|
||
errors = api_config_errors(data)
|
||
if errors:
|
||
return None, "标题模型「{}」配置有误:{}".format(name, ";".join(errors))
|
||
return AiModelConfig.from_dict(data), None
|
||
|
||
def _resolve_title_model_config(self):
|
||
config, error = self._find_title_model()
|
||
if error:
|
||
QMessageBox.warning(self, "标题模型不可用", error)
|
||
return None
|
||
return config
|
||
|
||
def _start_title(self):
|
||
if self._thread is not None or self._title_thread is not None:
|
||
return
|
||
excel = self._excel_edit.text().strip()
|
||
if not excel:
|
||
QMessageBox.information(self, "提示", "请先选择 Excel 文件。")
|
||
return
|
||
model_config = self._resolve_title_model_config()
|
||
if model_config is None:
|
||
return
|
||
prompt = self._title_prompt_edit.toPlainText().strip()
|
||
if not prompt:
|
||
QMessageBox.information(self, "提示", "请先填写标题生成提示词。")
|
||
return
|
||
|
||
self._save_title_prompt(silent=True)
|
||
self._emit_config()
|
||
|
||
# reset run state (shared with image generation)
|
||
self._table.setRowCount(0)
|
||
self._row_to_table = {}
|
||
self._title_fail = 0
|
||
self._progress.setValue(0)
|
||
self._log.clear()
|
||
|
||
self._title_worker = _TitleWorker(
|
||
excel, model_config, prompt, self._interval.value())
|
||
self._title_thread = QThread(self)
|
||
self._title_worker.moveToThread(self._title_thread)
|
||
self._title_thread.started.connect(self._title_worker.run)
|
||
self._title_worker.tasks_loaded.connect(self._on_title_tasks_loaded)
|
||
self._title_worker.log.connect(self._append_log)
|
||
self._title_worker.progress.connect(self._on_title_progress)
|
||
self._title_worker.finished.connect(self._on_title_finished)
|
||
self._title_worker.failed.connect(self._on_title_failed)
|
||
self._title_worker.finished.connect(self._title_thread.quit)
|
||
self._title_worker.failed.connect(self._title_thread.quit)
|
||
self._title_thread.finished.connect(self._cleanup_title_thread)
|
||
self._title_thread.start()
|
||
self._set_title_running(True)
|
||
|
||
def _on_title_tasks_loaded(self, tasks):
|
||
self._populate_table(tasks)
|
||
self._update_stats(0, 0, len(tasks))
|
||
self._append_log("标题生成:已加载 {} 行".format(len(tasks)))
|
||
|
||
def _on_title_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, 1, result.generated_title) # refresh 标题 列
|
||
self._set_cell(row, 4, "完成")
|
||
self._set_cell(row, 5, "标题已生成")
|
||
else:
|
||
self._set_cell(row, 4, "失败")
|
||
self._set_cell(row, 5, result.error)
|
||
if not result.success:
|
||
self._title_fail += 1
|
||
self._progress.setValue(completed)
|
||
self._update_stats(completed - self._title_fail, self._title_fail, total - completed)
|
||
|
||
def _on_title_finished(self, success, fail):
|
||
self._set_title_running(False)
|
||
msg = "标题生成结束:成功 {},失败 {}".format(success, fail)
|
||
self._append_log(msg)
|
||
self._reload_after_titles()
|
||
if success or fail:
|
||
QMessageBox.information(
|
||
self, "标题生成",
|
||
msg + "。\n标题已写回 Excel,可点「开始生成」生成穿搭图。")
|
||
else:
|
||
QMessageBox.information(self, "标题生成", "该表没有可处理的行。")
|
||
|
||
def _on_title_failed(self, message):
|
||
self._set_title_running(False)
|
||
self._append_log("标题生成无法开始:" + message)
|
||
QMessageBox.warning(self, "无法开始", message)
|
||
|
||
def _cleanup_title_thread(self):
|
||
self._title_thread = None
|
||
self._title_worker = None
|
||
|
||
def _reload_after_titles(self):
|
||
"""Re-read Excel so the table + later image generation use new titles."""
|
||
excel = self._excel_edit.text().strip()
|
||
if not excel:
|
||
return
|
||
try:
|
||
from services.excel_service import read_all_rows
|
||
rows = read_all_rows(excel)
|
||
except Exception as exc: # noqa: BLE001 - best effort
|
||
logger.info("Reload after titles failed: %s", exc)
|
||
return
|
||
self._populate_table(rows)
|
||
self._update_stats(0, 0, len(rows))
|
||
|
||
def _set_title_running(self, running):
|
||
self._title_btn.setEnabled(not running)
|
||
self._title_btn.setText("生成中…" if running else "生成标题")
|
||
self._start_btn.setEnabled(not running) # mutually exclusive with 开始生成
|
||
self._excel_edit.setEnabled(not running)
|
||
|
||
# -- 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 or self._title_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_outfit_output_dir
|
||
output_dir = str(get_outfit_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))
|
||
self._title_btn.setEnabled(not running) # mutually exclusive with 生成标题
|
||
|
||
# -- worker callbacks (UI thread) -----------------------------------
|
||
|
||
def _populate_table(self, tasks):
|
||
"""Fill the detail table from a task list (shared by image/title runs)."""
|
||
self._table.setRowCount(len(tasks))
|
||
self._row_to_table = {}
|
||
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)))
|
||
|
||
def _on_tasks_loaded(self, tasks):
|
||
self._populate_table(tasks)
|
||
self._update_stats(0, 0, len(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, "完成")
|
||
if result.output_paths: # 目录行:子目录 + 张数
|
||
self._set_cell(row, 5, "{}({} 张)".format(
|
||
result.output_path, len(result.output_paths)))
|
||
else:
|
||
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)
|
||
if getattr(summary, "total", 0) == 0:
|
||
self._append_log("没有待处理的行。")
|
||
self._show_no_pending_message()
|
||
return
|
||
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 _show_no_pending_message(self):
|
||
"""Explain why nothing ran: rows already 完成/失败, and how to redo (§10.4)."""
|
||
done = failed = 0
|
||
excel = self._excel_edit.text().strip()
|
||
if excel:
|
||
try:
|
||
from services.excel_service import (
|
||
STATUS_DONE, STATUS_FAILED, read_all_rows)
|
||
for r in read_all_rows(excel):
|
||
if r.status == STATUS_DONE:
|
||
done += 1
|
||
elif r.status == STATUS_FAILED:
|
||
failed += 1
|
||
except Exception: # noqa: BLE001 - best effort
|
||
pass
|
||
if done == 0 and failed == 0:
|
||
text = "该表没有可处理的行(标题/货号/衣服图为空的行会被跳过)。"
|
||
else:
|
||
parts = []
|
||
if done:
|
||
parts.append("已完成 {} 行(已跳过)".format(done))
|
||
if failed:
|
||
parts.append("失败 {} 行(勾选「重试上次失败的行」可重做)".format(failed))
|
||
text = ("该表没有待处理的行:" + ";".join(parts)
|
||
+ "。\n要重做已完成的行,请清空对应行的状态(E)列后重试。")
|
||
QMessageBox.information(self, "没有待处理的行", text)
|
||
|
||
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):
|
||
# Directory rows produce several images (output_paths); single-file rows
|
||
# one (output_path). Add a thumbnail for each (docs/11 §9.1).
|
||
for path in (result.output_paths or [result.output_path]):
|
||
if not path:
|
||
continue
|
||
pix = QPixmap(path)
|
||
item = QListWidgetItem(result.task.product_id)
|
||
if not pix.isNull():
|
||
item.setIcon(QIcon(pix))
|
||
item.setData(Qt.UserRole, 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_outfit_output_dir
|
||
path = str(get_outfit_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
|
||
# normpath so a directory path "d:/images/a/" shows its leaf "a" (docs/11 §4.1).
|
||
return os.path.basename(os.path.normpath(str(path)))
|
||
|
||
|
||
def _csv(value):
|
||
text = str(value).replace('"', '""')
|
||
if any(c in text for c in (",", "\n", '"')):
|
||
return '"' + text + '"'
|
||
return text
|