diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index cfdaed6..a460413 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -697,6 +697,9 @@ class SuiteTaskState: settings: dict = field(default_factory=product_suite.default_suite_settings) current_job_ids: list = field(default_factory=list) show_history: bool = False + generation_job_ids: list = field(default_factory=list) + generation_mode: str = "batch" + generation_retry_job_id: int = None worker: object = None thread: object = None generation_run_token: str = "" @@ -1582,6 +1585,9 @@ class ProductSuiteTab(QWidget): state.project_binding_state = "" state.last_saved_prompt = "" state.current_job_ids = [] + state.generation_job_ids = [] + state.generation_mode = "batch" + state.generation_retry_job_id = None state.done = state.failed = state.total = 0 state.started_at = None if state is self._displayed_state: @@ -2517,12 +2523,22 @@ class ProductSuiteTab(QWidget): if state is None: return if state.generation_running(): + retrying = state.generation_mode == "retry" if state.generation_stop_requested: - self._status("正在停止当前套图任务", "warning") + self._status( + "正在停止当前图片重试" + if retrying + else "正在停止当前套图任务", + "warning", + ) return if self._confirm( - "停止生成套图", - "确认取消当前任务吗?已提交任务会在安全边界停止。", + "停止图片重试" if retrying else "停止生成套图", + ( + "确认停止当前图片重试吗?已提交任务会在安全边界停止。" + if retrying + else "确认取消当前任务吗?已提交任务会在安全边界停止。" + ), destructive=True, ): state.generation_stop_requested = True @@ -2533,14 +2549,21 @@ class ProductSuiteTab(QWidget): "stop_requested", ) self._apply_running_state(state) - self._status("已请求停止当前套图任务", "warning") + self._status( + "已请求停止当前图片重试" + if retrying + else "已请求停止当前套图任务", + "warning", + ) return self.start_generation(state) - def start_generation(self, state, specs=None): + def start_generation(self, state, specs=None, *, retry_job_id=None): if state.generation_running(): self._status("当前套图任务仍在生成", "warning") return False + retry_job_id = int(retry_job_id) if retry_job_id is not None else None + retrying = retry_job_id is not None if state is self._displayed_state: self._save_controls_to_state(state) template_text = None @@ -2595,12 +2618,16 @@ class ProductSuiteTab(QWidget): state.generation_run_token = run_token state.generation_stop_requested = False state.generation_terminal_streak = 0 + state.generation_job_ids = [] + state.generation_mode = "retry" if retrying else "batch" + state.generation_retry_job_id = retry_job_id state.done = 0 state.failed = 0 state.total = len(specs) state.started_at = time.monotonic() - state.current_job_ids = [] - state.show_history = False + if not retrying: + state.current_job_ids = [] + state.show_history = False self._generation_run_states[run_token] = state.key worker.progress.connect(self._on_generation_progress_signal) worker.finished.connect(self._on_generation_finished_signal) @@ -2611,17 +2638,27 @@ class ProductSuiteTab(QWidget): state, run_token, "started", - {"total": len(specs), "job_ids": 0}, + { + "total": len(specs), + "job_ids": 0, + "mode": state.generation_mode, + }, ) - if state is self._displayed_state: + if state is self._displayed_state and not retrying: self._loading = True try: self.history_button.setChecked(False) finally: self._loading = False + if state is self._displayed_state: self._apply_running_state(state) self._refresh_results(state) - self._status("商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), "info") + self._status( + "图片重试已开始;可切换到其他任务" + if retrying + else "商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), + "info", + ) return True def _generation_signal_token(self, payload=None): @@ -2651,7 +2688,7 @@ class ProductSuiteTab(QWidget): state.failed = int(payload.get("failed", state.failed) or 0) job_ids = payload.get("job_ids") if job_ids is not None: - state.current_job_ids = [int(job_id) for job_id in job_ids] + self._set_generation_job_ids(state, job_ids) state.generation_terminal_streak = 0 if state is self._displayed_state: self._refresh_results(state) @@ -2670,7 +2707,15 @@ class ProductSuiteTab(QWidget): {"has_error": True}, level="ERROR", ) - self._status("商品套图生成失败:%s" % _user_error(error), "danger") + self._status( + ( + "图片重试失败:%s" + if state.generation_mode == "retry" + else "商品套图生成失败:%s" + ) + % _user_error(error), + "danger", + ) @Slot(dict) def _on_generation_finished_signal(self, result): @@ -2678,6 +2723,8 @@ class ProductSuiteTab(QWidget): state = self._generation_state(token) if state is None: return + if result.get("job_ids") is not None: + self._set_generation_job_ids(state, result.get("job_ids")) self._log_generation_lifecycle( state, token, @@ -2779,7 +2826,7 @@ class ProductSuiteTab(QWidget): ) def _generation_job_ids(self, state): - job_ids = list(state.current_job_ids) + job_ids = list(state.generation_job_ids) if not job_ids and state.worker is not None: worker_job_ids = getattr(state.worker, "job_ids", []) if not isinstance(worker_job_ids, (list, tuple, set)): @@ -2789,9 +2836,30 @@ class ProductSuiteTab(QWidget): for job_id in list(worker_job_ids or []) ] if job_ids: - state.current_job_ids = job_ids + self._set_generation_job_ids(state, job_ids) return job_ids + def _set_generation_job_ids(self, state, job_ids): + normalized = [] + for job_id in job_ids or []: + value = int(job_id) + if value not in normalized: + normalized.append(value) + state.generation_job_ids = normalized + if state.generation_mode != "retry": + state.current_job_ids = list(normalized) + return + retry_job_id = state.generation_retry_job_id + current = [int(job_id) for job_id in state.current_job_ids] + if retry_job_id in current: + index = current.index(retry_job_id) + current[index:index + 1] = normalized + elif not state.show_history: + for job_id in normalized: + if job_id not in current: + current.append(job_id) + state.current_job_ids = current + def _generation_job_snapshot(self, state): job_ids = self._generation_job_ids(state) counts = { @@ -2865,6 +2933,7 @@ class ProductSuiteTab(QWidget): def _finalize_generation(self, state, run_token, result, *, source): if self._generation_state(run_token) is not state: return False + retrying = state.generation_mode == "retry" snapshot = self._generation_job_snapshot(state) if snapshot["job_ids"] and ( snapshot["all_terminal"] @@ -2890,6 +2959,8 @@ class ProductSuiteTab(QWidget): state.generation_run_token = "" state.generation_stop_requested = False state.generation_terminal_streak = 0 + state.generation_job_ids = [] + state.generation_retry_job_id = None state.worker = None state.thread = None state.done = success + failed + cancelled @@ -2912,6 +2983,7 @@ class ProductSuiteTab(QWidget): "cancelled": cancelled, "active": active, "elapsed_seconds": elapsed, + "mode": "retry" if retrying else "batch", }, level="WARNING" if active or result.get("ok") is False else "INFO", ) @@ -2923,39 +2995,76 @@ class ProductSuiteTab(QWidget): or "生成线程已结束,部分任务可稍后继续查询" ) if state is self._displayed_state: - self._message("商品套图生成未完整结束", _user_error(message)) + self._message( + "图片重试未完整结束" + if retrying + else "商品套图生成未完整结束", + _user_error(message), + ) else: self._status( - "套图任务%d生成未完整结束" % state.serial, + ( + "套图任务%d图片重试未完整结束" + if retrying + else "套图任务%d生成未完整结束" + ) + % state.serial, "danger", ) return True if stop_requested or cancelled: if state is self._displayed_state: - self._message( - "商品套图生成已停止", - "本轮共%d张:成功%d张,失败%d张,停止%d张;" - "已提交任务可稍后继续查询;总用时%d秒。" - % (total, success, failed, cancelled, elapsed), - icon=QMessageBox.Information, - ) + if retrying: + self._message( + "图片重试已停止", + "本次重试:成功%d张,失败%d张,停止%d张;" + "已提交任务可稍后继续查询;总用时%d秒。" + % (success, failed, cancelled, elapsed), + icon=QMessageBox.Information, + ) + else: + self._message( + "商品套图生成已停止", + "本轮共%d张:成功%d张,失败%d张,停止%d张;" + "已提交任务可稍后继续查询;总用时%d秒。" + % (total, success, failed, cancelled, elapsed), + icon=QMessageBox.Information, + ) self._status( - "商品套图生成已停止:成功%d张,失败%d张,停止%d张" + ( + "图片重试已停止:成功%d张,失败%d张,停止%d张" + if retrying + else "商品套图生成已停止:成功%d张,失败%d张,停止%d张" + ) % (success, failed, cancelled), "warning", ) return True if state is self._displayed_state: - self._message( - "商品套图生成完成", - "本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。" - % (total, success, failed, cancelled, elapsed), - icon=QMessageBox.Information, + if retrying: + self._message( + "图片重试成功" if success and not failed else "图片重试失败", + "本次重试:成功%d张,失败%d张,停止%d张;总用时%d秒。" + % (success, failed, cancelled, elapsed), + icon=QMessageBox.Information, + ) + else: + self._message( + "商品套图生成完成", + "本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。" + % (total, success, failed, cancelled, elapsed), + icon=QMessageBox.Information, + ) + if retrying: + self._status( + "图片重试成功" if success and not failed else "图片重试失败,请查看失败卡片", + "success" if success and not failed else "danger", + ) + else: + self._status( + "商品套图生成完成:成功%d张,失败%d张" % (success, failed), + "success", ) - self._status( - "商品套图生成完成:成功%d张,失败%d张" % (success, failed), - "success", - ) return True def _log_generation_lifecycle( @@ -3012,6 +3121,8 @@ class ProductSuiteTab(QWidget): self.generate_button.setText( "正在停止..." if state.generation_stop_requested + else "停止重试" + if state.generation_mode == "retry" else "停止生成" ) self.generate_button.setStyleSheet( @@ -3035,7 +3146,8 @@ class ProductSuiteTab(QWidget): return elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0 self.elapsed_label.setText( - "套图 %d/%d(%d秒) · 失败 %d" % ( + "%s %d/%d(%d秒) · 失败 %d" % ( + "重试" if state.generation_mode == "retry" else "套图", state.done, state.total, elapsed, @@ -3067,8 +3179,12 @@ class ProductSuiteTab(QWidget): return [] if state.show_history: return jobs - current = set(state.current_job_ids) - return [job for job in jobs if int(job.id) in current] + by_id = {int(job.id): job for job in jobs} + return [ + by_id[int(job_id)] + for job_id in state.current_job_ids + if int(job_id) in by_id + ] def _refresh_results(self, state): while self.result_grid.count(): @@ -3122,12 +3238,19 @@ class ProductSuiteTab(QWidget): if state.generation_running(): self._message("当前任务正在生成", "请等待当前生成结束或停止后再重试单张图片。") return + if str(getattr(job, "status", "") or "") not in { + "failed", + "expired", + "cancelled", + }: + self._status("当前图片无需重试", "warning") + return spec = { "source_asset_id": job.source_asset_id, "job_type": job.job_type, "prompt": job.prompt, } - self.start_generation(state, specs=[spec]) + self.start_generation(state, specs=[spec], retry_job_id=job.id) def _show_job_menu(self, job, global_position): menu = QMenu(self) diff --git a/docs/tasks/T-640.md b/docs/tasks/T-640.md index 7c57cb7..423603d 100644 --- a/docs/tasks/T-640.md +++ b/docs/tasks/T-640.md @@ -3,7 +3,7 @@ id: T-640 title: 商品套图单张失败重试保持当前轮结果 phase: 7 deps: [T-639] -status: TODO +status: DONE created: 2026-07-16 --- @@ -107,4 +107,7 @@ git diff --check ## 执行记录 -- 待执行。 +- 2026-07-16:`SuiteTaskState` 新增内存态 `generation_job_ids`、`generation_mode` 与 `generation_retry_job_id`。`generation_job_ids` 专门供当前活动请求的进度、停止、线程结束和终态看门狗使用;`current_job_ids` 只表示当前结果区的有效槽位,解决单张重试时旧成功 job 被错误纳入本次汇总的问题。未修改 SQLite schema。 +- 2026-07-16:失败、过期或停止卡片的「重试」改为显式单张重试模式。worker 仍新建 job 保留历史和计费记录;新 job ID 返回后会原位替换当前结果区中的旧失败 job,其他成功图片保持不变。当前结果改按 `current_job_ids` 顺序展示;历史视图继续展示原失败与每次重试记录,从历史发起重试时不会强制退出历史视图。 +- 2026-07-16:重试运行状态、停止确认、进度文案和完成弹窗改为「图片重试」语义;重试成功、失败、停止和未完整结束分别给中文反馈。正常整轮生成、停止、T-639 `run_token` 隔离与终态兜底保持原行为。 +- 2026-07-16:补齐当前轮原位替换、历史记录保留、活动快照只统计新 job、重试再次失败仍可继续重试、历史视图保持和真实 QThread 完整退出测试。相关 42 项测试通过;在不包含用户未提交默认提示词改名的临时隔离 worktree 中,全量 535 项 unittest 通过,Ruff、`compileall` 与 `git diff --check` 均通过。 diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index eecb8a4..a5b7b2c 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -856,6 +856,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): state.thread = mock.Mock() state.generation_run_token = "watchdog-run" state.current_job_ids = [job.id for job in jobs] + state.generation_job_ids = [job.id for job in jobs] state.total = len(jobs) state.started_at = time.monotonic() tab._generation_run_states["watchdog-run"] = state.key @@ -972,6 +973,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): state.thread = mock.Mock() state.generation_run_token = "thread-fallback" state.current_job_ids = [job.id] + state.generation_job_ids = [job.id] state.total = 1 state.started_at = time.monotonic() tab._generation_run_states["thread-fallback"] = state.key @@ -1127,6 +1129,260 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_failed_job_retry_replaces_current_slot_and_keeps_history(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, sources = self._create_project_with_assets(temp_dir, config, 1) + source = sources[0] + + success_path = os.path.join(temp_dir, "success.jpg") + retry_path = os.path.join(temp_dir, "retry.jpg") + self._write_image(success_path) + self._write_image(retry_path) + success_asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=success_path, + parent_asset_id=source.id, + path=config["db_path"], + ) + success_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + prompt="成功图", + path=config["db_path"], + ) + success_job = image_studio.update_job_status( + success_job.id, + "succeeded", + output_asset_id=success_asset.id, + path=config["db_path"], + ) + failed_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + prompt="失败图", + path=config["db_path"], + ) + failed_job = image_studio.update_job_status( + failed_job.id, + "failed", + error="上游生成失败", + path=config["db_path"], + ) + + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.current_job_ids = [success_job.id, failed_job.id] + tab._load_state(state) + messages = [] + tab._message = lambda title, message, **kwargs: messages.append( + (title, message) + ) + + def fake_run_jobs(jobs, **kwargs): + job = list(jobs)[0] + retry_asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=retry_path, + parent_asset_id=source.id, + path=config["db_path"], + ) + image_studio.update_job_status( + job.id, + "succeeded", + output_asset_id=retry_asset.id, + path=config["db_path"], + ) + return { + "total": 1, + "success": 1, + "failed": 0, + "cancelled": 0, + "jobs": [], + } + + with mock.patch( + "app.gui.workers.image_studio_generation.run_jobs", + side_effect=fake_run_jobs, + ): + tab.retry_job(failed_job) + generation_thread = state.thread + deadline = time.monotonic() + 3 + while state.worker is not None and time.monotonic() < deadline: + QTest.qWait(20) + self.app.processEvents() + while ( + generation_thread is not None + and generation_thread.isRunning() + and time.monotonic() < deadline + ): + QTest.qWait(20) + self.app.processEvents() + self.assertFalse(generation_thread.isRunning()) + + all_jobs = image_studio.list_jobs(project.id, path=config["db_path"]) + retry_jobs = [ + job + for job in all_jobs + if job.id not in {success_job.id, failed_job.id} + ] + self.assertEqual(1, len(retry_jobs)) + retry_job = retry_jobs[0] + self.assertEqual( + [success_job.id, retry_job.id], + state.current_job_ids, + ) + self.assertEqual([], state.generation_job_ids) + self.assertEqual( + [success_job.id, retry_job.id], + [job.id for job in tab._jobs_for_state(state)], + ) + self.assertEqual("图片重试成功", messages[-1][0]) + + state.show_history = True + history_ids = {job.id for job in tab._jobs_for_state(state)} + self.assertEqual( + {success_job.id, failed_job.id, retry_job.id}, + history_ids, + ) + + self.assert_removed(temp_dir) + + def test_retry_tracks_only_new_job_and_preserves_history_view(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, sources = self._create_project_with_assets(temp_dir, config, 1) + source = sources[0] + success_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + prompt="成功图", + path=config["db_path"], + ) + success_job = image_studio.update_job_status( + success_job.id, + "succeeded", + path=config["db_path"], + ) + failed_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + prompt="失败图", + path=config["db_path"], + ) + failed_job = image_studio.update_job_status( + failed_job.id, + "failed", + path=config["db_path"], + ) + retry_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + prompt="重试图", + path=config["db_path"], + ) + + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.current_job_ids = [success_job.id, failed_job.id] + state.generation_mode = "retry" + state.generation_retry_job_id = failed_job.id + state.total = 1 + + tab._set_generation_job_ids(state, [retry_job.id]) + + self.assertEqual( + [success_job.id, retry_job.id], + state.current_job_ids, + ) + self.assertEqual([retry_job.id], state.generation_job_ids) + snapshot = tab._generation_job_snapshot(state) + self.assertEqual(1, snapshot["job_ids"]) + self.assertEqual(1, snapshot["active"]) + + image_studio.update_job_status( + retry_job.id, + "failed", + error="重试仍失败", + path=config["db_path"], + ) + state.worker = mock.Mock() + state.thread = mock.Mock() + state.generation_run_token = "retry-failed" + state.started_at = time.monotonic() + tab._generation_run_states["retry-failed"] = state.key + messages = [] + tab._message = lambda title, message, **kwargs: messages.append( + (title, message) + ) + + self.assertTrue( + tab._finalize_generation( + state, + "retry-failed", + {"total": 1, "success": 0, "failed": 1}, + source="worker", + ) + ) + self.assertEqual("图片重试失败", messages[-1][0]) + retry_cards = [ + card + for card in tab.findChildren(SuiteResultCard) + if card.job.id == retry_job.id + ] + self.assertEqual(1, len(retry_cards)) + self.assertTrue( + any( + button.text() == "重试" + for button in retry_cards[0].findChildren(QPushButton) + ) + ) + + state.show_history = True + tab._load_state(state) + with mock.patch.object( + tab, + "_start_thread", + return_value=mock.Mock(), + ): + self.assertTrue( + tab.start_generation( + state, + specs=[ + { + "source_asset_id": source.id, + "job_type": failed_job.job_type, + "prompt": failed_job.prompt, + } + ], + retry_job_id=failed_job.id, + ) + ) + self.assertTrue(state.show_history) + self.assertTrue(tab.history_button.isChecked()) + state.worker = None + state.thread = None + state.generation_run_token = "" + + self.assert_removed(temp_dir) + def test_original_list_expands_without_internal_scrollbars(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)