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
+107 -4
View File
@@ -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):