feat(gui): replace AI studio with product suite

This commit is contained in:
chengma
2026-07-14 09:53:13 +08:00
parent fb873aae90
commit bc115ba0d7
27 changed files with 3453 additions and 81 deletions
+14
View File
@@ -299,6 +299,7 @@ CREATE TABLE IF NOT EXISTS image_studio_projects (
target_main_count INTEGER NOT NULL DEFAULT 9, target_main_count INTEGER NOT NULL DEFAULT 9,
target_detail_count INTEGER NOT NULL DEFAULT 12, target_detail_count INTEGER NOT NULL DEFAULT 12,
draft_prompt TEXT, draft_prompt TEXT,
suite_settings_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -478,6 +479,7 @@ def init_db(path=None, conn=None) -> None:
_ensure_batch_delete_columns(database) _ensure_batch_delete_columns(database)
_ensure_task_image_task_columns(database) _ensure_task_image_task_columns(database)
_ensure_task_cover_reset_columns(database) _ensure_task_cover_reset_columns(database)
_ensure_image_studio_project_suite_columns(database)
_ensure_image_studio_job_recovery_columns(database) _ensure_image_studio_job_recovery_columns(database)
@@ -505,6 +507,18 @@ def _ensure_task_cover_reset_columns(database):
database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT")
def _ensure_image_studio_project_suite_columns(database):
columns = {
row["name"]
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
}
if "suite_settings_json" not in columns:
database.execute(
"ALTER TABLE image_studio_projects "
"ADD COLUMN suite_settings_json TEXT NOT NULL DEFAULT '{}'"
)
def _ensure_image_studio_job_recovery_columns(database): def _ensure_image_studio_job_recovery_columns(database):
columns = { columns = {
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
+4
View File
@@ -29,6 +29,9 @@ if QT_IMPORT_ERROR is None:
ImageStudioGenerateJobsWorker, ImageStudioGenerateJobsWorker,
ImageStudioPullImagesWorker, ImageStudioPullImagesWorker,
ImageStudioResumeJobsWorker, ImageStudioResumeJobsWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteImportImagesWorker,
WriteBackWorker, WriteBackWorker,
) )
from .tabs.accounts import AccountDialog, AccountsTab from .tabs.accounts import AccountDialog, AccountsTab
@@ -36,6 +39,7 @@ if QT_IMPORT_ERROR is None:
from .tabs.collect import CollectTab from .tabs.collect import CollectTab
from .tabs.generate import GenerateTab from .tabs.generate import GenerateTab
from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab
from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab
from .tabs.settings import SettingsTab from .tabs.settings import SettingsTab
from .main_window import MainWindow from .main_window import MainWindow
from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker
+3 -4
View File
@@ -7,7 +7,7 @@ from .tabs.accounts import AccountsTab
from .tabs.apply import ApplyTab from .tabs.apply import ApplyTab
from .tabs.collect import CollectTab from .tabs.collect import CollectTab
from .tabs.generate import GenerateTab from .tabs.generate import GenerateTab
from .tabs.image_studio import ImageStudioTab from .tabs.product_suite import ProductSuiteTab
from .tabs.settings import SettingsTab from .tabs.settings import SettingsTab
from .widgets import * from .widgets import *
@@ -166,13 +166,12 @@ class MainWindow(QMainWindow):
ai_models_path=self.ai_models_path, ai_models_path=self.ai_models_path,
status_callback=self.show_status, status_callback=self.show_status,
) )
if title == "⑥ AI工场": if title == "⑥ 商品套图":
return ImageStudioTab( return ProductSuiteTab(
db_path=self.db_path, db_path=self.db_path,
config=self.config, config=self.config,
config_path=self.config_path, config_path=self.config_path,
status_callback=self.show_status, status_callback=self.show_status,
prompts_dir=appconfig.image_studio_prompts_dir(self.config),
) )
raise ValueError(f"未知主界面模块:{title}") raise ValueError(f"未知主界面模块:{title}")
File diff suppressed because it is too large Load Diff
+1
View File
@@ -60,6 +60,7 @@ TAB_TITLES = [
"③ 更新蝦皮", "③ 更新蝦皮",
"④ 账号管理", "④ 账号管理",
"⑤ 设置", "⑤ 设置",
"⑥ 商品套图",
] ]
TAB_STYLE = """ TAB_STYLE = """
+157 -1
View File
@@ -7,7 +7,7 @@ import re
import threading import threading
import time import time
from .. import image_studio, image_studio_export, image_studio_generation, image_studio_images from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
from .widgets import * from .widgets import *
@@ -67,6 +67,10 @@ def _format_image_studio_event(event):
return prefix return prefix
def _format_product_suite_event(event):
return _format_image_studio_event(event).replace("[AI工场]", "[商品套图]", 1)
def _image_studio_user_detail(detail): def _image_studio_user_detail(detail):
text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip() text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip()
text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text) text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text)
@@ -286,6 +290,158 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
return summary return summary
class ProductSuiteGenerateWorker(BaseWorker):
"""Create and run independently configured product-suite jobs."""
def __init__(
self,
project_id,
job_specs,
*,
aspect_ratio="1:1",
db_path=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.project_id = int(project_id)
self.job_specs = [dict(spec) for spec in (job_specs or [])]
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
self._lock = threading.Lock()
def execute(self):
total = len(self.job_specs)
if total <= 0:
raise ValueError("商品套图生成任务不能为空")
jobs = []
for spec in self.job_specs:
if self.should_cancel():
break
jobs.append(
image_studio.create_job(
self.project_id,
source_asset_id=spec.get("source_asset_id"),
job_type=spec.get("job_type") or "套图",
prompt=spec.get("prompt") or "",
generation_source="cmhub",
provider="cmhub",
path=self.db_path,
)
)
self.progress.emit(
{
"total": len(jobs),
"done": 0,
"failed": 0,
"job_ids": [job.id for job in jobs],
}
)
def on_event(payload):
event = dict(payload or {})
self.log.emit(_format_product_suite_event(event))
if event.get("step") == "job_done":
with self._lock:
self._done += 1
if event.get("result") != "success":
self._failed += 1
progress = {
"total": len(jobs),
"done": self._done,
"failed": self._failed,
"job_ids": [job.id for job in jobs],
}
self.progress.emit(progress)
summary = image_studio_generation.run_jobs(
jobs,
aspect_ratio=self.aspect_ratio,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
path=self.db_path,
should_stop=self.should_cancel,
on_event=on_event,
)
summary["project_id"] = self.project_id
summary["job_ids"] = [job.id for job in jobs]
return summary
class ProductSuiteAiWriteWorker(BaseWorker):
"""Generate product selling-point copy without blocking the suite workspace."""
def __init__(
self,
instruction,
context,
*,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.instruction = str(instruction or "")
self.context = str(context or "")
self.config = config
self.cmhub_config_path = cmhub_config_path
def execute(self):
if self.should_cancel():
return {"cancelled": True}
text = ai.gen_title(
self.instruction,
self.context,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
if self.should_cancel():
return {"cancelled": True}
return {"text": str(text or "").strip()}
class ProductSuiteImportImagesWorker(BaseWorker):
"""Validate and copy local product images outside the GUI thread."""
def __init__(
self,
project_id,
*,
file_paths=None,
image_bytes=None,
filename_hint="clipboard.png",
db_path=None,
config=None,
):
super().__init__()
self.project_id = int(project_id)
self.file_paths = list(file_paths or [])
self.image_bytes = bytes(image_bytes) if image_bytes is not None else None
self.filename_hint = str(filename_hint or "clipboard.png")
self.db_path = db_path
self.config = config
def execute(self):
if self.image_bytes is not None:
asset = image_studio_images.import_original_bytes(
self.project_id,
self.image_bytes,
filename_hint=self.filename_hint,
path=self.db_path,
config=self.config,
)
return {"assets": [asset], "errors": [], "limit": 16}
return image_studio_images.import_original_files(
self.project_id,
self.file_paths,
path=self.db_path,
config=self.config,
)
class ImageStudioResumeJobsWorker(BaseWorker): class ImageStudioResumeJobsWorker(BaseWorker):
"""Resume submitted/running or failed-download AI studio jobs.""" """Resume submitted/running or failed-download AI studio jobs."""
+99 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
@@ -41,6 +42,7 @@ class ImageStudioProject:
target_main_count: int target_main_count: int
target_detail_count: int target_detail_count: int
draft_prompt: Optional[str] draft_prompt: Optional[str]
suite_settings_json: str
status: str status: str
created_at: str created_at: str
updated_at: str updated_at: str
@@ -357,6 +359,32 @@ def update_project_prompt(project_id, draft_prompt, path=None, conn=None):
return get_project(project_id, conn=database) return get_project(project_id, conn=database)
def project_suite_settings(project) -> dict:
raw = _get(project, "suite_settings_json", "{}") or "{}"
try:
value = json.loads(str(raw))
except (TypeError, ValueError):
return {}
return value if isinstance(value, dict) else {}
def update_project_suite_settings(project_id, settings, path=None, conn=None):
if not isinstance(settings, dict):
raise db.DbError("商品套图设置必须是对象")
payload = json.dumps(settings, ensure_ascii=False, sort_keys=True)
with _connection(conn, path) as database:
with database:
database.execute(
"""
UPDATE image_studio_projects
SET suite_settings_json = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
""",
(payload, _now(), int(project_id)),
)
return get_project(project_id, conn=database)
def soft_delete_project(project_id, reason="", path=None, conn=None): def soft_delete_project(project_id, reason="", path=None, conn=None):
now = _now() now = _now()
with _connection(conn, path) as database: with _connection(conn, path) as database:
@@ -461,6 +489,47 @@ def list_assets(project_id, kind=None, include_missing=True, path=None, conn=Non
return _fetch_all(database, sql, params, ImageStudioAsset) return _fetch_all(database, sql, params, ImageStudioAsset)
def reorder_original_assets(project_id, asset_ids, path=None, conn=None):
ordered_ids = [int(asset_id) for asset_id in asset_ids]
if len(ordered_ids) != len(set(ordered_ids)):
raise db.DbError("商品原图排序不能包含重复图片")
with _connection(conn, path) as database:
rows = database.execute(
"""
SELECT id FROM image_studio_assets
WHERE project_id = ? AND kind = ?
ORDER BY source_order, id
""",
(int(project_id), ASSET_KIND_ORIGINAL),
).fetchall()
existing_ids = [int(row["id"]) for row in rows]
if set(existing_ids) != set(ordered_ids):
raise db.DbError("商品原图排序必须包含当前项目的全部原图")
now = _now()
with database:
for source_order, asset_id in enumerate(ordered_ids, 1):
database.execute(
"""
UPDATE image_studio_assets
SET source_order = ?, updated_at = ?
WHERE id = ? AND project_id = ? AND kind = ?
""",
(
source_order,
now,
asset_id,
int(project_id),
ASSET_KIND_ORIGINAL,
),
)
return list_assets(
project_id,
kind=ASSET_KIND_ORIGINAL,
path=path,
conn=database,
)
def asset_reference_counts(asset_id, path=None, conn=None): def asset_reference_counts(asset_id, path=None, conn=None):
"""Return selection/job references for one asset before pool removal.""" """Return selection/job references for one asset before pool removal."""
@@ -506,7 +575,7 @@ def remove_asset_if_unused(asset_id, path=None, conn=None):
return asset return asset
def sync_original_asset_urls(project_id, image_urls, path=None, conn=None): def sync_original_asset_urls(project_id, image_urls, path=None, conn=None, max_assets=16):
"""Store the read-only Shopee main image URL snapshot as remote-only assets.""" """Store the read-only Shopee main image URL snapshot as remote-only assets."""
normalized = [] normalized = []
@@ -538,6 +607,13 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
for row in existing_rows for row in existing_rows
if row["remote_url"] if row["remote_url"]
} }
local_only_count = sum(
1
for row in existing_rows
if not row["remote_url"] and row["status"] != ASSET_STATUS_MISSING
)
remote_limit = max(0, int(max_assets) - local_only_count)
normalized = normalized[:remote_limit]
active_ids = set() active_ids = set()
for item in normalized: for item in normalized:
row = by_url.get(item["src"]) row = by_url.get(item["src"])
@@ -577,7 +653,11 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
), ),
) )
active_ids.add(int(cursor.lastrowid)) active_ids.add(int(cursor.lastrowid))
missing_ids = [int(row["id"]) for row in existing_rows if int(row["id"]) not in active_ids] missing_ids = [
int(row["id"])
for row in existing_rows
if row["remote_url"] and int(row["id"]) not in active_ids
]
if missing_ids: if missing_ids:
placeholders = ",".join("?" for _ in missing_ids) placeholders = ",".join("?" for _ in missing_ids)
database.execute( database.execute(
@@ -677,6 +757,23 @@ def get_job(job_id, path=None, conn=None):
) )
def list_jobs(project_id, *, statuses=None, path=None, conn=None):
clauses = ["project_id = ?"]
params = [int(project_id)]
requested_statuses = [str(status) for status in (statuses or []) if str(status)]
if requested_statuses:
invalid = set(requested_statuses) - JOB_STATUSES
if invalid:
raise db.DbError("图片生成任务状态无效")
placeholders = ",".join("?" for _ in requested_statuses)
clauses.append(f"status IN ({placeholders})")
params.extend(requested_statuses)
sql = "SELECT * FROM image_studio_jobs WHERE " + " AND ".join(clauses)
sql += " ORDER BY updated_at DESC, id DESC"
with _connection(conn, path) as database:
return _fetch_all(database, sql, params, ImageStudioJob)
def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None): def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None):
now = _now() now = _now()
with _connection(conn, path) as database: with _connection(conn, path) as database:
+28 -3
View File
@@ -13,6 +13,7 @@ from .version import APP_VERSION
MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5 MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS)
class ImageStudioGenerationError(RuntimeError): class ImageStudioGenerationError(RuntimeError):
@@ -171,7 +172,7 @@ def run_jobs(
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = { futures = {
executor.submit( executor.submit(
_run_one_job, _run_one_job_with_global_slot,
job.id, job.id,
runtime, runtime,
cfg, cfg,
@@ -206,6 +207,11 @@ def run_jobs(
return summary return summary
def _run_one_job_with_global_slot(*args):
with _GLOBAL_IMAGE_STUDIO_SLOTS:
return _run_one_job(*args)
def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, should_stop, on_event): def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, should_stop, on_event):
job = image_studio.get_job(job_id, path=db_path) job = image_studio.get_job(job_id, path=db_path)
if job is None: if job is None:
@@ -237,14 +243,24 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
source_asset, source_asset,
runtime, runtime,
config, config,
aspect_ratio,
db_path, db_path,
should_stop, should_stop,
on_event, on_event,
) )
image_studio.update_job_status(job.id, "running", path=db_path) image_studio.update_job_status(job.id, "running", path=db_path)
request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event) request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event)
_raise_if_stopped(should_stop)
out_path = _output_path(project, job, image_root) out_path = _output_path(project, job, image_root)
saved_path = _download_and_save_job_image(request_result, out_path, config, on_event, job.id) saved_path = _download_and_save_job_image(request_result, out_path, config, on_event, job.id)
try:
_raise_if_stopped(should_stop)
except ImageStudioGenerationError:
try:
if os.path.isfile(saved_path):
os.remove(saved_path)
finally:
raise
asset = image_studio.add_asset( asset = image_studio.add_asset(
project.id, project.id,
_generated_kind(job.job_type), _generated_kind(job.job_type),
@@ -278,7 +294,16 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
return {"job": updated, "status": status, "error": str(exc)} return {"job": updated, "status": status, "error": str(exc)}
def _submit_or_resume_job(job, source_asset, runtime, config, db_path, should_stop, on_event): def _submit_or_resume_job(
job,
source_asset,
runtime,
config,
aspect_ratio,
db_path,
should_stop,
on_event,
):
if job.task_id: if job.task_id:
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id}) _notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id})
return _request_result(job.task_id, runtime, config) return _request_result(job.task_id, runtime, config)
@@ -291,7 +316,7 @@ def _submit_or_resume_job(job, source_asset, runtime, config, db_path, should_st
"model": runtime["alias"], "model": runtime["alias"],
"image_base64": ai._image_data_url(source_path), "image_base64": ai._image_data_url(source_path),
"resolution": ai._normalize_cmhub_resolution(resolution), "resolution": ai._normalize_cmhub_resolution(resolution),
"aspect_ratio": "1:1", "aspect_ratio": str(aspect_ratio or "1:1"),
} }
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"}) _notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
data = ai._cmhub_call_with_retry( data = ai._cmhub_call_with_retry(
+198
View File
@@ -25,6 +25,7 @@ DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
DEFAULT_READ_TIMEOUT_SECONDS = 30 DEFAULT_READ_TIMEOUT_SECONDS = 30
DEFAULT_THUMBNAIL_SIZE = 220 DEFAULT_THUMBNAIL_SIZE = 220
DEFAULT_THUMBNAIL_WORKERS = 4 DEFAULT_THUMBNAIL_WORKERS = 4
MAX_ORIGINAL_ASSETS = 16
class ImageStudioImageError(RuntimeError): class ImageStudioImageError(RuntimeError):
@@ -311,6 +312,203 @@ def _existing_local_asset(asset):
return None return None
def import_original_files(
project_id,
file_paths,
*,
path=None,
config=None,
image_root=None,
max_assets=MAX_ORIGINAL_ASSETS,
):
"""Validate and atomically copy local product images into one studio project."""
imported = []
errors = []
for file_path in file_paths or []:
try:
with open(os.path.abspath(str(file_path)), "rb") as fh:
content = fh.read(int(ORIGINAL_MAX_BYTES) + 1)
imported.append(
import_original_bytes(
project_id,
content,
filename_hint=os.path.basename(str(file_path)),
path=path,
config=config,
image_root=image_root,
max_assets=max_assets,
)
)
except Exception as exc:
errors.append({"path": os.path.abspath(str(file_path)), "error": str(exc)})
return {"assets": imported, "errors": errors, "limit": int(max_assets)}
def import_original_bytes(
project_id,
content,
*,
filename_hint="clipboard.png",
path=None,
config=None,
image_root=None,
max_assets=MAX_ORIGINAL_ASSETS,
):
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
project = image_studio.get_project(project_id, path=database_path)
if project is None:
raise ImageStudioImageError("商品套图任务不存在")
image_bytes = bytes(content or b"")
if not image_bytes:
raise ImageStudioImageError("商品原图内容为空")
if len(image_bytes) > int(ORIGINAL_MAX_BYTES):
raise ImageStudioImageError("商品原图超过大小上限")
info = _image_info(image_bytes)
originals = image_studio.list_assets(
project.id,
kind=image_studio.ASSET_KIND_ORIGINAL,
path=database_path,
)
active_originals = [
asset for asset in originals if asset.status != image_studio.ASSET_STATUS_MISSING
]
digest = hashlib.sha256(image_bytes).hexdigest()[:16]
duplicate = next(
(
asset
for asset in originals
if digest in os.path.basename(str(asset.local_path or ""))
and _existing_local_asset(asset) is not None
),
None,
)
if duplicate is not None:
return duplicate
if len(active_originals) >= int(max_assets):
raise ImageStudioImageError("商品原图最多只能添加%d张" % int(max_assets))
directory = image_studio.project_image_dirs(
image_root or appconfig.image_dir(cfg),
project,
)["originals"]
os.makedirs(directory, exist_ok=True)
order = max([int(asset.source_order or 0) for asset in originals] + [0]) + 1
extension = _extension_for_format(info["format"])
safe_hint = os.path.splitext(os.path.basename(str(filename_hint or "image")))[0]
safe_hint = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in safe_hint)
safe_hint = safe_hint.strip("_")[:32] or "image"
final_path = os.path.join(
directory,
"original_%02d_%s_%s%s" % (order, safe_hint, digest, extension),
)
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
created = False
try:
with open(temp_path, "wb") as fh:
fh.write(image_bytes)
with open(temp_path, "rb") as fh:
_image_info(fh.read())
os.replace(temp_path, final_path)
created = True
return image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=final_path,
aspect_ratio="%d:%d" % (int(info["width"]), int(info["height"])),
source_order=order,
path=database_path,
)
except Exception as exc:
for candidate in (temp_path, final_path if created else None):
if candidate and os.path.exists(candidate):
try:
os.remove(candidate)
except OSError:
pass
if isinstance(exc, ImageStudioImageError):
raise
raise ImageStudioImageError(f"保存商品原图失败:{exc}") from exc
def trash_generated_asset(asset_id, *, path=None, config=None, image_root=None):
"""Move one generated image to the app-managed trash without losing DB history."""
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
asset = image_studio.get_asset(asset_id, path=database_path)
if asset is None or not str(asset.kind or "").startswith("generated_"):
raise ImageStudioImageError("只能删除商品套图生成结果")
source_path = os.path.abspath(str(asset.local_path or ""))
if not source_path or not os.path.isfile(source_path):
raise ImageStudioImageError("生成图片文件不存在")
project = image_studio.get_project(asset.project_id, path=database_path)
if project is None:
raise ImageStudioImageError("商品套图任务不存在")
root = image_studio.project_image_dirs(
image_root or appconfig.image_dir(cfg),
project,
)["root"]
trash_dir = os.path.join(root, ".trash")
os.makedirs(trash_dir, exist_ok=True)
trash_path = os.path.join(
trash_dir,
"%s_%s" % (uuid.uuid4().hex, os.path.basename(source_path)),
)
try:
os.replace(source_path, trash_path)
image_studio.update_asset_local_path(
asset.id,
trash_path,
status=image_studio.ASSET_STATUS_MISSING,
path=database_path,
)
except Exception as exc:
if os.path.isfile(trash_path) and not os.path.exists(source_path):
try:
os.replace(trash_path, source_path)
except OSError:
pass
raise ImageStudioImageError(f"删除生成图片失败:{exc}") from exc
return {
"asset_id": asset.id,
"original_path": source_path,
"trash_path": trash_path,
}
def restore_trashed_asset(record, *, path=None, config=None):
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
payload = dict(record or {})
asset = image_studio.get_asset(payload.get("asset_id"), path=database_path)
if asset is None:
raise ImageStudioImageError("待撤销的生成图片记录不存在")
trash_path = os.path.abspath(str(payload.get("trash_path") or ""))
original_path = os.path.abspath(str(payload.get("original_path") or ""))
if not os.path.isfile(trash_path):
raise ImageStudioImageError("废纸篓中的生成图片不存在")
if os.path.exists(original_path):
stem, extension = os.path.splitext(original_path)
original_path = "%s_restored_%s%s" % (stem, uuid.uuid4().hex[:8], extension)
os.makedirs(os.path.dirname(original_path), exist_ok=True)
os.replace(trash_path, original_path)
try:
return image_studio.update_asset_local_path(
asset.id,
original_path,
status=image_studio.ASSET_STATUS_AVAILABLE,
path=database_path,
)
except Exception as exc:
try:
os.replace(original_path, trash_path)
except OSError:
pass
raise ImageStudioImageError(f"撤销删除生成图片失败:{exc}") from exc
def download_original_asset(asset_id, *, path=None, config=None, image_root=None, session=None): def download_original_asset(asset_id, *, path=None, config=None, image_root=None, session=None):
"""Download one Shopee original image into originals/ and mark its asset available.""" """Download one Shopee original image into originals/ and mark its asset available."""
+148
View File
@@ -0,0 +1,148 @@
"""Pure product-suite configuration and generation planning helpers."""
from __future__ import annotations
from collections import OrderedDict
FIXED_CATEGORIES = ("白底图", "场景图", "卖点图")
DEFAULT_CATEGORY_COUNTS = OrderedDict(
(("白底图", 1), ("场景图", 2), ("卖点图", 2))
)
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16")
MAX_CATEGORY_NAME_LENGTH = 10
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16
def default_suite_settings():
return {
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1",
"per_image_primary": False,
"categories": dict(DEFAULT_CATEGORY_COUNTS),
"custom_category_order": [],
}
def normalize_suite_settings(value=None):
raw = dict(value or {}) if isinstance(value, dict) else {}
normalized = default_suite_settings()
normalized["platform"] = str(raw.get("platform") or "Shopee")
normalized["country"] = str(raw.get("country") or "中国台湾")
normalized["language"] = str(raw.get("language") or "繁体中文")
ratio = str(raw.get("ratio") or "1:1")
normalized["ratio"] = ratio if ratio in RATIOS else "1:1"
normalized["per_image_primary"] = bool(raw.get("per_image_primary", False))
raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {}
categories = OrderedDict()
for name, default_count in DEFAULT_CATEGORY_COUNTS.items():
categories[name] = _count(raw_categories.get(name, default_count))
requested_order = raw.get("custom_category_order")
requested_order = requested_order if isinstance(requested_order, list) else []
seen = set(FIXED_CATEGORIES)
custom_order = []
for candidate in list(requested_order) + list(raw_categories):
name = str(candidate or "")
if name in seen or suite_name_error(name, categories):
continue
seen.add(name)
custom_order.append(name)
categories[name] = _count(raw_categories.get(name, 1))
normalized["categories"] = dict(categories)
normalized["custom_category_order"] = custom_order
return normalized
def suite_name_error(name, existing=None, old_name=""):
value = str(name or "")
if not value.strip():
return "分类名称不能为空"
if value != value.strip() or any(character.isspace() for character in value):
return "分类名称不能包含空格"
if len(value) > MAX_CATEGORY_NAME_LENGTH:
return "分类名称不能超过10个字"
names = set(existing or ())
if value in names and value != str(old_name or ""):
return "分类名称已存在"
return ""
def category_order(settings):
normalized = normalize_suite_settings(settings)
categories = normalized["categories"]
custom = [
name
for name in normalized["custom_category_order"]
if name in categories and name not in FIXED_CATEGORIES
]
return list(FIXED_CATEGORIES) + custom
def suite_total_count(settings, image_count):
normalized = normalize_suite_settings(settings)
categories = normalized["categories"]
base = sum(_count(categories.get(name, 0)) for name in category_order(normalized))
if not normalized["per_image_primary"]:
return base
white_count = _count(categories.get("白底图", 0))
other_count = max(0, base - white_count)
return white_count + other_count * max(1, int(image_count or 0))
def build_suite_prompt(base_prompt, settings, category, item_id, source_index=1):
normalized = normalize_suite_settings(settings)
context = [
"生成一张电商商品套图。",
"平台:%s" % normalized["platform"],
"国家地区:%s" % normalized["country"],
"输出语言:%s" % normalized["language"],
"图片比例:%s" % normalized["ratio"],
"套图分类:%s" % str(category),
"商品ID:%s" % str(item_id or ""),
"当前主参考图序号:%d" % max(1, int(source_index or 1)),
"商品卖点与要求:%s" % str(base_prompt or "").strip(),
"保持商品主体、款式、颜色和关键细节准确,不添加无依据的功能或参数。",
]
return "\n".join(context)
def build_job_specs(source_assets, base_prompt, settings, item_id):
assets = list(source_assets or [])
if not assets:
return []
normalized = normalize_suite_settings(settings)
specs = []
for category in category_order(normalized):
count = _count(normalized["categories"].get(category, 0))
if count <= 0:
continue
targets = assets if normalized["per_image_primary"] and category != "白底图" else assets[:1]
for source_index, asset in enumerate(targets, 1):
for category_index in range(1, count + 1):
specs.append(
{
"source_asset_id": int(getattr(asset, "id", asset)),
"job_type": str(category),
"category": str(category),
"category_index": category_index,
"source_index": source_index,
"prompt": build_suite_prompt(
base_prompt,
normalized,
category,
item_id,
source_index=source_index,
),
}
)
return specs
def _count(value):
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
+4 -4
View File
@@ -4,10 +4,10 @@
## 一句话定位 ## 一句话定位
蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 5 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面。AI工场图片候选工作区的代码与数据能力保留,但当前暂不在主界面展示入口。 蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 6 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面;⑥「商品套图」用于按账号与商品生成本地电商套图,不自动上传蝦皮。
主流水线(工作流优先顺序): 主流水线(工作流优先顺序):
**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置**。⑥ AI工场是保留的图片项目/照片池/cmhub 托管生图工作区,当前隐藏主界面入口且不自动上传蝦皮。 **① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图**。①~③是 Excel 批量更新主流水线;⑥是独立的本地商品套图工作区,复用 cmhub 托管生图与既有 `image_studio_*` 数据,不自动上传蝦皮。
目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。 目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。
@@ -30,7 +30,7 @@
## 当前阶段 ## 当前阶段
当前项目处于:**V0 单账号流程已验证,正在实现 V1 多账号 + Excel + AI 的 5 Tab GUI 工作台;AI工场入口暂时隐藏**。 当前项目处于:**V0 单账号流程已验证,V1 多账号 + Excel + AI 的 6 Tab GUI 工作台持续收口中;⑥商品套图已接入**。
优先路径: 优先路径:
@@ -65,7 +65,7 @@
**V1 当前 coding 目标**: **V1 当前 coding 目标**:
- 当前正式界面为 5 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置;⑥ AI工场的代码与本地数据保留,但主界面入口暂时隐藏,不自动提交线上。 - 当前正式界面为 6 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。旧 `ImageStudioTab` 只保留内部兼容,主界面入口由 `ProductSuiteTab` 替代;⑥只生成和管理本地图片,不自动提交线上。
- GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。 - GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。
- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。 - 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。
- Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。 - Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。
+3 -2
View File
@@ -32,9 +32,9 @@
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 | | 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 |
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 | | 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线) ### V1 当前目标(多账号 + Excel + AI,6 Tab 工作台)
5 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置。 6 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。
| 功能 | 用户能做什么 | 优先级 | | 功能 | 用户能做什么 | 优先级 |
| --- | --- | --- | | --- | --- | --- |
@@ -49,6 +49,7 @@
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 | | 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 | | 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 | | 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 |
| 商品套图(⑥) | 以账号+商品ID建立本地图片项目;可拉取蝦皮主图或添加本地原图,按白底图/场景图/卖点图及自定义分类生成套图;支持多任务并行、历史结果、单图重试、删除撤销;只写本地项目,不自动上传或修改蝦皮 | P1 |
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 | | 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 |
### 后续迭代 ### 后续迭代
+1 -1
View File
@@ -30,7 +30,7 @@
## 二、决策记录与演进 ## 二、决策记录与演进
- **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。 - **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。
- **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、AI工场照片池、进度与停止。当前环境已安装 PySide6,且 Tkinter 不可用;Qt 的 `QThread`/signal-slot 比 Tkinter 手动 queue/after 更适合长任务回传 UI。 - **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、商品套图多任务、进度与停止。⑥采用原生 `ProductSuiteTab`,不引入 QtWebEngine;Qt 的 `QThread`/signal-slot 用于长任务回传 UI。
- **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。 - **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。 - **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。 - **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
+17 -14
View File
@@ -5,14 +5,14 @@
## 一、系统结构 ## 一、系统结构
Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 5 个工作流 Tab;AI工场图片工作区的代码、数据库与本地资产能力保留,但主界面入口暂时隐藏。 Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 6 个工作流 Tab;⑥商品套图使用新的原生 PySide6 界面,并复用既有 `image_studio_*` 数据与生图服务。
```text ```text
运营(人) 运营(人)
| |
v v
GUI(PySide6 QTabWidget,当前显示 5 Tab) GUI(PySide6 QTabWidget,当前显示 6 Tab)
① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置 ① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置 ⑥ 商品套图
| |
v v
核心模块(Python) 核心模块(Python)
@@ -25,8 +25,9 @@ GUI(PySide6 QTabWidget,当前显示 5 Tab)
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽) ├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 ├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面) ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
├── image_studio AI工场项目/资产/job/终选顺序数据服务 ├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
├── image_studio_images 远程原图安全下载、缩略图、原图落盘 ├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
├── image_studio_images 远程原图安全下载、本地原图导入、生成图废纸篓
├── image_studio_generation cmhub 托管多图异步 submit/poll/download 编排 ├── image_studio_generation cmhub 托管多图异步 submit/poll/download 编排
└── image_studio_export 终选图片本地 JPEG 转码、目录安全导出 └── image_studio_export 终选图片本地 JPEG 转码、目录安全导出
| |
@@ -39,8 +40,8 @@ Shopee 卖家中心页面 / 本地图片目录
真实组件: 真实组件:
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。`ImageStudioTab` 及其服务层仍保留在代码和发布包中,但当前不加入顶层 `QTabWidget`。 - GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 6 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。第六 Tab 只创建 `ProductSuiteTab`;旧 `ImageStudioTab` 保留内部兼容,但不加入顶层 `QTabWidget`。
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 - 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`product_suite.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 - 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。 - 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。
@@ -63,7 +64,7 @@ imported → collected → generated → applied
## 三、职责划分 ## 三、职责划分
**GUI(当前显示 5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。⑥ AI工场入口当前隐藏;其只读拉图、下载图片和 cmhub 托管生成代码仍保留,且不会自动上传蝦皮。** **GUI(当前显示 6 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。⑥商品套图的只读拉图、原图下载/导入、AI帮写和 cmhub 生成均在独立 worker 中运行;多任务可并行,且不会自动上传蝦皮。**
**核心模块** **核心模块**
@@ -84,7 +85,7 @@ imported → collected → generated → applied
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 - cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;AI工场完整提示词模板存 `data/prompts/image_studio/<名称>.txt`。 - 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;旧 AI工场模板目录 `data/prompts/image_studio/<名称>.txt` 仅保留兼容,⑥商品套图直接把卖点文本与结构化设置保存在项目表。
- 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。 - 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。 T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。
@@ -141,7 +142,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
`ai` 段只放**选择 + 全局生成参数**: `ai` 段只放**选择 + 全局生成参数**:
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。 - `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页与⑥AI工场只展示 cmhub 托管“默认档 / 高质量档 / 省点档”、展示名、用途和扣点提示,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug;执行层仍只保存 alias。 - `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页展示托管档位、别名和扣点提示;⑥商品套图直接使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。
- `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。 - `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。
- `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。 - `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。
- `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。 - `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。
@@ -419,7 +420,9 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。
- 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。
- ⑥AI工场固定使用⑤保存的 cmhub 生图 alias。生成区显示“cmhub 托管默认档 / 高质量档 / 省点档”、当前生图别名、余额和扣点;档位是产品说明层,真实模型可由 cmhub 后台调整,客户端不保存 OpenAI slug。 - ⑥商品套图固定使用⑤保存的 cmhub 生图 alias;平台、国家地区、输出语言、比例、分类、商品ID、参考图序号和卖点文本由 `product_suite.build_suite_prompt()` 组成每个 job 的完整提示词。比例同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,最终进入 cmhub 请求与输出资产元数据。
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
提示词管理: 提示词管理:
@@ -461,14 +464,14 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
| WebSocket Origin | `websocket-client` `suppress_origin=True` | | WebSocket Origin | `websocket-client` `suppress_origin=True` |
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 | | 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
| 前台激活 | ①采集和⑥AI工场只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | 前台激活 | ①采集和⑥商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` | | 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` |
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 | | 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 |
| AI工场原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不下载图片、不改标题/封面、不拖拽、不点击更新;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 | | 商品套图原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不改标题/封面、不拖拽、不点击更新;URL 读取完成后再由最多2个图片下载 worker 落盘;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 |
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` | | 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` |
| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 | | 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 |
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | | 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
@@ -516,7 +519,7 @@ cmshopee/
│ └── prompts/ │ └── prompts/
│ ├── title/<名称>.txt # 标题提示词命名模板 │ ├── title/<名称>.txt # 标题提示词命名模板
│ ├── cover/<名称>.txt # 封面提示词命名模板 │ ├── cover/<名称>.txt # 封面提示词命名模板
│ └── image_studio/<名称>.txt # AI工场完整提示词模板 │ └── image_studio/<名称>.txt # 旧AI工场模板兼容目录,商品套图不依赖
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) └── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md # 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
``` ```
+2 -2
View File
@@ -4,7 +4,7 @@
## 一句话定位 ## 一句话定位
蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥AI工场图片候选生成。 蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥商品套图生成。
## 文档导航 ## 文档导航
@@ -21,7 +21,7 @@
- [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。 - [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。
- [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。 - [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。
- [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。 - [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。
- [AI工场端到端验收](ai-studio-e2e-checklist.md):⑥ AI工场 cmhub 托管主线的自动化覆盖、人工只读验收和发布检查。 - [旧 AI工场兼容层端到端验收](ai-studio-e2e-checklist.md):T-622 前图片工作区的自动化覆盖;当前⑥商品套图继续复用其中的数据与 cmhub 服务。
- [AI工场托管模型评测与默认档位策略](ai-studio-model-evaluation.md):OpenAI / GPT 托管能力经 cmhub 别名落地前的评测样本、档位策略、上线门槛与运营用法。 - [AI工场托管模型评测与默认档位策略](ai-studio-model-evaluation.md):OpenAI / GPT 托管能力经 cmhub 别名落地前的评测样本、档位策略、上线门槛与运营用法。
- [AI工场 BYOK 启动门禁记录](byok-startup-gate.md):自定义模型 / BYOK 开工前的授权、设备凭证、Provider 白名单、task secret 和接口契约检查记录。 - [AI工场 BYOK 启动门禁记录](byok-startup-gate.md):自定义模型 / BYOK 开工前的授权、设备凭证、Provider 白名单、task secret 和接口契约检查记录。
- [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。 - [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。
+2
View File
@@ -1,5 +1,7 @@
# AI工场端到端验收与发布检查 # AI工场端到端验收与发布检查
> T-622 后主窗口入口已由「⑥商品套图」替换;本文保留为旧 `ImageStudioTab` 与共享 `image_studio_*` 服务的兼容回归清单,不再代表当前第六 Tab 的界面验收。当前商品套图交互以 [`tasks/T-622.md`](tasks/T-622.md) 和 [`routes.md`](routes.md) 为准。
> T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。 > T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。
## 已实现闭环 ## 已实现闭环
+25 -6
View File
@@ -355,9 +355,9 @@ render_prompt(template_text, task) -> str
- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。 - `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。 - 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
## AI工场模块(`app/image_studio*.py`,已建) ## 商品套图模块(`app/product_suite.py` + `app/image_studio*.py`,已建)
AI工场是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。 商品套图是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。底层继续复用旧 AI工场的数据表与生成服务,避免迁移既有项目数据。
```python ```python
# app/image_studio.py # app/image_studio.py
@@ -365,9 +365,13 @@ ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection
create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject
list_projects(path=None) -> list[ImageStudioProject] list_projects(path=None) -> list[ImageStudioProject]
update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject
project_suite_settings(project) -> dict
update_project_suite_settings(project_id, settings, path=None) -> ImageStudioProject
sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset] sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset]
list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset] list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset]
reorder_original_assets(project_id, asset_ids, path=None) -> list[ImageStudioAsset]
create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) -> ImageStudioJob create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) -> ImageStudioJob
list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob]
list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob] list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob]
replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection] replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection]
pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -> dict pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -> dict
@@ -376,6 +380,16 @@ pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -
download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage
load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult
download_original_asset(asset_id, path=None, config=None) -> ImageStudioAsset download_original_asset(asset_id, path=None, config=None) -> ImageStudioAsset
import_original_files(project_id, file_paths, path=None, config=None) -> dict
import_original_bytes(project_id, content, filename_hint="clipboard.png", ...) -> ImageStudioAsset
trash_generated_asset(asset_id, path=None, config=None) -> dict
restore_trashed_asset(record, path=None, config=None) -> ImageStudioAsset
# app/product_suite.py
normalize_suite_settings(value=None) -> dict
suite_total_count(settings, image_count) -> int
build_suite_prompt(base_prompt, settings, category, item_id, source_index=1) -> str
build_job_specs(source_assets, base_prompt, settings, item_id) -> list[dict]
# app/image_studio_generation.py # app/image_studio_generation.py
generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", ...) -> dict generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", ...) -> dict
@@ -387,10 +401,11 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None
要点: 要点:
- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `originals/generated/exports`。 - 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。
- `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。
- 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。
- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。
- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。AI工场生成区只展示托管档位、生图别名、扣点和余额,不展示 Provider URL、OpenAI Key 或上游接口路径。 - cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到 cmhub;界面不展示 Provider URL、OpenAI Key 或上游接口路径。
- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。 - 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。
- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 - 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。
@@ -406,7 +421,8 @@ class CollectTab(QWidget) # ① 导入采集:导入 Excel +
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志 class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志
class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
class ImageStudioTab(QWidget) # ⑥ AI工场:项目、原图、照片池、终选、cmhub托管生成、导出 class ProductSuiteTab(QWidget) # ⑥ 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度 class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
@@ -417,6 +433,9 @@ class ImageStudioDownloadOriginalWorker(BaseWorker)# ⑥ 后台下载远程原
class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job
class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job
class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG
class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图
class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载
class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台生成商品卖点与画面要求
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑 class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单 class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
@@ -428,7 +447,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。 T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。
`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ AI工场图片候选生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 `MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ 商品套图生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
+28 -34
View File
@@ -1,11 +1,11 @@
# 界面与流程结构 # 界面与流程结构
> 桌面工具,无前端路由。当前正式界面用 **5 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;AI工场代码与数据保留,但主界面入口暂时隐藏。 > 桌面工具,无前端路由。当前正式界面用 **6 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;⑥商品套图已替换旧 AI工场入口。
## Tab 顺序与职责(工作流优先) ## Tab 顺序与职责(工作流优先)
``` ```
① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置 ① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置 │ ⑥ 商品套图
``` ```
| Tab | 职责 | 风险 | | Tab | 职责 | 风险 |
@@ -15,18 +15,19 @@
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | | ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 |
⑥ AI工场原有代码、SQLite项目与图片资产继续保留,但当前不加入主窗口Tab栏;后续重新开放入口需另立任务。隐藏入口不会删除或迁移用户已有AI工场数据。 旧 `ImageStudioTab` 与 `image_studio_*` SQLite/图片资产服务继续保留作内部兼容;主窗口只创建 `ProductSuiteTab`,不会并列暴露两套第六 Tab,也不会删除或迁移用户既有项目数据。
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
## 启动强制升级门禁 ## 启动强制升级门禁
创建五个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。 创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
## 全局 Tab 栏可用性 ## 全局 Tab 栏可用性
当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式: 当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
- 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。 - 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。
- Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。 - Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。
@@ -179,37 +180,30 @@
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。 - ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。 - ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。
## ⑥ AI工场(入口暂时隐藏) ## ⑥ 商品套图
``` ```
┌ AI工场 ──────────────────────────────────────────────────────┐ ┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐
│ 左轨:账号[▼] 商品ID[____] [打开项目] [拉取主图] [打开项目文件夹] │ │ 账号[▼] 商品ID[____________] [拉取蝦皮主图] │
│ 项目列表:账号 / 商品ID / 更新时间 │ ├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤
│ 中区:蝦皮原主图(单击下载进池,双击预览) │ │ 商品原图:主图/参考1..5/添加 │ 共N张·成功M张 [历史生成][打开文件夹] │
│ 照片池:原图/主图/详情图角标、比例、排队/生成/失败状态 │ │ 平台/国家/语言/比例 │ [结果卡][结果卡][失败卡·重试] │
│ 右侧:模板[▼] [新建][重命名][保存][删除] │ │ 每张上传图分别作为主图生成 │ │
│ [完整提示词输入框] │ │ 商品卖点与要求 [AI帮写/取消] │ │
│ 类型[主图▼] 数量[4] 比例[1:1▼] cmhub扣点/余额提示 │ │ 白底图/场景图/卖点图/自定义类 │ 进度条 套图X/Y(秒)·失败N │
│ [开始生成][停止] 进度条 运行日志 │ │ [生成套图(N) ⇄ 停止生成] │ │
│ 底部:主图终选 / 详情图终选(拖入、插入、重排、移出) [导出终选] │ └─────────────────────────────┴──────────────────────────────────┘
└───────────────────────────────────────────────────────────────┘
``` ```
- 项目以 `账号别名 + 商品ID` 唯一;打开项目只创建/切换本地项目,不修改蝦皮。 - 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。
- 「拉取主图」复用已验证只读 CDP:后台打开商品详情页读取主图 URL,写入 `image_studio_assets(kind=original)`;不下载图片、不改标题/封面、不点击更新。 - 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。
- 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。 - 商品原图最多16张。前6个槽位固定显示主图与参考1~5;支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。
- 照片池只展示本地文件存在且可解码使用的原图、生成主图和生成详情图;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。照片池附近的「生成任务」区单独展示尚未产出可用图片的等待提交、已提交、生成中、生成失败、任务过期和已停止任务,并显示脱敏后的中文原因摘要、恢复方式和已有计费信息;任务卡不能设为源图或拖入终选。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。
- 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
- 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面显示当前托管档位(默认/高质量/省点)、生图别名、扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 - 平台、国家地区、语言和比例都写进每个 job 的完整提示词;比例还透传到 cmhub 生图请求,不是装饰字段。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。
- 「继续查询任务」只恢复当前项目中已保存 `task_id` 且标记为可恢复的 cmhub 生图任务,包括已提交、生成中及下载/保存中断后可继续处理的任务;恢复时只 poll/download 原任务,不再次 submit,不重复扣点。上游已终态失败、任务过期或尚未取得 `task_id` 即停止的任务会明确提示需要重新生成,可能产生新的计费;已有 `task_id` 后被用户停止的任务仍可继续查询。 - 生成按钮按当前总数显示并在运行时切换为停止。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。
- 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。 - AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。
- 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
- 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。
- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI。拉主图、导出等互斥 worker 运行时禁用项目切换和工作区写操作;生图或继续查询任务运行时仍只允许一个耗时 worker,但当前项目的照片池浏览/源图选择、主图和详情图终选拖放、提示词模板编辑以及下一轮类型/数量/比例设置保持可用。这些修改只作用于下一轮,当前已提交任务的参数不变;此时仍禁用项目切换、原图区下载、拉主图、开始生成、继续查询、导出和删除项目,停止为协作式停止。
- 「导出终选」可在主图/详情图未满目标数量时导出当前终选;主图和详情图总数为 0 时阻断。用户选择导出父目录后,程序在其下创建商品 ID 子目录,按终选顺序转码为 `商品ID_主图_1.jpg`、`商品ID_详情图_1.jpg`,透明图铺白底输出真正 JPEG。
- 商品目录已存在时只提供三选:覆盖本软件导出的图片(仅删除匹配当前商品命名规则的旧主图/详情图,保留用户其它文件)、新建带时间目录、取消;不提供合并,也不递归清空用户目录。
- 导出前会预检所有终选源文件和图片解码,先写 staging,转码失败不创建商品目录、不留下半套新图;成功后中文提示实际目录和主图/详情图数量,并提供打开目录。
- 本小节当前覆盖 T-591/T-593:主界面、终选排序和本地导出已接入;不自动上传或修改蝦皮,BYOK/自定义 Provider 仍后置。
## 流程导航 ## 流程导航
@@ -239,12 +233,12 @@
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 | | `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
| `ImageStudioTab(QWidget)` | ⑥ | AI工场项目列表、只读拉蝦皮主图、原图下载进池、照片池、大图预览、完整提示词模板 CRUD、cmhub 托管多图生成控制 | | `ProductSuiteTab(QWidget)` | ⑥ | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 | | `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 |
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 | | `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ImageStudioGenerateJobsWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、cmhub 托管生图 submit/poll/download;不直接操作 QWidget | | `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成与AI帮写;不直接操作 QWidget |
> 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。 > 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。
+12 -5
View File
@@ -3,7 +3,7 @@ id: T-622
title: ⑥用「商品套图」模块替换现有 AI工场(迁移电商图生成器套餐模块) title: ⑥用「商品套图」模块替换现有 AI工场(迁移电商图生成器套餐模块)
phase: 7 phase: 7
deps: [T-564] deps: [T-564]
status: TODO status: DONE
created: 2026-07-13 created: 2026-07-13
--- ---
@@ -42,11 +42,11 @@ UI 已定稿:`docs/ui/tab6-suite-package-v1.svg`(本仓库家风:`#2b3a55`
- 结果网格:每张卡片显示图片/生成中骨架/失败(带「重试」);右键单图可 预览 / 复制路径 / 重新生成。 - 结果网格:每张卡片显示图片/生成中骨架/失败(带「重试」);右键单图可 预览 / 复制路径 / 重新生成。
- 底部一行(与左侧「生成套图」按钮同高):进度条 + `套图 X/Y(秒)· 失败 N` 汇总。 - 底部一行(与左侧「生成套图」按钮同高):进度条 + `套图 X/Y(秒)· 失败 N` 汇总。
## 待决 / 选型(实现前确认) ## 实现决策
1. **技术栈**:推荐**原生 PySide6 重写 UI**、复用现有 `image_studio_*` 后端;已排除内嵌 WebView(避免 QtWebEngine 打包体积与依赖)。若坚持搬 WebView 需另评估。 1. **技术栈**:采用原生 PySide6 `ProductSuiteTab`,复用现有 `image_studio_*` 后端,不引入 QtWebEngine;旧 `ImageStudioTab` 仅保留内部兼容。
2. 平台/国家/语言下拉的选中值如何进入生成上下文(提示词模板变量 or 生成请求参数),需与 `image_studio_generation` 对齐。 2. **生成上下文**:平台/国家/语言/比例/分类/商品ID/参考图序号统一由 `product_suite.build_suite_prompt()` 写入每个 job prompt;比例另透传到 cmhub `aspect_ratio` 请求字段。
3. 效果图当前为「滚动区裁剪」呈现;如需一屏展示全部配置项,属实现期布局细节。 3. **布局**:配置区可滚动,生成按钮固定;缩略图和卖点框设稳定高度,结构分类用常驻 chip + 单项展开计数,基准窗口首屏可见。
## 验收要点 ## 验收要点
@@ -102,3 +102,10 @@ UI 已定稿:`docs/ui/tab6-suite-package-v1.svg`(本仓库家风:`#2b3a55`
- 不动 ①~⑤ 既有标签与流程。 - 不动 ①~⑤ 既有标签与流程。
- 不引入 `QtWebEngine`(采纳原生重写方案时)。 - 不引入 `QtWebEngine`(采纳原生重写方案时)。
- 安全红线:不写真实凭证到代码/文档/日志;`config.json`、`cmshopee.db`、`images/` 等不提交;不绕过 Shopee 风控;生成/导出等本地操作不触碰 ③ 的线上提交边界。 - 安全红线:不写真实凭证到代码/文档/日志;`config.json`、`cmshopee.db`、`images/` 等不提交;不绕过 Shopee 风控;生成/导出等本地操作不触碰 ③ 的线上提交边界。
## 执行记录
- 2026-07-14:完成第六 Tab 从旧 AI工场入口切换为原生「商品套图」。新增多任务状态、账号+商品ID上下文、16张商品原图导入/拖放/粘贴/排序/后台拉取、结构分类与AI帮写、生成/停止、结果历史、重试、预览、废纸篓删除与撤销;旧 `ImageStudioTab` 保留但主窗口不再创建。
- 2026-07-14:扩展 `image_studio_projects.suite_settings_json` 及原位迁移,新增套图纯逻辑、项目 job 查询/原图排序、本地图片原子导入、生成图废纸篓;cmhub 比例从 UI 真实透传,并以进程级闸门限制多个套图任务合计最多5个在途 job。停止信号在下载前后生效,停止后的临时图片不入资产库。
- 2026-07-14:同步 AI 开发入口、需求、技术栈、架构、API、routes、文档导航与 UI 索引;新增领域、worker、GUI、迁移、图片边界、比例透传和停止清理测试。
- 验证:在只包含 T-622 暂存文件的干净 worktree 中运行 `py -3.10 -m unittest discover -s tests`,459 项全部通过;`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。原工作区另有任务开始前即存在的默认封面提示词改名(删除 `papa1.txt`、新增 `默认.txt`),会使3个仍断言 `papa1` 的旧测试失败,该改动未回退、未纳入本任务提交。
+2 -2
View File
@@ -4,7 +4,7 @@
| 文件 | 内容 | | 文件 | 内容 |
| --- | --- | | --- | --- |
| [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;⑥ AI工场为图片候选工作区) | | [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;图中旧⑥AI工场入口已由商品套图替换) |
| [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 | | [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 |
| [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 | | [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 |
| [tab2-ai-generate-T584.svg](tab2-ai-generate-T584.svg) | ② AI生成(T-584 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 | | [tab2-ai-generate-T584.svg](tab2-ai-generate-T584.svg) | ② AI生成(T-584 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 |
@@ -14,6 +14,6 @@
| [tab6-image-studio.svg](tab6-image-studio.svg) | ⑥ 图片精修(提案·初版):商品列表、原主图九宫、照片池、生成控制、终选排序 | | [tab6-image-studio.svg](tab6-image-studio.svg) | ⑥ 图片精修(提案·初版):商品列表、原主图九宫、照片池、生成控制、终选排序 |
| [tab6-image-studio-v2.svg](tab6-image-studio-v2.svg) | ⑥ 图片精修(提案·重排):细图标轨、照片池放大、提示词中心整列、全宽出片盘 | | [tab6-image-studio-v2.svg](tab6-image-studio-v2.svg) | ⑥ 图片精修(提案·重排):细图标轨、照片池放大、提示词中心整列、全宽出片盘 |
| [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | ⑥ AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 | | [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | ⑥ AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 |
| [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | ⑥ 商品套图(迁移提案·v1):从「虾皮圈电商图生成器」商品套餐模块迁移,替换 AI工场;已去掉「详情图」(与套图合并为单一图片类型)。采用本仓库既有模块风格(#2b3a55 头栏 / #2f6fed 蓝 / 微软雅黑)。多任务标签页、左侧配置面板(商品原图上传、生成设置、AI 帮写、套图结构配置:白底/场景/模特/细节/卖点+自定义分类计数)、右侧生成结果网格、底部进度与生成/停止 | | [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | ⑥ 商品套图(T-622 已实现):从「虾皮圈电商图生成器」商品套餐交互迁移并替换主界面 AI工场入口;单一套图类型,多任务标签,商品原图,平台/国家/语言/比例,AI 帮写,白底/场景/卖点及自定义分类,结果历史、重试、删除撤销与生成进度 |
> 仅为线框效果图,最终样式以实现为准。带 `-T584`/`-v2` 等后缀的为**改版提案**,未定稿。界面职责与流程见 [../routes.md](../routes.md)。 > 仅为线框效果图,最终样式以实现为准。带 `-T584`/`-v2` 等后缀的为**改版提案**,未定稿。界面职责与流程见 [../routes.md](../routes.md)。
+3 -1
View File
@@ -56,6 +56,7 @@ from app.gui import (
ForcedUpdateDialog, ForcedUpdateDialog,
ImageStudioTab, ImageStudioTab,
MainWindow, MainWindow,
ProductSuiteTab,
SettingsTab, SettingsTab,
TAB_STYLE, TAB_STYLE,
TAB_TITLES, TAB_TITLES,
@@ -491,7 +492,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.addCleanup(window.close) self.addCleanup(window.close)
self.assertEqual(gui.display_name(), window.windowTitle()) self.assertEqual(gui.display_name(), window.windowTitle())
self.assertEqual(5, window.tabs.count()) self.assertEqual(6, window.tabs.count())
self.assertEqual( self.assertEqual(
TAB_TITLES, TAB_TITLES,
[window.tabs.tabText(index) for index in range(window.tabs.count())], [window.tabs.tabText(index) for index in range(window.tabs.count())],
@@ -511,6 +512,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(window.tabs.widget(2), ApplyTab) self.assertIsInstance(window.tabs.widget(2), ApplyTab)
self.assertIsInstance(window.tabs.widget(3), AccountsTab) self.assertIsInstance(window.tabs.widget(3), AccountsTab)
self.assertIsInstance(window.tabs.widget(4), SettingsTab) self.assertIsInstance(window.tabs.widget(4), SettingsTab)
self.assertIsInstance(window.tabs.widget(5), ProductSuiteTab)
self.assertFalse( self.assertFalse(
any( any(
isinstance(window.tabs.widget(index), ImageStudioTab) isinstance(window.tabs.widget(index), ImageStudioTab)
+157
View File
@@ -59,6 +59,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
"item_id", "item_id",
"target_main_count", "target_main_count",
"target_detail_count", "target_detail_count",
"suite_settings_json",
"deleted_at", "deleted_at",
}.issubset(projects_columns) }.issubset(projects_columns)
) )
@@ -72,6 +73,50 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_init_db_migrates_legacy_projects_with_default_suite_settings(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "legacy-project.db")
conn = db.connect(db_path)
try:
conn.execute(
"""
CREATE TABLE image_studio_projects (
id INTEGER PRIMARY KEY,
account_alias TEXT NOT NULL,
account_slug TEXT NOT NULL,
account_name TEXT,
item_id TEXT NOT NULL,
target_main_count INTEGER NOT NULL DEFAULT 9,
target_detail_count INTEGER NOT NULL DEFAULT 12,
draft_prompt TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
deleted_reason TEXT,
UNIQUE(account_alias, item_id)
)
"""
)
conn.execute(
"""
INSERT INTO image_studio_projects
(id, account_alias, account_slug, item_id, created_at, updated_at)
VALUES (1, 'alias', 'alias_slug', '51100639510', '2026-07-14', '2026-07-14')
"""
)
conn.commit()
finally:
conn.close()
db.init_db(db_path)
project = image_studio.get_project(1, path=db_path)
self.assertEqual("{}", project.suite_settings_json)
self.assertEqual({}, image_studio.project_suite_settings(project))
self.assert_removed(temp_dir)
def test_project_crud_unique_per_account_and_image_dirs(self): def test_project_crud_unique_per_account_and_image_dirs(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
@@ -110,6 +155,15 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path) updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path)
self.assertEqual("二次提示词", updated.draft_prompt) self.assertEqual("二次提示词", updated.draft_prompt)
suite_updated = image_studio.update_project_suite_settings(
project.id,
{"ratio": "3:4", "categories": {"白底图": 1}},
path=db_path,
)
self.assertEqual(
{"ratio": "3:4", "categories": {"白底图": 1}},
image_studio.project_suite_settings(suite_updated),
)
dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project) dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project)
self.assertEqual( self.assertEqual(
@@ -215,6 +269,43 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_reorder_original_assets_requires_complete_project_order(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
first = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=os.path.join(temp_dir, "first.png"),
source_order=1,
path=db_path,
)
second = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=os.path.join(temp_dir, "second.png"),
source_order=2,
path=db_path,
)
reordered = image_studio.reorder_original_assets(
project.id,
[second.id, first.id],
path=db_path,
)
self.assertEqual([second.id, first.id], [asset.id for asset in reordered])
with self.assertRaisesRegex(db.DbError, "全部原图"):
image_studio.reorder_original_assets(project.id, [first.id], path=db_path)
self.assert_removed(temp_dir)
def test_remove_asset_only_when_not_referenced(self): def test_remove_asset_only_when_not_referenced(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
@@ -287,6 +378,55 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_sync_original_asset_urls_preserves_local_upload_and_caps_active_assets(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
local = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=os.path.join(temp_dir, "local.png"),
source_order=1,
path=db_path,
)
image_studio.sync_original_asset_urls(
project.id,
[
{"index": index, "src": "https://susercontent.com/%d.jpg" % index}
for index in range(1, 8)
],
max_assets=3,
path=db_path,
)
active = image_studio.list_assets(
project.id,
kind=image_studio.ASSET_KIND_ORIGINAL,
include_missing=False,
path=db_path,
)
self.assertEqual(3, len(active))
self.assertIn(local.id, [asset.id for asset in active])
self.assertEqual(
image_studio.ASSET_STATUS_AVAILABLE,
image_studio.get_asset(local.id, path=db_path).status,
)
image_studio.sync_original_asset_urls(project.id, [], max_assets=3, path=db_path)
self.assertEqual(
image_studio.ASSET_STATUS_AVAILABLE,
image_studio.get_asset(local.id, path=db_path).status,
)
self.assert_removed(temp_dir)
def test_job_lifecycle_and_resumable_query(self): def test_job_lifecycle_and_resumable_query(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
@@ -376,6 +516,23 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
[], [],
image_studio.list_resumable_jobs(path=db_path, include_failed_downloads=True), image_studio.list_resumable_jobs(path=db_path, include_failed_downloads=True),
) )
self.assertEqual(
[terminal.id, job.id],
[item.id for item in image_studio.list_jobs(project.id, path=db_path)],
)
self.assertEqual(
[terminal.id],
[
item.id
for item in image_studio.list_jobs(
project.id,
statuses=["failed"],
path=db_path,
)
],
)
with self.assertRaisesRegex(db.DbError, "状态无效"):
image_studio.list_jobs(project.id, statuses=["unknown"], path=db_path)
with self.assertRaises(db.DbError): with self.assertRaises(db.DbError):
image_studio.create_job( image_studio.create_job(
+89
View File
@@ -146,6 +146,95 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_generate_image_jobs_sends_selected_aspect_ratio(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
submitted_payloads = []
def fake_submit(method, url, api_key, **kwargs):
submitted_payloads.append(dict(kwargs["payload"]))
return {"task_id": "cmhub-ratio", "status": "queued"}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
side_effect=fake_submit,
), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-ratio",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/ratio.png"},
},
), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"比例测试",
1,
aspect_ratio="3:4",
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["success"])
self.assertEqual("3:4", submitted_payloads[0]["aspect_ratio"])
self.assert_removed(temp_dir)
def test_stop_after_download_discards_temporary_result(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
stopped = {"value": False}
saved_paths = []
def fake_download(request_result, out_path, config, on_event, job_id):
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as fh:
fh.write(self._png_bytes())
saved_paths.append(out_path)
stopped["value"] = True
return out_path
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
return_value={"task_id": "cmhub-stop", "status": "queued"},
), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-stop",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/stop.png"},
},
), \
mock.patch(
"app.image_studio_generation._download_and_save_job_image",
side_effect=fake_download,
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"停止测试",
1,
config=cfg,
path=cfg["db_path"],
should_stop=lambda: stopped["value"],
)
self.assertEqual(1, summary["cancelled"])
self.assertEqual([], image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]))
self.assertEqual(1, len(saved_paths))
self.assertFalse(os.path.exists(saved_paths[0]))
self.assert_removed(temp_dir)
def test_resume_existing_job_polls_without_new_submit(self): def test_resume_existing_job_polls_without_new_submit(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir) cfg, project, source = self._project_source(temp_dir)
+143
View File
@@ -246,6 +246,149 @@ class ImageStudioImageTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_import_original_files_copies_valid_images_deduplicates_and_limits(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
source = os.path.join(temp_dir, "商品图.png")
with open(source, "wb") as fh:
fh.write(self._png_bytes())
config = {
"data_dir": temp_dir,
"db_path": db_path,
"image_dir": os.path.join(temp_dir, "images"),
}
first = image_studio_images.import_original_files(
project.id,
[source],
path=db_path,
config=config,
)
duplicate = image_studio_images.import_original_files(
project.id,
[source],
path=db_path,
config=config,
)
self.assertEqual([], first["errors"])
self.assertEqual(first["assets"][0].id, duplicate["assets"][0].id)
self.assertNotEqual(os.path.abspath(source), first["assets"][0].local_path)
self.assertTrue(os.path.isfile(first["assets"][0].local_path))
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "最多"):
image_studio_images.import_original_bytes(
project.id,
self._png_bytes(color=(10, 20, 30, 255)),
filename_hint="second.png",
path=db_path,
config=config,
max_assets=1,
)
self.assert_removed(temp_dir)
def test_import_original_ignores_missing_history_when_enforcing_limit(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
config = {
"data_dir": temp_dir,
"db_path": db_path,
"image_dir": os.path.join(temp_dir, "images"),
}
first = image_studio_images.import_original_bytes(
project.id,
self._png_bytes(),
filename_hint="first.png",
path=db_path,
config=config,
max_assets=1,
)
image_studio.mark_asset_status(
first.id,
image_studio.ASSET_STATUS_MISSING,
path=db_path,
)
second = image_studio_images.import_original_bytes(
project.id,
self._png_bytes(color=(10, 20, 30, 255)),
filename_hint="second.png",
path=db_path,
config=config,
max_assets=1,
)
self.assertNotEqual(first.id, second.id)
self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, second.status)
self.assert_removed(temp_dir)
def test_generated_asset_trash_and_restore_keep_database_history(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
config = {
"data_dir": temp_dir,
"db_path": db_path,
"image_dir": os.path.join(temp_dir, "images"),
}
generated_dir = image_studio.default_project_image_dirs(project, config=config)[
"generated"
]
os.makedirs(generated_dir, exist_ok=True)
generated_path = os.path.join(generated_dir, "result.png")
with open(generated_path, "wb") as fh:
fh.write(self._png_bytes())
asset = image_studio.add_asset(
project.id,
"generated_main",
local_path=generated_path,
path=db_path,
)
record = image_studio_images.trash_generated_asset(
asset.id,
path=db_path,
config=config,
)
trashed = image_studio.get_asset(asset.id, path=db_path)
self.assertEqual(image_studio.ASSET_STATUS_MISSING, trashed.status)
self.assertTrue(os.path.isfile(record["trash_path"]))
self.assertFalse(os.path.exists(generated_path))
restored = image_studio_images.restore_trashed_asset(
record,
path=db_path,
config=config,
)
self.assertEqual(asset.id, restored.id)
self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, restored.status)
self.assertTrue(os.path.isfile(restored.local_path))
self.assert_removed(temp_dir)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+74
View File
@@ -0,0 +1,74 @@
import os
import sys
import unittest
from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(__file__))
from app import product_suite
class ProductSuiteTests(unittest.TestCase):
def test_defaults_and_per_image_total(self):
settings = product_suite.default_suite_settings()
self.assertEqual(["白底图", "场景图", "卖点图"], product_suite.category_order(settings))
self.assertEqual(5, product_suite.suite_total_count(settings, 3))
settings["per_image_primary"] = True
self.assertEqual(13, product_suite.suite_total_count(settings, 3))
def test_custom_category_validation_and_normalization(self):
self.assertEqual("分类名称不能为空", product_suite.suite_name_error(""))
self.assertEqual("分类名称不能包含空格", product_suite.suite_name_error("使用 场景"))
self.assertEqual("分类名称不能超过10个字", product_suite.suite_name_error("一二三四五六七八九十甲"))
self.assertEqual("分类名称已存在", product_suite.suite_name_error("场景图", {"场景图"}))
settings = product_suite.normalize_suite_settings(
{
"ratio": "3:4",
"categories": {"白底图": 2, "场景图": 0, "卖点图": 1, "尺寸图": 2},
"custom_category_order": ["尺寸图"],
}
)
self.assertEqual("3:4", settings["ratio"])
self.assertEqual(["白底图", "场景图", "卖点图", "尺寸图"], product_suite.category_order(settings))
self.assertEqual(5, product_suite.suite_total_count(settings, 1))
def test_job_specs_include_selected_context_and_source_assignment(self):
settings = product_suite.default_suite_settings()
settings.update(
{
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "4:3",
"per_image_primary": True,
"categories": {"白底图": 1, "场景图": 1, "卖点图": 0},
}
)
assets = [SimpleNamespace(id=11), SimpleNamespace(id=12)]
specs = product_suite.build_job_specs(
assets,
"40小时续航,适合通勤",
settings,
"51100639510",
)
self.assertEqual(3, len(specs))
self.assertEqual([11, 11, 12], [spec["source_asset_id"] for spec in specs])
self.assertEqual(["白底图", "场景图", "场景图"], [spec["job_type"] for spec in specs])
for spec in specs:
self.assertIn("平台:Shopee", spec["prompt"])
self.assertIn("国家地区:中国台湾", spec["prompt"])
self.assertIn("输出语言:繁体中文", spec["prompt"])
self.assertIn("图片比例:4:3", spec["prompt"])
self.assertIn("商品ID:51100639510", spec["prompt"])
self.assertIn("40小时续航", spec["prompt"])
if __name__ == "__main__":
unittest.main()
+172
View File
@@ -0,0 +1,172 @@
import os
import sys
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import accounts, image_studio, image_studio_images
from app import gui
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
from app.gui.tabs.product_suite import ProductSuiteTab, SuiteResultCard
class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def tearDown(self):
for widget in QApplication.topLevelWidgets():
widget.close()
widget.deleteLater()
self.app.processEvents()
def _config(self, temp_dir):
return {
"chrome_path": "chrome.exe",
"user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"),
"image_dir": os.path.join(temp_dir, "images"),
"db_path": os.path.join(temp_dir, "cmshopee.db"),
"debug_port_range": [9222, 9260],
"config_path": os.path.join(temp_dir, "config.json"),
"cmhub_config_path": os.path.join(temp_dir, "cmhub.json"),
}
def _write_image(self, path):
image = QImage(40, 30, QImage.Format_RGB32)
image.fill(0xFF336699)
self.assertTrue(image.save(path))
def test_tab_builds_suite_controls_without_old_detail_workspace(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
self.assertEqual("productSuiteTab", tab.objectName())
self.assertEqual(1, tab.task_tabs.count())
self.assertEqual("套图任务 1", tab.task_tabs.tabText(0))
self.assertEqual("alias-a", tab.account_combo.currentData())
self.assertEqual("Shopee", tab.platform_combo.currentData())
self.assertEqual("中国台湾", tab.country_combo.currentData())
self.assertEqual("繁体中文", tab.language_combo.currentData())
self.assertEqual("1:1", tab.ratio_combo.currentData())
self.assertEqual("合计 5 张", tab.category_total_label.text())
self.assertEqual("生成套图(5)", tab.generate_button.text())
visible_text = " ".join(
[widget.text() for widget in tab.findChildren(QLabel)]
+ [widget.text() for widget in tab.findChildren(QPushButton)]
)
self.assertNotIn("详情图", visible_text)
self.assertNotIn("AI工场", visible_text)
self.assertIn("白底图", visible_text)
self.assertIn("场景图", visible_text)
self.assertIn("卖点图", visible_text)
tab.add_custom_category()
self.assertFalse(tab.custom_category_edit.isHidden())
tab.custom_category_edit.setText("尺寸图")
tab._commit_custom_category()
self.assertTrue(tab.custom_category_edit.isHidden())
self.assertIn("尺寸图", tab._displayed_state.settings["categories"])
self.assertEqual("尺寸图", tab._displayed_state.active_category)
self.assert_removed(temp_dir)
def test_task_tabs_keep_independent_prompt_and_settings(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
tab.prompt_edit.setPlainText("任务一卖点")
first_state = tab._displayed_state
tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("3:4"))
second_state = tab.add_task(inherit=True)
self.assertEqual(2, tab.task_tabs.count())
self.assertEqual("任务一卖点", second_state.prompt)
self.assertEqual("3:4", second_state.settings["ratio"])
tab.prompt_edit.setPlainText("任务二卖点")
tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("16:9"))
tab.task_tabs.setCurrentIndex(0)
self.assertIs(first_state, tab._displayed_state)
self.assertEqual("任务一卖点", tab.prompt_edit.toPlainText())
self.assertEqual("3:4", tab.ratio_combo.currentData())
self.assert_removed(temp_dir)
def test_project_settings_and_result_history_use_existing_backend(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "source.png")
self._write_image(source_path)
source = image_studio_images.import_original_files(
project.id,
[source_path],
path=config["db_path"],
config=config,
)["assets"][0]
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="场景卖点",
path=config["db_path"],
)
image_studio.update_job_status(
job.id,
"failed",
error="上游超时 https://example.invalid/private",
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 = "51100639510"
state.project_id = project.id
state.current_job_ids = [job.id]
tab._load_state(state)
self.assertEqual([source.id], tab.original_list.asset_ids())
self.assertEqual("共 1 张 · 成功 0 张", tab.result_summary_label.text())
cards = tab.findChildren(SuiteResultCard)
self.assertEqual(1, len(cards))
self.assertNotIn(
"https://",
" ".join(label.text() for label in cards[0].findChildren(QLabel)),
)
state.settings["ratio"] = "4:3"
state.prompt = "持久化卖点"
tab._persist_state(state)
stored = image_studio.get_project(project.id, path=config["db_path"])
self.assertEqual("持久化卖点", stored.draft_prompt)
self.assertEqual("4:3", image_studio.project_suite_settings(stored)["ratio"])
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
import os
import sys
import unittest
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import db, image_studio
from app import gui
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from app.gui.workers import ProductSuiteGenerateWorker
class ProductSuiteWorkerTests(TempDirMixin, unittest.TestCase):
def test_generate_worker_creates_category_jobs_and_forwards_ratio(self):
with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=os.path.join(temp_dir, "source.png"),
path=db_path,
)
worker = ProductSuiteGenerateWorker(
project.id,
[
{
"source_asset_id": source.id,
"job_type": "白底图",
"prompt": "白底商品图",
},
{
"source_asset_id": source.id,
"job_type": "场景图",
"prompt": "通勤场景图",
},
],
aspect_ratio="3:4",
db_path=db_path,
config={"db_path": db_path},
)
with mock.patch(
"app.gui.workers.image_studio_generation.run_jobs",
return_value={
"total": 2,
"success": 2,
"failed": 0,
"cancelled": 0,
"jobs": [],
},
) as run_jobs:
result = worker.execute()
jobs = image_studio.list_jobs(project.id, path=db_path)
self.assertEqual(2, len(jobs))
self.assertEqual({"白底图", "场景图"}, {job.job_type for job in jobs})
self.assertEqual([job.id for job in reversed(jobs)], result["job_ids"])
self.assertEqual("3:4", run_jobs.call_args.kwargs["aspect_ratio"])
self.assertEqual(db_path, run_jobs.call_args.kwargs["path"])
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()