From 81351c77b52303ad9d81f2c5f2b769ae2cdd588c Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 23 Jun 2026 14:30:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-outfit):=20=E8=AF=9D=E6=9C=AF=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E8=A1=8C=E5=BE=AE=E8=B0=83=20+=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=8F=92=E5=85=A5=E6=A0=87=E9=A2=98=20+=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E9=A2=84=E8=A7=88=E6=94=B9=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=20(=C2=A719.20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 话术组小改版(保留 {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 --- docs/11-ai-outfit.md | 46 ++++++++---- src/app/widgets/ai_outfit_panel.py | 111 +++++++++++++++++++++++++++-- tasks.md | 34 +++++++-- tests/test_ai_outfit_panel.py | 41 +++++++++++ 4 files changed, 210 insertions(+), 22 deletions(-) diff --git a/docs/11-ai-outfit.md b/docs/11-ai-outfit.md index 467346d..ecff493 100644 --- a/docs/11-ai-outfit.md +++ b/docs/11-ai-outfit.md @@ -162,9 +162,9 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 ## 7. 提示词 - 提示词模板存 `~/.cmbot/config/outfit_prompt.txt`,界面可查看/编辑/保存;点「开始」前自动保存一次。 -- 占位符:仅 `{title}`(标题);界面只提供「插入标题」按钮,把占位符插入光标处。货号(`product_id`)不进提示词——它只用于 Excel B 列读取与输出文件命名(`货号.jpg`),不影响 AI 生成内容。(`render_prompt` 仍会替换偶然出现的 `{product_id}`,保持兼容,但界面不再引导插入。) -- **「保存话术」按钮**:把当前编辑的模板原文(占位符原样保留,**不**存替换后的结果)经 `config_service` 写入 `outfit_prompt.txt`。用途是"改完先存、暂不开跑"并给用户明确反馈;与"开始前自动保存"并存、互为兜底(保留按钮是有意为之,对非技术用户更安心)。 -- ~~**最终提示词预览(内嵌、不弹窗)**~~ **(§17 已移除)**:原左栏话术编辑下方的只读预览区 + 样本行下拉,为给左栏让出「标题生成」组(§17)已整体移除。相关历史问题(§10.1 下拉撑宽、§10.3 样本行为空、§10.4 不清空样本)随之作废。 +- 占位符:仅 `{title}`(标题);界面提供「插入标题」按钮,把占位符插入光标处。**标题由用户经 `{title}` 自行放置(可前、可中、可省),程序不自动前置**——这样标题不会被焊死在固定位置,也不会与旧话术里的 `{title}` 重复。货号(`product_id`)不进提示词——只用于 Excel B 列读取与输出文件命名(`货号.jpg`)。(`render_prompt` 仍替换偶现的 `{product_id}` 以兼容,但界面不引导插入。) +- **「保存」按钮**(原「保存话术」改名、移到模板按钮行「重命名」之后):把当前编辑的模板原文(占位符原样保留,**不**存替换后的结果)经 `config_service` 写入当前模板。用途是"改完先存、暂不开跑";与"开始前自动保存"并存、互为兜底。 +- **最终提示词预览改为按需弹窗(§7.3)**:左栏要同时容纳「标题生成」组与「穿搭话术」组,纵向空间紧张(常驻预览会撑到 ~690px > 视口 ~664px、逼出滚动),故预览不做常驻面板,改成话术编辑框下方一个「预览最终提示词」按钮 → 弹**非模态**小窗,内含数据行下拉 + 只读的替换后提示词(含 §7.1 输出要求)。要"边改边看"就开着弹窗,话术/分辨率/数据行变化时刷新。 - 缺标题占位符时开始前弹窗询问是否继续。 - 默认话术方向(穿搭/上身,区别于旧项目的主图场景,需另调):人物上身实穿、保留衣服款式/版型/颜色/印花、合适身材与场景、电商可用、默认纯净不加促销牛皮癣。 @@ -182,8 +182,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 - 仅「参考解析度」动态(= 分辨率设置),其余 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 话术模板(多套命名话术) @@ -198,8 +198,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 **界面**(「穿搭生成话术」组,套印花页范式) -- 编辑框**上方一行**:「穿搭生成话术」标签 + 模板下拉;下一行放「新建」「另存为」「重命名」「删除」。 -- 编辑框**下方**:保留「插入标题」「保存」。 +- 编辑框**上方一行**:「穿搭生成话术」标签 + 模板下拉;下一行模板按钮 `新建 / 另存为 / 重命名 / 保存 / 删除`(**「保存」从原编辑框下方移到此行「重命名」之后、改名自「保存话术」**)。 +- 编辑框**下方一行**:「插入标题」+「预览最终提示词」(弹窗,§7.3)。 - 左栏约 360px,5 个模板按钮偏多 → 实现时可排两行或用紧凑小按钮。 **行为** @@ -207,10 +207,32 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 - **切换 / 重命名 / 删除 / 新建 / 另存为之前**:若编辑框与当前套已存文本不同(脏)→ 弹「是否保存当前修改?」(保存 / 不保存 / 取消;取消则下拉还原到原选项)。 - 保存=覆盖当前套;另存为=存为新名并选中;重命名=改当前套名;删除=二次确认,删后选邻近,**不可删到 0**。 - 名字**唯一**(重名拒绝或自动加序号)。 -- 记住上次所选;生成/预览用编辑框当前文本(`render_prompt` + §7.1 尾巴不变);「开始生成」前把当前编辑存回所选套(沿用现有自动存逻辑)。 +- 记住上次所选;生成用编辑框当前文本(`render_prompt(text, task, resolution)` + §7.1 尾巴不变);「开始生成」前把当前编辑存回所选套(沿用现有自动存逻辑)。 **解耦**:核心 `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. 并发、限速、重试、停止 复用旧项目的限速、重试、心跳与温和停止策略(`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 行顺序,状态用 完成 / 失败 / 生成中 / 待处理 / 跳过 徽章。 - **右栏(设置 + 运行,~400px)**:生成设置标题与「重试上次失败的行」同排;图片并发数 / 新请求间隔 / 单任务冷却 / 失败重试 / 分辨率 / JPG 质量保持 **3 列 × 2 行**;`AI 模型` 标签与下拉框同排;本次进度 + 统计(完成 / 失败 / 待处理);开始生成 / 停止生成同排;导出失败清单 / 打开输出目录同排;节省的纵向空间给实时日志(含 §8 心跳行)。 @@ -262,7 +284,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 ### 10.1 已知问题:左栏内容被中间面板裁掉(下拉框撑宽) -> **§17 更新:已作废。** 撑宽元凶「样本行下拉」随预览一并移除(§17);保留本节仅作历史记录。标题模型改为「配置定名」、不放界面(§17.3),左栏也不再有新下拉。 +> **§17/§7.3 更新。** 撑宽元凶「样本行下拉」随预览移除(§17);§7.3 把数据行下拉**收进预览弹窗**(不在左栏),故本节裁切问题在左栏不再出现。弹窗内的下拉仍建议套 `_compact_combo` 的 elide 策略以防长项撑宽弹窗。标题模型仍不放界面(§17.3 配置定名)。 **现象**:「2 AI 穿搭」左栏组件右侧显示不完整,像被中间面板遮挡了一部分。 @@ -296,7 +318,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 ### 10.3 已知问题:全表生成完成后,预览样本行下拉为空 -> **§17 更新:已作废。** 预览与样本行下拉已移除(§17),本问题不复存在。`read_all_rows` 仍保留——它现在是「标题生成」的行来源(§17)。保留本节作历史记录。 +> **§17/§7.3 更新。** 一度随预览移除(§17),§7.3 把「数据行下拉」请回**预览弹窗**。本节结论**重新生效**:弹窗内数据行下拉必须用 `read_all_rows`(状态无关)填,否则整表完成后又会变空。`read_all_rows` 同时是「标题生成」的行来源(§17)。 **现象**:选中的 Excel 整表生成成功后,再次选中该文件,「最终生成要求预览」的样本行下拉只显示「(选 Excel 后显示替换效果)」,无可选行。 @@ -310,7 +332,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 ### 10.4 改进:整表无待处理行时的提示 + 生成不清空预览样本 -> **§17 更新:改进 2 作废**(预览样本已移除,§17);**改进 1(无待处理行提示)继续有效**。 +> **§17/§7.3 更新。** 改进 1(无待处理行提示)一直有效;改进 2 一度作废,§7.3 把数据行下拉请回**预览弹窗**后**重新生效**:`_on_tasks_loaded` 不动弹窗的数据行下拉,下拉只由 `read_all_rows` 维护,与运行解耦。 承接 §10.3。整表都「完成」后点「开始生成」时当前体验不清晰,两项改进: diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index ea3a43f..73dcd1e 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, + QDialog, QInputDialog, QDoubleSpinBox, QFileDialog, @@ -216,6 +217,79 @@ class _TitleWorker(QObject): 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 # --------------------------------------------------------------------------- @@ -235,6 +309,7 @@ class AiOutfitPanel(QWidget): 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) @@ -348,8 +423,10 @@ class AiOutfitPanel(QWidget): 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._prompt_delete)): + ("重命名", self._prompt_rename), ("保存", self._save_prompt), + ("删除", self._prompt_delete)): b = QPushButton(text) if text == "删除": b.setObjectName("aiPromptDeleteBtn") @@ -358,15 +435,16 @@ class AiOutfitPanel(QWidget): 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) - save_btn = QPushButton("保存话术") - save_btn.clicked.connect(self._save_prompt) - prow.addWidget(save_btn) + preview_btn = QPushButton("预览最终提示词") + preview_btn.clicked.connect(self._open_preview) + prow.addWidget(preview_btn) pv.addLayout(prow) col.addWidget(prm, stretch=3) @@ -419,6 +497,8 @@ class AiOutfitPanel(QWidget): 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() @@ -631,6 +711,8 @@ class AiOutfitPanel(QWidget): 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( @@ -643,6 +725,27 @@ class AiOutfitPanel(QWidget): 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): diff --git a/tasks.md b/tasks.md index 0a2f464..8f20987 100644 --- a/tasks.md +++ b/tasks.md @@ -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 不覆盖) -- [ ] `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 配置定名)」 -- [ ] `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)用例 -- [ ] 验证:相关单测 + 全套 py37 通过;离屏冒烟:`title_model` 命中 `ai_models.json` → 生成走该模型;改名/缺失 → 明确报错 +- [x] `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`:`_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] `config_service.py`:`DEFAULT_CONFIG["title_model"]` 默认值由 `""` 改为 `"GPT-5.5 文本"`;注释改为「标题模型名字(对应 ai_models.json 的 name,§17.3 配置定名)」 +- [x] `docs/ai_models.sample.json`:补一条 `name="GPT-5.5 文本"`、`api_type=chat`、`model=gpt-5.5`(占位 key)的文本模型示例,作为标题模型样板 +- [x] 测试:更新 `test_ai_outfit_panel.py`(断言**无**标题模型下拉、`_title_model_combo` 不存在);新增 `_resolve_title_model_config` 命中/缺失(报错返回 None)用例 +- [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 通过;离屏冒烟:点「预览最终提示词」弹窗 → 选数据行标题替换、切分辨率输出要求刷新;左栏不因预览常驻而需要滚动 \ No newline at end of file diff --git a/tests/test_ai_outfit_panel.py b/tests/test_ai_outfit_panel.py index 91af09a..333030d 100644 --- a/tests/test_ai_outfit_panel.py +++ b/tests/test_ai_outfit_panel.py @@ -97,6 +97,47 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): 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): """title_model 名字命中 ai_models.json → 返回 AiModelConfig。""" from services.ai_image_service import AiModelConfig