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>
This commit is contained in:
2026-06-23 14:30:57 +08:00
co-authored by Claude Opus 4.8
parent c47e9dee95
commit 81351c77b5
4 changed files with 210 additions and 22 deletions
+34 -12
View File
@@ -162,9 +162,9 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
## 7. 提示词 ## 7. 提示词
- 提示词模板存 `~/.cmbot/config/outfit_prompt.txt`,界面可查看/编辑/保存;点「开始」前自动保存一次。 - 提示词模板存 `~/.cmbot/config/outfit_prompt.txt`,界面可查看/编辑/保存;点「开始」前自动保存一次。
- 占位符:仅 `{title}`(标题);界面只提供「插入标题」按钮,把占位符插入光标处。货号(`product_id`)不进提示词——它只用于 Excel B 列读取与输出文件命名(`货号.jpg`),不影响 AI 生成内容。(`render_prompt` 仍会替换偶然出现的 `{product_id}`,保持兼容,但界面不再引导插入。) - 占位符:仅 `{title}`(标题);界面提供「插入标题」按钮,把占位符插入光标处。**标题由用户经 `{title}` 自行放置(可前、可中、可省),程序不自动前置**——这样标题不会被焊死在固定位置,也不会与旧话术里的 `{title}` 重复。货号(`product_id`)不进提示词——只用于 Excel B 列读取与输出文件命名(`货号.jpg`)。(`render_prompt` 仍替换偶现的 `{product_id}` 以兼容,但界面不引导插入。)
- **「保存话术」按钮**:把当前编辑的模板原文(占位符原样保留,**不**存替换后的结果)经 `config_service` 写入 `outfit_prompt.txt`。用途是"改完先存、暂不开跑"并给用户明确反馈;与"开始前自动保存"并存、互为兜底(保留按钮是有意为之,对非技术用户更安心)。 - **「保存」按钮**(原「保存话术」改名、移到模板按钮行「重命名」之后):把当前编辑的模板原文(占位符原样保留,**不**存替换后的结果)经 `config_service` 写入当前模板。用途是"改完先存、暂不开跑";与"开始前自动保存"并存、互为兜底。
- ~~**最终提示词预览(内嵌、不弹窗)**~~ **(§17 已移除)**:原左栏话术编辑下方的只读预览区 + 样本行下拉,为给左栏让出「标题生成」组(§17)已整体移除。相关历史问题(§10.1 下拉撑宽、§10.3 样本行为空、§10.4 不清空样本)随之作废。 - **最终提示词预览改为按需弹窗(§7.3)**:左栏要同时容纳「标题生成」组与「穿搭话术」组,纵向空间紧张(常驻预览会撑到 ~690px > 视口 ~664px、逼出滚动),故预览不做常驻面板,改成话术编辑框下方一个「预览最终提示词」按钮 → 弹**非模态**小窗,内含数据行下拉 + 只读的替换后提示词(含 §7.1 输出要求)。要"边改边看"就开着弹窗,话术/分辨率/数据行变化时刷新。
- 缺标题占位符时开始前弹窗询问是否继续。 - 缺标题占位符时开始前弹窗询问是否继续。
- 默认话术方向(穿搭/上身,区别于旧项目的主图场景,需另调):人物上身实穿、保留衣服款式/版型/颜色/印花、合适身材与场景、电商可用、默认纯净不加促销牛皮癣。 - 默认话术方向(穿搭/上身,区别于旧项目的主图场景,需另调):人物上身实穿、保留衣服款式/版型/颜色/印花、合适身材与场景、电商可用、默认纯净不加促销牛皮癣。
@@ -182,8 +182,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- 仅「参考解析度」动态(= 分辨率设置),其余 3 行固定;**简体文案**(与软件一致)。 - 仅「参考解析度」动态(= 分辨率设置),其余 3 行固定;**简体文案**(与软件一致)。
- **始终自动附加**(同旧项目,不做开关)。 - **始终自动附加**(同旧项目,不做开关)。
- 由 `core/ai_outfit.render_prompt(template, task, resolution)` 实现:`resolution` 为空时不附加(保持纯函数单测兼容);`generate_outfit_image` 生成时传入当前分辨率。 - 由 `core/ai_outfit.render_prompt(template, task, resolution)` 实现:`resolution` 为空时不附加(保持纯函数单测兼容);`generate_outfit_image` 生成时传入当前分辨率。**不做标题自动前置**——标题由话术里的 `{title}` 决定(§7)。
- **左栏「最终提示词预览」必须包含这段**(带当前选中分辨率),否则预览与实际发送不一致;分辨率下拉变化时预览同步刷新。 - **预览弹窗必须包含这段**(带当前选中分辨率),否则预览与实际发送不一致;分辨率下拉变化时弹窗同步刷新(§7.3)。
### 7.2 话术模板(多套命名话术) ### 7.2 话术模板(多套命名话术)
@@ -198,8 +198,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
**界面**(「穿搭生成话术」组,套印花页范式) **界面**(「穿搭生成话术」组,套印花页范式)
- 编辑框**上方一行**:「穿搭生成话术」标签 + 模板下拉;下一行放「新建」「另存为」「重命名」「删除」。 - 编辑框**上方一行**:「穿搭生成话术」标签 + 模板下拉;下一行模板按钮 `新建 / 另存为 / 重命名 / 保存 / 删除`(**「保存」从原编辑框下方移到此行「重命名」之后、改名自「保存话术」**)。
- 编辑框**下方**:保留「插入标题」「保存」。 - 编辑框**下方一行**:「插入标题」+「预览最终提示词」(弹窗,§7.3)。
- 左栏约 360px,5 个模板按钮偏多 → 实现时可排两行或用紧凑小按钮。 - 左栏约 360px,5 个模板按钮偏多 → 实现时可排两行或用紧凑小按钮。
**行为** **行为**
@@ -207,10 +207,32 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- **切换 / 重命名 / 删除 / 新建 / 另存为之前**:若编辑框与当前套已存文本不同(脏)→ 弹「是否保存当前修改?」(保存 / 不保存 / 取消;取消则下拉还原到原选项)。 - **切换 / 重命名 / 删除 / 新建 / 另存为之前**:若编辑框与当前套已存文本不同(脏)→ 弹「是否保存当前修改?」(保存 / 不保存 / 取消;取消则下拉还原到原选项)。
- 保存=覆盖当前套;另存为=存为新名并选中;重命名=改当前套名;删除=二次确认,删后选邻近,**不可删到 0**。 - 保存=覆盖当前套;另存为=存为新名并选中;重命名=改当前套名;删除=二次确认,删后选邻近,**不可删到 0**。
- 名字**唯一**(重名拒绝或自动加序号)。 - 名字**唯一**(重名拒绝或自动加序号)。
- 记住上次所选;生成/预览用编辑框当前文本(`render_prompt` + §7.1 尾巴不变);「开始生成」前把当前编辑存回所选套(沿用现有自动存逻辑)。 - 记住上次所选;生成用编辑框当前文本(`render_prompt(text, task, resolution)` + §7.1 尾巴不变);「开始生成」前把当前编辑存回所选套(沿用现有自动存逻辑)。
**解耦**:核心 `render_prompt(text, task, resolution)` 不动;"用哪段文本"由面板的模板选择决定。 **解耦**:核心 `render_prompt(text, task, resolution)` 不动;"用哪段文本"由面板的模板选择决定。
### 7.3 最终提示词预览(按需弹窗)
左栏要同时放「标题生成」组与「穿搭话术」组,纵向吃紧(常驻预览 ~690px > 视口 ~664px → 逼出滚动)。故预览**不常驻**,改成话术编辑框下方一个「预览最终提示词」按钮 → 弹**非模态** `QDialog`:
```
[预览最终提示词] ← 话术编辑框下方按钮
↓ 点开
┌─ 最终提示词预览 ─────────────┐
│ 数据行: [第 2 行 · 纯棉短袖 ▾] │
│ ┌─ 只读 ──────────────────┐ │
│ │ 为 纯棉短袖 生成… │ │ ← {title} 换成选中行标题
│ │ 批量生成输出要求: │ │ ← §7.1,随分辨率刷新
│ │ - 参考解析度: 1K … │ │
│ └────────────────────────┘ │
└────────────────────────────┘
```
- **非模态**:弹窗开着时仍可编辑左栏话术;话术文本 / 分辨率 / 数据行下拉任一变化 → 弹窗内只读预览实时刷新(`render_prompt(话术, 选中行, 分辨率)`)。要"边改边看"就开着它。
- **数据行下拉在弹窗内**(不占左栏):复用 `excel_service.read_all_rows(excel)`(状态无关,整表完成后仍可选;§10.3 的 `read_all_rows` 因此保留)。未选 Excel / 无有效行 → 显示带占位符的话术原文 + 提示;缺 `{title}` 占位符时在预览顶部提示。
- **零常驻高度**:左栏只剩两个编辑框、不滚动;想验证替换效果时才弹窗。
- 预览只读,不改话术本身;标题仍由话术里的 `{title}` 决定(不自动前置,§7)。
## 8. 并发、限速、重试、停止 ## 8. 并发、限速、重试、停止
复用旧项目的限速、重试、心跳与温和停止策略(`docs/旧ai穿搭项目.md` §5.3),但并发语义改为更适合「一行一个目录」的模型: 复用旧项目的限速、重试、心跳与温和停止策略(`docs/旧ai穿搭项目.md` §5.3),但并发语义改为更适合「一行一个目录」的模型:
@@ -250,7 +272,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
三栏布局: 三栏布局:
- **左栏(数据源 + 标题/话术创作,~360px)**:`Excel` / `输出` 各为**行内一行**(标签 + 路径 + 浏览,省纵向空间);数据源概览(共 N 行 / 完成 / 待处理 / 失败);**「标题生成」组(§17)在上**:标题生成提示词编辑 +「保存」+「生成标题」(**无标题模型下拉**,模型由 `title_model` 配置定名,§17.3);**「穿搭生成话术」组在下**:话术编辑 +「保存话术」+「插入标题」+ 多套模板下拉(§7.2)。**最终提示词预览已移除(§17)**。图片 AI 模型下拉在右栏;标题模型不放界面。 - **左栏(数据源 + 标题/话术创作,~360px)**:`Excel` / `输出` 各为**行内一行**(标签 + 路径 + 浏览,省纵向空间);数据源概览(共 N 行 / 完成 / 待处理 / 失败);**「标题生成」组(§17)在上**:标题生成提示词编辑 +「保存」+「生成标题」(**无标题模型下拉**,模型由 `title_model` 配置定名,§17.3);**「穿搭生成话术」组在下**:模板下拉 + 按钮行 `新建/另存为/重命名/保存/删除` + 话术编辑框 + 下方「插入标题」「预览最终提示词」(预览为弹窗,数据行下拉在弹窗内,§7.3)。图片 AI 模型下拉在右栏;标题模型不放界面。
- **中栏(结果 + 明细)**:顶部「最近结果」缩略图条——**只展示已完成的人物效果图**,新图自动加到最左、首图标「最新」,单击看大图、右键开所在文件夹;下方「处理明细」表(行 / 标题 / 货号 / 衣服图 / 状态 / 结果或原因),按 Excel 行顺序,状态用 完成 / 失败 / 生成中 / 待处理 / 跳过 徽章。 - **中栏(结果 + 明细)**:顶部「最近结果」缩略图条——**只展示已完成的人物效果图**,新图自动加到最左、首图标「最新」,单击看大图、右键开所在文件夹;下方「处理明细」表(行 / 标题 / 货号 / 衣服图 / 状态 / 结果或原因),按 Excel 行顺序,状态用 完成 / 失败 / 生成中 / 待处理 / 跳过 徽章。
- **右栏(设置 + 运行,~400px)**:生成设置标题与「重试上次失败的行」同排;图片并发数 / 新请求间隔 / 单任务冷却 / 失败重试 / 分辨率 / JPG 质量保持 **3 列 × 2 行**;`AI 模型` 标签与下拉框同排;本次进度 + 统计(完成 / 失败 / 待处理);开始生成 / 停止生成同排;导出失败清单 / 打开输出目录同排;节省的纵向空间给实时日志(含 §8 心跳行)。 - **右栏(设置 + 运行,~400px)**:生成设置标题与「重试上次失败的行」同排;图片并发数 / 新请求间隔 / 单任务冷却 / 失败重试 / 分辨率 / JPG 质量保持 **3 列 × 2 行**;`AI 模型` 标签与下拉框同排;本次进度 + 统计(完成 / 失败 / 待处理);开始生成 / 停止生成同排;导出失败清单 / 打开输出目录同排;节省的纵向空间给实时日志(含 §8 心跳行)。
@@ -262,7 +284,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
### 10.1 已知问题:左栏内容被中间面板裁掉(下拉框撑宽) ### 10.1 已知问题:左栏内容被中间面板裁掉(下拉框撑宽)
> **§17 更新:已作废。** 撑宽元凶「样本行下拉」随预览一并移除(§17);保留本节仅作历史记录。标题模型改为「配置定名」、不放界面(§17.3),左栏也不再有新下拉。 > **§17/§7.3 更新。** 撑宽元凶「样本行下拉」随预览移除(§17);§7.3 把数据行下拉**收进预览弹窗**(不在左栏),故本节裁切问题在左栏不再出现。弹窗内的下拉仍建议套 `_compact_combo` 的 elide 策略以防长项撑宽弹窗。标题模型仍不放界面(§17.3 配置定名)。
**现象**:「2 AI 穿搭」左栏组件右侧显示不完整,像被中间面板遮挡了一部分。 **现象**:「2 AI 穿搭」左栏组件右侧显示不完整,像被中间面板遮挡了一部分。
@@ -296,7 +318,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
### 10.3 已知问题:全表生成完成后,预览样本行下拉为空 ### 10.3 已知问题:全表生成完成后,预览样本行下拉为空
> **§17 更新:已作废。** 预览与样本行下拉已移除(§17),本问题不复存在。`read_all_rows` 仍保留——它现在是「标题生成」的行来源(§17)。保留本节作历史记录。 > **§17/§7.3 更新。** 一度随预览移除(§17),§7.3 把「数据行下拉」请回**预览弹窗**。本节结论**重新生效**:弹窗内数据行下拉必须用 `read_all_rows`(状态无关)填,否则整表完成后又会变空。`read_all_rows` 同时是「标题生成」的行来源(§17)。
**现象**:选中的 Excel 整表生成成功后,再次选中该文件,「最终生成要求预览」的样本行下拉只显示「(选 Excel 后显示替换效果)」,无可选行。 **现象**:选中的 Excel 整表生成成功后,再次选中该文件,「最终生成要求预览」的样本行下拉只显示「(选 Excel 后显示替换效果)」,无可选行。
@@ -310,7 +332,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
### 10.4 改进:整表无待处理行时的提示 + 生成不清空预览样本 ### 10.4 改进:整表无待处理行时的提示 + 生成不清空预览样本
> **§17 更新:改进 2 作废**(预览样本已移除,§17);**改进 1(无待处理行提示)继续有效**。 > **§17/§7.3 更新。** 改进 1(无待处理行提示)一直有效;改进 2 一度作废,§7.3 把数据行下拉请回**预览弹窗**后**重新生效**:`_on_tasks_loaded` 不动弹窗的数据行下拉,下拉只由 `read_all_rows` 维护,与运行解耦。
承接 §10.3。整表都「完成」后点「开始生成」时当前体验不清晰,两项改进: 承接 §10.3。整表都「完成」后点「开始生成」时当前体验不清晰,两项改进:
+107 -4
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDialog,
QInputDialog, QInputDialog,
QDoubleSpinBox, QDoubleSpinBox,
QFileDialog, QFileDialog,
@@ -216,6 +217,79 @@ class _TitleWorker(QObject):
self.finished.emit(success, fail) 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 # Main panel
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -235,6 +309,7 @@ class AiOutfitPanel(QWidget):
self._title_worker = None self._title_worker = None
self._title_fail = 0 # running failure count for title stats self._title_fail = 0 # running failure count for title stats
self._title_model_name = "" # configured 标题模型 name (§17.3 配置定名) self._title_model_name = "" # configured 标题模型 name (§17.3 配置定名)
self._preview_dialog = None # 最终提示词预览弹窗(§7.3,按需)
self._row_to_table = {} # excel row_index -> table row self._row_to_table = {} # excel row_index -> table row
self._failures = [] # list of OutfitResult (failed) self._failures = [] # list of OutfitResult (failed)
self._last_resolution = "" # for "value actually changed" check (§10.2) self._last_resolution = "" # for "value actually changed" check (§10.2)
@@ -348,8 +423,10 @@ class AiOutfitPanel(QWidget):
prompt_header.addWidget(self._prompt_combo, stretch=1) prompt_header.addWidget(self._prompt_combo, stretch=1)
pv.addLayout(prompt_header) pv.addLayout(prompt_header)
trow = QHBoxLayout() trow = QHBoxLayout()
# 「保存」插在「重命名」「删除」之间(§7.2,原编辑框下方的「保存话术」上移改名)
for text, slot in (("新建", self._prompt_new), ("另存为", self._prompt_save_as), for text, slot in (("新建", self._prompt_new), ("另存为", self._prompt_save_as),
("重命名", self._prompt_rename), ("删除", self._prompt_delete)): ("重命名", self._prompt_rename), ("保存", self._save_prompt),
("删除", self._prompt_delete)):
b = QPushButton(text) b = QPushButton(text)
if text == "删除": if text == "删除":
b.setObjectName("aiPromptDeleteBtn") b.setObjectName("aiPromptDeleteBtn")
@@ -358,15 +435,16 @@ class AiOutfitPanel(QWidget):
pv.addLayout(trow) pv.addLayout(trow)
self._prompt_edit = QPlainTextEdit() self._prompt_edit = QPlainTextEdit()
self._prompt_edit.setMinimumHeight(150) self._prompt_edit.setMinimumHeight(150)
self._prompt_edit.textChanged.connect(self._refresh_preview_dialog)
pv.addWidget(self._prompt_edit) pv.addWidget(self._prompt_edit)
prow = QHBoxLayout() prow = QHBoxLayout()
insert_title_btn = QPushButton("插入标题") insert_title_btn = QPushButton("插入标题")
insert_title_btn.clicked.connect( insert_title_btn.clicked.connect(
lambda: self._prompt_edit.insertPlainText("{title}")) lambda: self._prompt_edit.insertPlainText("{title}"))
prow.addWidget(insert_title_btn) prow.addWidget(insert_title_btn)
save_btn = QPushButton("保存话术") preview_btn = QPushButton("预览最终提示词")
save_btn.clicked.connect(self._save_prompt) preview_btn.clicked.connect(self._open_preview)
prow.addWidget(save_btn) prow.addWidget(preview_btn)
pv.addLayout(prow) pv.addLayout(prow)
col.addWidget(prm, stretch=3) col.addWidget(prm, stretch=3)
@@ -419,6 +497,8 @@ class AiOutfitPanel(QWidget):
self._retry_count.setRange(0, 10) self._retry_count.setRange(0, 10)
self._resolution = QComboBox() self._resolution = QComboBox()
self._resolution.addItems(_RESOLUTIONS) 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) # activated = user click only; programmatic sets won't pop (docs/11 §10.2)
self._resolution.activated.connect(self._on_resolution_activated) self._resolution.activated.connect(self._on_resolution_activated)
self._quality = QComboBox() self._quality = QComboBox()
@@ -631,6 +711,8 @@ class AiOutfitPanel(QWidget):
if path: if path:
self._excel_edit.setText(path) self._excel_edit.setText(path)
self._emit_config() self._emit_config()
if self._preview_dialog is not None:
self._preview_dialog.reload_rows()
def _browse_output(self): def _browse_output(self):
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
@@ -643,6 +725,27 @@ class AiOutfitPanel(QWidget):
self._store_current_text() self._store_current_text()
self.statusBar_message("话术已保存") 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)------------------------------------------------ # -- 话术模板(§7.2)------------------------------------------------
def _rebuild_prompt_combo(self): def _rebuild_prompt_combo(self):
+28 -6
View File
@@ -1323,9 +1323,31 @@
任务: 任务:
- [ ] `ai_outfit_panel.py`:`_build_title_group` 去掉「标题模型」下拉(`_title_model_combo` 及其 `_compact_combo`/填充/`_set_title_running` 里的禁用);`apply_config` 不再填标题下拉、`_emit_config` 不再写 `title_model`(值由配置/手动维护,UI 不覆盖) - [x] `ai_outfit_panel.py`:`_build_title_group` 去掉「标题模型」下拉(`_title_model_combo` 及其 `_compact_combo`/填充/`_set_title_running` 里的禁用);`apply_config` 不再填标题下拉、`_emit_config` 不再写 `title_model`(值由配置/手动维护,UI 不覆盖)
- [ ] `ai_outfit_panel.py`:`_selected_title_model_config()` 改为 `_resolve_title_model_config()`——读 `app_config.title_model` 名字,在 `self._models` 里按 `name` 查;命中校验 `api_config_errors` 后返回 `AiModelConfig`;找不到/未配置时 `QMessageBox.warning` 明确报错(提示加文本模型并设 `title_model`)。`_start_title` 改用它;面板需持有当前 `title_model` 值(`apply_config` 存一份 `self._title_model_name`) - [x] `ai_outfit_panel.py`:`_selected_title_model_config()` 改为 `_resolve_title_model_config()`——读 `app_config.title_model` 名字,在 `self._models` 里按 `name` 查;命中校验 `api_config_errors` 后返回 `AiModelConfig`;找不到/未配置时 `QMessageBox.warning` 明确报错(提示加文本模型并设 `title_model`)。`_start_title` 改用它;面板需持有当前 `title_model` 值(`apply_config` 存一份 `self._title_model_name`)
- [ ] `config_service.py`:`DEFAULT_CONFIG["title_model"]` 默认值由 `""` 改为 `"GPT-5.5 文本"`;注释改为「标题模型名字(对应 ai_models.json 的 name,§17.3 配置定名)」 - [x] `config_service.py`:`DEFAULT_CONFIG["title_model"]` 默认值由 `""` 改为 `"GPT-5.5 文本"`;注释改为「标题模型名字(对应 ai_models.json 的 name,§17.3 配置定名)」
- [ ] `docs/ai_models.sample.json`:补一条 `name="GPT-5.5 文本"`、`api_type=chat`、`model=gpt-5.5`(占位 key)的文本模型示例,作为标题模型样板 - [x] `docs/ai_models.sample.json`:补一条 `name="GPT-5.5 文本"`、`api_type=chat`、`model=gpt-5.5`(占位 key)的文本模型示例,作为标题模型样板
- [ ] 测试:更新 `test_ai_outfit_panel.py`(断言**无**标题模型下拉、`_title_model_combo` 不存在);新增 `_resolve_title_model_config` 命中/缺失(报错返回 None)用例 - [x] 测试:更新 `test_ai_outfit_panel.py`(断言**无**标题模型下拉、`_title_model_combo` 不存在);新增 `_resolve_title_model_config` 命中/缺失(报错返回 None)用例
- [ ] 验证:相关单测 + 全套 py37 通过;离屏冒烟:`title_model` 命中 `ai_models.json` → 生成走该模型;改名/缺失 → 明确报错 - [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:`title_model` 命中 `ai_models.json` → 生成走该模型;改名/缺失 → 明确报错
### 19.20 穿搭话术:按钮行微调 +「插入标题」保留 + 预览改弹窗 — docs/11 §7 / §7.3 / §10
前置阅读:
- `docs/11-ai-outfit.md`(§7 提示词、§7.1 输出要求、§7.2 多套模板、§7.3 预览弹窗、§10 界面)
- `src/core/ai_outfit.py`(`render_prompt`/`build_output_requirements`)
- `src/app/widgets/ai_outfit_panel.py`(`_build_left` 话术组、`_prompt_edit`、模板按钮行、`_save_prompt`/`_store_current_text`、`_start`/`_OutfitWorker`、§19.18 删掉的样本下拉/预览逻辑可参考)
- `tests/test_ai_outfit.py`(`render_prompt` 既有用例,**不改 render_prompt,保持原样**)
背景:
话术组小改版(用户最终确认):①「保存话术」→「保存」、移到模板按钮行「重命名」之后(行变 `新建/另存为/重命名/保存/删除`);② **保留「插入标题」**+ `{title}` 占位符(标题不自动前置——用户自定位置、不与旧话术重复);③ 「最终提示词预览」因左栏空间紧张(标题生成组 + 话术组并存)**改为按需弹窗**:编辑框下方「预览最终提示词」按钮 → 非模态 `QDialog`,内含数据行下拉 + 只读替换后提示词(含 §7.1 输出要求)。`render_prompt` 与 `DEFAULT_OUTFIT_PROMPT` **不改**。
任务:
- [x] `ai_outfit_panel.py` 模板按钮行:在「重命名」与「删除」之间插入「保存」(连 `_save_prompt`);删掉编辑框下方原「保存话术」按钮;**保留**编辑框下方「插入标题」按钮,旁边新增「预览最终提示词」按钮
- [x] `ai_outfit_panel.py` 预览弹窗(新 `_OutfitPreviewDialog` 或方法):非模态 `QDialog`,含数据行下拉(`_compact_combo` + `read_all_rows(excel)` 填,状态无关)+ 只读 `QPlainTextEdit`;内容 = `render_prompt(话术正文, 选中行, 当前分辨率)`;缺 `{title}` 时顶部提示;未选 Excel/无行 → 显示话术原文 + 提示
- [x] `ai_outfit_panel.py` 弹窗联动:话术 `textChanged` / 分辨率 `currentIndexChanged` / 数据行下拉变化 → 刷新弹窗预览(仅弹窗存在时);弹窗用 `_reload_sample_rows` 思路在打开时/选 Excel 后填行
- [x] `ai_outfit_panel.py` 生成:保持现有 `_start` 的「缺 `{title}` 弹窗询问是否继续」(标题靠占位符);不引入自动前置
- [x] 测试:面板离屏断言——按钮行含「保存」、编辑框下方仍有「插入标题」、有「预览最终提示词」按钮;打开预览弹窗后选数据行/改分辨率,预览文本 = `render_prompt` 结果(含输出要求);`render_prompt` 既有用例保持绿(不改该函数)
- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:点「预览最终提示词」弹窗 → 选数据行标题替换、切分辨率输出要求刷新;左栏不因预览常驻而需要滚动
+41
View File
@@ -97,6 +97,47 @@ class TestAiOutfitPanelDefaults(unittest.TestCase):
self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT) self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT)
def test_prompt_buttons_save_row_insert_title_and_preview(self):
"""§19.20: 按钮行含「保存」;编辑框下方保留「插入标题」+「预览最终提示词」。"""
from PySide6.QtWidgets import QPushButton
panel = self._panel()
panel.apply_config(dict(DEFAULT_CONFIG))
texts = [b.text() for b in panel.findChildren(QPushButton)]
self.assertIn("保存", texts)
self.assertIn("插入标题", texts)
self.assertIn("预览最终提示词", texts)
self.assertNotIn("保存话术", texts) # 旧按钮已改名上移
def test_preview_dialog_renders_selected_row(self):
"""§7.3: 预览弹窗按选中数据行替换 {title} 并附加输出要求。"""
from openpyxl import Workbook
from app.widgets.ai_outfit_panel import _OutfitPreviewDialog
xlsx = self.tmp / "rows.xlsx"
wb = Workbook(); ws = wb.active
ws.append(["标题", "货号", "原始图片路径", "结果", "状态", "原因"])
ws.append(["纯棉短袖", "TY001", r"D:\img\a.png", "", "", ""])
wb.save(str(xlsx)); wb.close()
panel = self._panel()
panel.apply_config(dict(DEFAULT_CONFIG))
panel._excel_edit.setText(str(xlsx))
panel._prompt_edit.setPlainText("为 {title} 生成")
panel._set_combo(panel._resolution, "1K")
dialog = _OutfitPreviewDialog(panel) # __init__ fills rows + refresh
# Row dropdown picked up the valid row; pick it.
self.assertEqual(dialog._combo.count(), 1)
dialog._combo.setCurrentIndex(0)
text = dialog._view.toPlainText()
self.assertTrue(text.startswith("为 纯棉短袖 生成"))
self.assertIn("批量生成输出要求", text)
self.assertIn("参考解析度:1K", text)
dialog.deleteLater()
def test_find_title_model_resolves_by_config_name(self): def test_find_title_model_resolves_by_config_name(self):
"""title_model 名字命中 ai_models.json → 返回 AiModelConfig。""" """title_model 名字命中 ai_models.json → 返回 AiModelConfig。"""
from services.ai_image_service import AiModelConfig from services.ai_image_service import AiModelConfig