feat: show title generation wait timer
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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`,与本任务无关
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user