feat(ai-studio): keep workspace usable during generation

This commit is contained in:
chengma
2026-07-13 09:38:30 +08:00
parent 14282306f6
commit 42fe233ed9
4 changed files with 198 additions and 25 deletions
+59 -22
View File
@@ -303,6 +303,7 @@ class ImageStudioTab(QWidget):
self._running_worker = None
self._running_thread = None
self._operation_running = False
self._operation_kind = ""
self._worker_error_handled = False
self._download_open_after = {}
self._thumbnail_loader = thumbnail_loader or image_studio_images.ThumbnailLoader()
@@ -811,6 +812,9 @@ class ImageStudioTab(QWidget):
self.project_table.resizeColumnsToContents()
def pull_main_images(self, checked=False):
if self._operation_running:
self._status("AI工场当前任务仍在进行,暂不能拉取蝦皮主图", "warning")
return
alias = str(self.account_combo.currentData() or "").strip()
item_id = self.item_id_edit.text().strip()
if not alias or not item_id:
@@ -845,7 +849,7 @@ class ImageStudioTab(QWidget):
worker.log.connect(self._append_log)
worker.finished.connect(self._on_pull_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场拉取蝦皮主图")
self._start_worker(worker, "AI工场拉取蝦皮主图", operation_kind="pull_images")
self._append_log("[AI工场] 拉取蝦皮主图开始")
def _local_original_count(self, project):
@@ -1006,6 +1010,9 @@ class ImageStudioTab(QWidget):
self._status(f"已打开项目文件夹:{opened}", "success")
def delete_current_project(self, checked=False):
if self._operation_running:
self._status("AI工场当前任务仍在进行,暂不能删除项目", "warning")
return
project = self.current_project
if project is None:
self._message("未选择商品", "请先从商品列表选择要删除的项目。")
@@ -1518,10 +1525,13 @@ class ImageStudioTab(QWidget):
self._refresh_project_summary()
def _update_project_action_buttons(self):
enabled = self.current_project is not None and not self._operation_running
self.open_folder_button.setEnabled(enabled)
has_project = self.current_project is not None
self.open_folder_button.setEnabled(
has_project and (not self._operation_running or self._is_generation_operation())
)
self.delete_project_button.setEnabled(
enabled
has_project
and not self._operation_running
and not self._has_original_download_activity(
getattr(self.current_project, "id", None)
)
@@ -1661,6 +1671,9 @@ class ImageStudioTab(QWidget):
self.remove_asset(asset_id)
def remove_asset(self, asset_id):
if self._operation_running:
self._status("AI工场当前任务仍在进行,暂不能移除照片", "warning")
return
if not self._confirm("移除照片", "只从AI工场照片池移除记录,不删除本地图片文件。"):
return
try:
@@ -1767,6 +1780,9 @@ class ImageStudioTab(QWidget):
self._status(f"保存AI工场草稿提示词失败:{exc}", "warning")
def start_generation(self, checked=False):
if self._operation_running:
self._status("AI工场当前任务仍在进行,请等待完成或停止后再生成", "warning")
return
if self.current_project is None:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
return
@@ -1801,7 +1817,7 @@ class ImageStudioTab(QWidget):
worker.log.connect(self._append_log)
worker.finished.connect(self._on_generation_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场生成图片")
self._start_worker(worker, "AI工场生成图片", operation_kind="generate")
def stop_generation(self, checked=False):
if self._running_worker is not None and hasattr(self._running_worker, "cancel"):
@@ -1810,6 +1826,9 @@ class ImageStudioTab(QWidget):
self._status("AI工场生成已请求停止", "warning")
def resume_generation_jobs(self, checked=False):
if self._operation_running:
self._status("AI工场当前任务仍在进行,请等待完成或停止后再继续查询", "warning")
return
if self.current_project is None:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
return
@@ -1836,9 +1855,12 @@ class ImageStudioTab(QWidget):
worker.log.connect(self._append_log)
worker.finished.connect(self._on_generation_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场继续查询")
self._start_worker(worker, "AI工场继续查询", operation_kind="resume")
def export_selections(self, checked=False):
if self._operation_running:
self._status("AI工场当前任务仍在进行,暂不能导出终选", "warning")
return
if self.current_project is None:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
return
@@ -1863,7 +1885,7 @@ class ImageStudioTab(QWidget):
worker.log.connect(self._append_log)
worker.finished.connect(self._on_export_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场导出终选")
self._start_worker(worker, "AI工场导出终选", operation_kind="export")
def _export_existing_mode(self, parent_dir, main_count, detail_count):
try:
@@ -1963,14 +1985,18 @@ class ImageStudioTab(QWidget):
self._on_worker_failed(-1, summary.get("error") or fallback_message)
return True
def _start_worker(self, worker, thread_name):
def _start_worker(self, worker, thread_name, *, operation_kind="exclusive"):
if self._running_thread is not None:
self._status("AI工场上一项任务仍在收尾,请稍候", "warning")
return False
thread = run_worker(worker, thread_name=thread_name, start=False)
thread.finished.connect(lambda: self._forget_running_thread(thread))
self._running_worker = worker
self._running_thread = thread
self._worker_error_handled = False
self._set_running(True)
self._set_running(True, operation_kind=operation_kind)
thread.start()
return True
def _finish_worker(self):
self._set_running(False)
@@ -1980,27 +2006,38 @@ class ImageStudioTab(QWidget):
self._running_thread = None
self._running_worker = None
def _set_running(self, running):
def _is_generation_operation(self):
return self._operation_running and self._operation_kind in {"generate", "resume"}
def _set_running(self, running, *, operation_kind="exclusive"):
self._operation_running = bool(running)
self._operation_kind = str(operation_kind or "exclusive") if running else ""
generation_running = self._is_generation_operation()
self.pull_images_button.setEnabled(not running)
self.account_combo.setEnabled(not running)
self.item_id_edit.setEnabled(not running)
self.project_table.setEnabled(not running)
self.original_grid.setEnabled(not running)
self.pool_grid.setEnabled(not running)
self.main_selection_list.setEnabled(not running)
self.detail_selection_list.setEnabled(not running)
self.template_combo.setEnabled(not running)
self.template_new_button.setEnabled(not running)
self.template_rename_button.setEnabled(not running)
self.template_save_button.setEnabled(not running)
self.template_delete_button.setEnabled(not running)
self.prompt_edit.setEnabled(not running)
self.job_type_combo.setEnabled(not running)
self.count_spin.setEnabled(not running)
self.aspect_combo.setEnabled(not running)
self.pool_grid.setEnabled(not running or generation_running)
self.main_selection_list.setEnabled(not running or generation_running)
self.detail_selection_list.setEnabled(not running or generation_running)
self.template_combo.setEnabled(not running or generation_running)
self.template_new_button.setEnabled(not running or generation_running)
self.template_rename_button.setEnabled(not running or generation_running)
self.template_save_button.setEnabled(not running or generation_running)
self.template_delete_button.setEnabled(not running or generation_running)
self.prompt_edit.setEnabled(not running or generation_running)
self.job_type_combo.setEnabled(not running or generation_running)
self.count_spin.setEnabled(not running or generation_running)
self.aspect_combo.setEnabled(not running or generation_running)
self.start_button.setEnabled(not running)
self.resume_button.setEnabled(not running)
self.stop_button.setEnabled(running)
self.export_button.setEnabled(not running)
if generation_running:
self.generation_guard_label.setText("本轮任务已固定;此处修改将在下一轮生成时生效。")
else:
self.generation_guard_label.setText("不会在失败后静默切换生成来源。")
self._update_project_action_buttons()
def _set_account_combo(self, alias):
+1 -1
View File
@@ -200,7 +200,7 @@
- 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。
- 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。
- 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。
- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI;运行中禁用项目切换、模板编辑、源图选择、终选拖放和生成设置,停止为协作式停止。
- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI。拉主图、导出等互斥 worker 运行时禁用项目切换和工作区写操作;生图或继续查询任务运行时仍只允许一个耗时 worker,但当前项目的照片池浏览/源图选择、主图和详情图终选拖放、提示词模板编辑以及下一轮类型/数量/比例设置保持可用。这些修改只作用于下一轮,当前已提交任务的参数不变;此时仍禁用项目切换、原图区下载、拉主图、开始生成、继续查询、导出和删除项目,停止为协作式停止。
- 「导出终选」可在主图/详情图未满目标数量时导出当前终选;主图和详情图总数为 0 时阻断。用户选择导出父目录后,程序在其下创建商品 ID 子目录,按终选顺序转码为 `商品ID_主图_1.jpg`、`商品ID_详情图_1.jpg`,透明图铺白底输出真正 JPEG。
- 商品目录已存在时只提供三选:覆盖本软件导出的图片(仅删除匹配当前商品命名规则的旧主图/详情图,保留用户其它文件)、新建带时间目录、取消;不提供合并,也不递归清空用户目录。
- 导出前会预检所有终选源文件和图片解码,先写 staging,转码失败不创建商品目录、不留下半套新图;成功后中文提示实际目录和主图/详情图数量,并提供打开目录。
+12 -2
View File
@@ -3,7 +3,7 @@ id: T-612
title: AI工场生图运行态分级,保留当前项目非冲突操作
phase: 7
deps: [T-594, T-604, T-608, T-609]
status: TODO
status: DONE
created: 2026-07-13
---
@@ -92,4 +92,14 @@ git diff --check
## 执行记录
(完成后记录实现、验证命令与人工验收结果。)
- 2026-07-13:完成 AI工场生图运行态分级。
- `app/gui/tabs/image_studio.py`:为通用 worker 增加 `operation_kind`,区分生成/继续查询、拉主图、导出等操作;生成/继续查询期间保留照片池、终选、提示词模板/编辑和下一轮参数,仍禁用项目切换、原图区下载、拉图、开始/继续查询、导出、删除。
- `app/gui/tabs/image_studio.py`:生成 worker 创建时固定使用当时的源图、提示词和参数;界面显示「本轮任务已固定;此处修改将在下一轮生成时生效」。打开项目文件夹在生成期间仍可用。
- `app/gui/tabs/image_studio.py`:`_start_worker()` 在上一 QThread 尚未发出 `thread.finished` 时拒绝启动下一 worker;拉图、生成、继续查询、导出、删除和移除照片增加运行中保护,避免程序化调用绕过禁用按钮。
- `docs/routes.md`:更新⑥ AI工场的生成运行态边界。
- `tests/test_gui.py`:新增生成态控件清单/参数快照测试,以及线程收尾前禁止启动下一 worker 的测试。
- 验证:
- 主工作区定向 GUI 测试通过:4 项,覆盖生成运行态、参数快照和线程生命周期。
- 主工作区 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 通过。
- 主工作区全量 unittest 因既有未提交默认提示词文件变更失败 3 项(`papa1` 被乱码默认模板替代),不属于本任务。
- 干净 worktree `D:\chengma\cmshopee-t612-verify` 仅应用本任务差异后通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(389 项)及 `git diff --check`。
+126
View File
@@ -996,6 +996,132 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_image_studio_generation_keeps_current_project_workspace_available(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
db.init_db(cfg["db_path"])
project = image_studio.create_or_get_project(
account_alias="alias-a",
account_slug="alias_a",
item_id="51100639510",
path=cfg["db_path"],
)
source = image_studio.add_asset(
project.id,
"original",
local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")),
path=cfg["db_path"],
)
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
self.addCleanup(tab.close)
tab._select_project(project.id)
tab._select_source_asset(source)
tab.prompt_edit.setPlainText("第一轮提示词")
class FakeStudioWorker:
def __init__(self):
self.progress = DummySignal()
self.log = DummySignal()
self.finished = DummySignal()
self.failed = DummySignal()
def cancel(self):
pass
fake_worker = FakeStudioWorker()
fake_thread = FakeThread()
with mock.patch(
"app.gui.tabs.image_studio.ImageStudioGenerateJobsWorker",
return_value=fake_worker,
) as worker_factory, mock.patch(
"app.gui.tabs.image_studio.run_worker", return_value=fake_thread
):
tab.start_generation()
self.assertEqual("generate", tab._operation_kind)
self.assertTrue(tab.pool_grid.isEnabled())
self.assertTrue(tab.main_selection_list.isEnabled())
self.assertTrue(tab.detail_selection_list.isEnabled())
self.assertTrue(tab.template_combo.isEnabled())
self.assertTrue(tab.prompt_edit.isEnabled())
self.assertTrue(tab.job_type_combo.isEnabled())
self.assertTrue(tab.count_spin.isEnabled())
self.assertTrue(tab.aspect_combo.isEnabled())
self.assertTrue(tab.open_folder_button.isEnabled())
self.assertFalse(tab.start_button.isEnabled())
self.assertFalse(tab.resume_button.isEnabled())
self.assertFalse(tab.pull_images_button.isEnabled())
self.assertFalse(tab.project_table.isEnabled())
self.assertFalse(tab.original_grid.isEnabled())
self.assertFalse(tab.export_button.isEnabled())
self.assertFalse(tab.delete_project_button.isEnabled())
self.assertIn("下一轮生成", tab.generation_guard_label.text())
tab.prompt_edit.setPlainText("下一轮提示词")
tab.count_spin.setValue(3)
tab.job_type_combo.setCurrentIndex(tab.job_type_combo.findData("detail"))
tab.aspect_combo.setCurrentIndex(tab.aspect_combo.findData("3:4"))
args = worker_factory.call_args.args
self.assertEqual("第一轮提示词", args[2])
self.assertEqual(4, args[3])
self.assertEqual("main", worker_factory.call_args.kwargs["job_type"])
self.assertEqual("1:1", worker_factory.call_args.kwargs["aspect_ratio"])
tab._finish_worker()
self.assertTrue(tab.start_button.isEnabled())
self.assertEqual("", tab._operation_kind)
self.assertIn("不会在失败后", tab.generation_guard_label.text())
fake_thread.finished.emit()
self.assertIsNone(tab._running_worker)
self.assertIsNone(tab._running_thread)
self.assert_removed(temp_dir)
def test_image_studio_worker_waits_for_thread_cleanup_before_next_start(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
self.addCleanup(tab.close)
first_worker = SimpleNamespace()
second_worker = SimpleNamespace()
first_thread = FakeThread()
second_thread = FakeThread()
with mock.patch(
"app.gui.tabs.image_studio.run_worker",
side_effect=[first_thread, second_thread],
) as run_worker:
self.assertTrue(
tab._start_worker(
first_worker,
"AI工场测试线程",
operation_kind="generate",
)
)
tab._finish_worker()
self.assertFalse(
tab._start_worker(
second_worker,
"AI工场测试线程",
operation_kind="generate",
)
)
self.assertEqual(1, run_worker.call_count)
first_thread.finished.emit()
self.assertTrue(
tab._start_worker(
second_worker,
"AI工场测试线程",
operation_kind="generate",
)
)
self.assertEqual(2, run_worker.call_count)
second_thread.finished.emit()
self.assert_removed(temp_dir)
def test_image_studio_worker_thread_is_kept_until_thread_finished(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)