feat: show title generation wait timer

This commit is contained in:
2026-07-04 16:47:12 +08:00
parent 58343ce4d6
commit b5f289fddd
3 changed files with 135 additions and 12 deletions
+74 -2
View File
@@ -7,8 +7,9 @@
通过 Qt signal 回主线程刷新 UI(子线程不直接碰控件)。
"""
import logging
import time
from PySide6.QtCore import QObject, QSize, Qt, QThread, QUrl, Signal
from PySide6.QtCore import QObject, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
@@ -322,6 +323,12 @@ 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._title_wait_started_at = 0.0
self._title_wait_limit_seconds = 0
self._title_wait_generation = 0
self._title_wait_timer = QTimer(self)
self._title_wait_timer.setInterval(1000)
self._title_wait_timer.timeout.connect(self._refresh_title_wait_label)
self._preview_dialog = None # 最终提示词预览弹窗(§7.3,按需)
self._row_to_table = {} # excel row_index -> table row
self._failures = [] # list of OutfitResult (failed)
@@ -471,7 +478,15 @@ class AiOutfitPanel(QWidget):
"""
box = QGroupBox()
v = QVBoxLayout(box)
v.addWidget(QLabel("标题生成提示词"))
header = QHBoxLayout()
header.addWidget(QLabel("标题生成提示词"))
header.addStretch(1)
self._title_wait_label = QLabel("")
self._title_wait_label.setObjectName("titleWaitLabel")
self._title_wait_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._title_wait_label.setVisible(False)
header.addWidget(self._title_wait_label)
v.addLayout(header)
self._title_prompt_edit = QPlainTextEdit()
self._title_prompt_edit.setMinimumHeight(110)
v.addWidget(self._title_prompt_edit)
@@ -987,6 +1002,7 @@ class AiOutfitPanel(QWidget):
self._title_fail = 0
self._progress.setValue(0)
self._log.clear()
self._start_title_wait_timer(self._title_wait_limit_for_model(model_config))
self._title_worker = _TitleWorker(excel, model_config, prompt)
self._title_thread = QThread(self)
@@ -1009,6 +1025,10 @@ class AiOutfitPanel(QWidget):
self._append_log("标题生成:已加载 {} 行".format(len(tasks)))
def _on_title_progress(self, completed, total, result):
if self._title_wait_timer.isActive():
self._title_wait_timer.stop()
self._set_title_wait_text("写入中 {}/{}".format(completed, total))
row = self._row_to_table.get(result.task.row_index)
if row is not None:
if result.success:
@@ -1025,6 +1045,10 @@ class AiOutfitPanel(QWidget):
def _on_title_finished(self, success, fail):
self._set_title_running(False)
if fail:
self._finish_title_wait("完成 {} 条,失败 {}".format(success, fail))
else:
self._finish_title_wait("完成 {} 条".format(success))
msg = "标题生成结束:成功 {},失败 {}".format(success, fail)
self._append_log(msg)
self._reload_after_titles()
@@ -1037,10 +1061,13 @@ class AiOutfitPanel(QWidget):
def _on_title_failed(self, message):
self._set_title_running(False)
self._finish_title_wait("生成失败")
self._append_log("标题生成无法开始:" + message)
QMessageBox.warning(self, "无法开始", message)
def _cleanup_title_thread(self):
if self._title_wait_timer.isActive():
self._reset_title_wait_label()
self._title_thread = None
self._title_worker = None
@@ -1064,6 +1091,51 @@ class AiOutfitPanel(QWidget):
self._start_btn.setEnabled(not running) # mutually exclusive with 开始生成
self._excel_edit.setEnabled(not running)
def _title_wait_limit_for_model(self, model_config):
"""Return the title request wait ceiling shown in the label (§17.10)."""
try:
override = int(getattr(model_config, "timeout_seconds", 0) or 0)
except (TypeError, ValueError):
override = 0
if override > 0:
return override
from services.ai_image_service import resolution_timeout
return resolution_timeout("4K")
def _start_title_wait_timer(self, limit_seconds):
self._title_wait_generation += 1
self._title_wait_limit_seconds = max(1, int(limit_seconds or 0))
self._title_wait_started_at = time.monotonic()
self._refresh_title_wait_label()
self._title_wait_timer.start()
def _refresh_title_wait_label(self):
elapsed = max(0, int(time.monotonic() - self._title_wait_started_at))
self._set_title_wait_text("等待中 {} / {}".format(
self._format_wait_seconds(elapsed),
self._format_wait_seconds(self._title_wait_limit_seconds)))
def _set_title_wait_text(self, text):
self._title_wait_label.setText(str(text))
self._title_wait_label.setVisible(bool(text))
def _finish_title_wait(self, text):
self._title_wait_timer.stop()
self._set_title_wait_text(text)
token = self._title_wait_generation
QTimer.singleShot(4000, lambda: self._reset_title_wait_label(token))
def _reset_title_wait_label(self, token=None):
if token is not None and token != self._title_wait_generation:
return
self._title_wait_timer.stop()
self._title_wait_label.clear()
self._title_wait_label.setVisible(False)
def _format_wait_seconds(self, seconds):
total = max(0, int(seconds or 0))
return "{:02d}:{:02d}".format(total // 60, total % 60)
# -- switch info popups (user-only; §10.2) --------------------------
def _on_resolution_activated(self, _index):
+11 -10
View File
@@ -1602,16 +1602,17 @@
任务:
- [ ] `ai_outfit_panel.py`:在「标题生成提示词」label 右侧新增等待计时 QLabel,默认隐藏或空白,不挤压编辑框
- [ ] 点击「生成标题」后启动 1 秒计时器,显示 `等待中 mm:ss / MM:SS`
- [ ] 最大等待默认取标题生成 600 秒;若当前标题模型 `timeout_seconds > 0`,显示该覆盖值
- [ ] 请求返回并进入写回阶段时,文案切换为 `写入中 已完成/总数` 或同等清晰状态;完成后短暂停留并复位
- [ ] 失败、超时、停止、线程清理时停止计时并复位,避免残留旧状态
- [ ] 不把该显示做成真实百分比进度,不影响右栏本次进度条和明细表刷新
- [ ] 测试:离屏断言点击「生成标题」后计时 label 变为等待中;mock 超时值覆盖;完成/失败后复位
- [x] `ai_outfit_panel.py`:在「标题生成提示词」label 右侧新增等待计时 QLabel,默认隐藏或空白,不挤压编辑框
- [x] 点击「生成标题」后启动 1 秒计时器,显示 `等待中 mm:ss / MM:SS`
- [x] 最大等待默认取标题生成 600 秒;若当前标题模型 `timeout_seconds > 0`,显示该覆盖值
- [x] 请求返回并进入写回阶段时,文案切换为 `写入中 已完成/总数` 或同等清晰状态;完成后短暂停留并复位
- [x] 失败、超时、停止、线程清理时停止计时并复位,避免残留旧状态
- [x] 不把该显示做成真实百分比进度,不影响右栏本次进度条和明细表刷新
- [x] 测试:离屏断言点击「生成标题」后计时 label 变为等待中;mock 超时值覆盖;完成/失败后复位
验收:
- [ ] 长时间标题生成期间,用户能看到已等待时间和最长等待时间
- [ ] 计时文案不暗示模型内部完成百分比
- [ ] 标题生成和图片生成互斥、原有进度/明细行为不回退
- [x] 长时间标题生成期间,用户能看到已等待时间和最长等待时间
- [x] 计时文案不暗示模型内部完成百分比
- [x] 标题生成和图片生成互斥、原有进度/明细行为不回退
- [x] 验证:`python -m py_compile src/app/widgets/ai_outfit_panel.py tests/test_ai_outfit_panel.py`、`python tests/test_ai_outfit_panel.py`、离屏启动主窗口通过;全套 `python -m unittest discover -s tests` 仅失败于既有未提交 `packaging/default_config/ai_models.json` 含非空 `api_key`,与本任务无关
+50
View File
@@ -3,6 +3,7 @@ import os
import shutil
import sys
import tempfile
import time
import unittest
from pathlib import Path
@@ -114,6 +115,55 @@ class TestAiOutfitPanelDefaults(unittest.TestCase):
self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT)
def test_title_wait_label_defaults_hidden(self):
panel = self._panel()
self.assertTrue(hasattr(panel, "_title_wait_label"))
self.assertTrue(panel._title_wait_label.isHidden())
self.assertEqual(panel._title_wait_label.text(), "")
def test_title_wait_timer_formats_elapsed_and_limit(self):
panel = self._panel()
panel._start_title_wait_timer(600)
panel._title_wait_started_at = time.monotonic() - 95
panel._refresh_title_wait_label()
self.assertFalse(panel._title_wait_label.isHidden())
self.assertEqual(panel._title_wait_label.text(), "等待中 01:35 / 10:00")
self.assertTrue(panel._title_wait_timer.isActive())
panel._reset_title_wait_label()
def test_title_wait_limit_uses_model_timeout_override(self):
from services.ai_image_service import AiModelConfig
panel = self._panel()
default_cfg = AiModelConfig(
url="https://r/v1/chat/completions", model="m", api_key="sk",
api_type="chat", timeout_seconds=0, connect_timeout_seconds=30)
override_cfg = AiModelConfig(
url="https://r/v1/chat/completions", model="m", api_key="sk",
api_type="chat", timeout_seconds=120, connect_timeout_seconds=30)
self.assertEqual(panel._title_wait_limit_for_model(default_cfg), 600)
self.assertEqual(panel._title_wait_limit_for_model(override_cfg), 120)
def test_title_progress_switches_wait_label_to_writing(self):
from core.models import OutfitTask, TitleResult
panel = self._panel()
task = OutfitTask(row_index=2, title="", product_id="SKU", garment_path="a.png")
panel._populate_table([task])
panel._start_title_wait_timer(600)
panel._on_title_progress(
1, 3, TitleResult(task=task, success=True, generated_title="新标题"))
self.assertFalse(panel._title_wait_timer.isActive())
self.assertFalse(panel._title_wait_label.isHidden())
self.assertEqual(panel._title_wait_label.text(), "写入中 1/3")
panel._reset_title_wait_label()
def test_prompt_buttons_save_row_insert_title_and_preview(self):
"""§19.20: 按钮行含「保存」;编辑框下方保留「插入标题」+「预览最终提示词」。"""
from PySide6.QtWidgets import QPushButton