feat(ai-outfit): 左栏新增「标题生成」(看图→提示词→AI 文字标题→写回A列) (§19.18)

AI 穿搭页左栏新增独立的「生成标题」流程:用户写标题提示词,AI 看该行
衣服图(目录行取首图)生成电商标题,逐行立即写回 Excel A 列并刷新明细表;
完成后重载 Excel,紧接「开始生成」跑图即用新标题。移除原「最终生成要求预览」
腾出版面(docs/11 §17)。

- ai_text_service.py:AiTextClient 复用图像服务 HTTP 管道做文本输出;
  chat/gemini 带图视觉,images/images_edits 明确报错;extract_text 取首条标题
- ai_title.py + TitleResult:单行编排,never raises
- excel_service.write_title_result:只写 A 列、不动 D/E/F
- config_service:title_model 默认 + load/save_title_prompt + 默认标题话术
- 面板:标题生成组(提示词+标题模型下拉+保存+生成标题)置于话术组上方;
  _TitleWorker 顺序逐行+立即回填+刷新;与「开始生成」互斥;删预览相关组件
- 测试:文本解析/payload、generate_title(单文件/目录首图/失败)、
  write_title_result、面板标题组存在且无预览;全套 py37 通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:04:38 +08:00
co-authored by Claude Opus 4.8
parent 313e71b774
commit 2fa488bd21
12 changed files with 921 additions and 86 deletions
+262 -84
View File
@@ -43,7 +43,9 @@ 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__)
@@ -136,6 +138,84 @@ class _OutfitWorker(QObject):
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)
# ---------------------------------------------------------------------------
# Main panel
# ---------------------------------------------------------------------------
@@ -151,6 +231,9 @@ class AiOutfitPanel(QWidget):
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._row_to_table = {} # excel row_index -> table row
self._failures = [] # list of OutfitResult (failed)
self._last_resolution = "" # for "value actually changed" check (§10.2)
@@ -241,7 +324,7 @@ class AiOutfitPanel(QWidget):
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))
@@ -249,6 +332,9 @@ class AiOutfitPanel(QWidget):
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)
@@ -271,7 +357,6 @@ class AiOutfitPanel(QWidget):
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("插入标题")
@@ -282,31 +367,37 @@ class AiOutfitPanel(QWidget):
save_btn.clicked.connect(self._save_prompt)
prow.addWidget(save_btn)
pv.addLayout(prow)
col.addWidget(prm, stretch=2)
# 最终生成要求预览(加大;内嵌、实时;替换样本行占位符 + 附加输出要求)
prev = QGroupBox()
pvw = QVBoxLayout(prev)
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)
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)
col.addWidget(prm, stretch=3)
scroll.setWidget(inner)
return scroll
def _build_title_group(self):
"""标题生成组(§17):提示词 + 标题模型下拉 + 保存 + 生成标题。"""
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)
model_row = QHBoxLayout()
model_row.addWidget(QLabel("标题模型"))
self._title_model_combo = QComboBox()
self._compact_combo(self._title_model_combo)
model_row.addWidget(self._title_model_combo, stretch=1)
v.addLayout(model_row)
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()
@@ -331,7 +422,6 @@ class AiOutfitPanel(QWidget):
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()
@@ -490,24 +580,29 @@ class AiOutfitPanel(QWidget):
self._rebuild_prompt_combo()
self._apply_prompt(name)
# 标题生成提示词(单份,§17.3)
self._title_prompt_edit.setPlainText(load_title_prompt())
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", ""))
self._fill_model_combo(self._model_combo, config.get("outfit_model", ""))
self._fill_model_combo(self._title_model_combo, config.get("title_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 _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))
@@ -527,6 +622,7 @@ class AiOutfitPanel(QWidget):
"outfit_quality": self._quality.currentText(),
"outfit_retry_failed": self._retry_failed_chk.isChecked(),
"outfit_prompt_name": self._current_prompt_name,
"title_model": self._title_model_combo.currentText() if self._models else "",
})
# -- left actions ---------------------------------------------------
@@ -537,7 +633,6 @@ class AiOutfitPanel(QWidget):
if path:
self._excel_edit.setText(path)
self._emit_config()
self._reload_sample_rows()
def _browse_output(self):
path = QFileDialog.getExistingDirectory(
@@ -574,7 +669,7 @@ class AiOutfitPanel(QWidget):
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
self._prompt_edit.setPlainText(self._saved_text)
def _store_current_text(self):
"""Save the editor text into the current template + persist to disk."""
@@ -684,54 +779,133 @@ class AiOutfitPanel(QWidget):
self._apply_prompt(self._prompts[min(idx, len(self._prompts) - 1)]["name"])
self._emit_config()
# -- inline prompt preview ------------------------------------------
# -- 标题生成(§17)-------------------------------------------------
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:
pid = t.product_id if (t.product_id and t.product_id.strip()) else "(无货号)"
self._sample_combo.addItem(
"第 {} 行 · {} · {}".format(t.row_index, pid, t.title), t)
else:
self._sample_combo.addItem("(选 Excel 后显示替换效果)", None)
self._sample_combo.blockSignals(False)
self._refresh_preview()
def _save_title_prompt(self, silent=False):
save_title_prompt(self._title_prompt_edit.toPlainText())
if not silent:
self.statusBar_message("标题提示词已保存")
def _reload_sample_rows(self):
"""Best-effort: read the chosen Excel to fill the sample dropdown.
def _selected_title_model_config(self):
if not self._models:
QMessageBox.warning(
self, "未配置模型",
"尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。")
return None
idx = self._title_model_combo.currentIndex()
if idx < 0 or idx >= len(self._models):
QMessageBox.warning(self, "未选择标题模型", "请先选择标题模型。")
return None
from services.ai_image_service import AiModelConfig, api_config_errors
data = self._models[idx]
errors = api_config_errors(data)
if errors:
QMessageBox.warning(self, "模型配置有误", ";".join(errors))
return None
return AiModelConfig.from_dict(data)
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"):
def _start_title(self):
if self._thread is not None or self._title_thread is not None:
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))
excel = self._excel_edit.text().strip()
if not excel:
QMessageBox.information(self, "提示", "请先选择 Excel 文件。")
return
model_config = self._selected_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:
self._preview_view.setPlainText(render_prompt(template, task, resolution))
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)
self._title_model_combo.setEnabled(not running and bool(self._models))
# -- switch info popups (user-only; §10.2) --------------------------
@@ -767,7 +941,7 @@ class AiOutfitPanel(QWidget):
# -- run control ----------------------------------------------------
def _start(self):
if self._thread is not None:
if self._thread is not None or self._title_thread is not None:
return
excel = self._excel_edit.text().strip()
if not excel:
@@ -856,11 +1030,14 @@ class AiOutfitPanel(QWidget):
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 _on_tasks_loaded(self, tasks):
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))
@@ -870,9 +1047,10 @@ class AiOutfitPanel(QWidget):
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))
# Preview sample rows are owned by read_all_rows (§10.3/§10.4); don't reset
# them to the run's task list (which is empty when the sheet is all 完成).
self._append_log("已加载 {} 行待处理任务".format(len(tasks)))
def _on_progress(self, completed, total, result):