Files
cmbot/src/app/widgets/ai_outfit_panel.py
T
adminandClaude Opus 4.8 81351c77b5 feat(ai-outfit): 话术按钮行微调 + 保留插入标题 + 最终提示词预览改弹窗 (§19.20)
话术组小改版(保留 {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>
2026-06-23 14:30:57 +08:00

1311 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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