diff --git a/app/cmhub_models.py b/app/cmhub_models.py new file mode 100644 index 0000000..469c564 --- /dev/null +++ b/app/cmhub_models.py @@ -0,0 +1,177 @@ +"""User-facing helpers for cmhub hosted model aliases.""" + +from __future__ import annotations + + +TIER_DEFAULT = "default" +TIER_HIGH_QUALITY = "high_quality" +TIER_ECONOMICAL = "economical" + +TIER_LABELS = { + TIER_DEFAULT: "默认档", + TIER_HIGH_QUALITY: "高质量档", + TIER_ECONOMICAL: "省点档", +} + +TIER_DESCRIPTIONS = { + TIER_DEFAULT: "日常批量,质量、速度和成本平衡", + TIER_HIGH_QUALITY: "重点商品或失败重试,质量优先,成本可能更高", + TIER_ECONOMICAL: "低价值 SKU 或初稿,省点优先,上线前需人工复核", +} + + +def normalize_tier(value): + text = str(value or "").strip().lower().replace("-", "_").replace(" ", "_") + if text in {"high", "quality", "high_quality", "premium", "pro", "sol"}: + return TIER_HIGH_QUALITY + if text in {"low", "cheap", "economy", "economical", "saving", "lite", "luna"}: + return TIER_ECONOMICAL + if text in {"default", "standard", "balanced", "balance", "terra"}: + return TIER_DEFAULT + return "" + + +def model_tier(model): + model = model if isinstance(model, dict) else {} + for key in ("tier", "model_tier", "quality_tier", "cmhub_tier", "recommended_tier"): + tier = normalize_tier(model.get(key)) + if tier: + return tier + haystack = " ".join(_model_text_values(model)).lower() + if any(token in haystack for token in ("省点", "低成本", "高频", "high-volume", "luna", "economy", "cheap", "lite")): + return TIER_ECONOMICAL + if any(token in haystack for token in ("高质量", "旗舰", "重点", "失败重试", "premium", "high-quality", "sol")): + return TIER_HIGH_QUALITY + if any(token in haystack for token in ("平衡", "默认", "日常", "standard", "balanced", "terra")): + return TIER_DEFAULT + alias = str(model.get("alias") or "").lower() + if alias.endswith("-hd") or alias.endswith("_hd") or alias.endswith(".hd") or alias == "hd": + return TIER_HIGH_QUALITY + return TIER_DEFAULT + + +def tier_label(tier): + return TIER_LABELS.get(normalize_tier(tier) or tier, TIER_LABELS[TIER_DEFAULT]) + + +def tier_description(tier): + return TIER_DESCRIPTIONS.get(normalize_tier(tier) or tier, TIER_DESCRIPTIONS[TIER_DEFAULT]) + + +def model_display_name(model): + model = model if isinstance(model, dict) else {} + for key in ("display_name", "name", "label"): + value = str(model.get(key) or "").strip() + if value: + return value + return str(model.get("alias") or "").strip() + + +def price_text(prices): + if not isinstance(prices, list): + return "" + parts = [] + for item in prices[:3]: + if not isinstance(item, dict): + continue + cost = item.get("points_cost") + if cost is None: + cost = item.get("cost") + if cost is None: + continue + resolution = item.get("resolution") or item.get("name") or "" + parts.append(f"{resolution}:{cost}点" if resolution else f"{cost}点") + return "/".join(parts) + + +def alias_label(model): + model = model if isinstance(model, dict) else {} + alias = str(model.get("alias") or "").strip() + display_name = model_display_name(model) + tier = model_tier(model) + parts = [tier_label(tier)] + if display_name and display_name != alias: + parts.append(display_name) + elif alias: + parts.append(alias) + prices = price_text(model.get("prices")) + if prices: + parts.append(prices) + if model.get("requires_image"): + parts.append("需参考图") + return " · ".join(parts) + + +def alias_tooltip(model): + model = model if isinstance(model, dict) else {} + alias = str(model.get("alias") or "").strip() + tier = model_tier(model) + usage = _recommended_for_text(model) + lines = [f"{tier_label(tier)}:{usage or tier_description(tier)}"] + if alias: + lines.append(f"cmhub 别名:{alias}") + prices = price_text(model.get("prices")) + if prices: + lines.append(f"扣点:{prices}") + return "\n".join(lines) + + +def configured_alias_summary(alias, *, model=None): + alias = str(alias or "").strip() + if model is None: + model = {"alias": alias} + else: + model = dict(model) + model.setdefault("alias", alias) + tier = model_tier(model) + if alias: + return f"cmhub 托管{tier_label(tier)},生图别名 {alias},扣点以返回结果为准" + return "cmhub 托管默认档,未配置生图别名;请先到⑤设置选择生图别名" + + +def _recommended_for_text(model): + for key in ("recommended_for", "usage", "description"): + value = model.get(key) + if isinstance(value, (list, tuple)): + text = "、".join(str(item).strip() for item in value if str(item).strip()) + else: + text = str(value or "").strip() + if text: + return text + return "" + + +def _model_text_values(model): + for key in ( + "alias", + "display_name", + "name", + "label", + "tier", + "model_tier", + "quality_tier", + "recommended_tier", + "recommended_for", + "description", + ): + value = model.get(key) + if isinstance(value, (list, tuple, set)): + for item in value: + text = str(item or "").strip() + if text: + yield text + elif isinstance(value, dict): + for item in value.values(): + text = str(item or "").strip() + if text: + yield text + else: + text = str(value or "").strip() + if text: + yield text + tags = model.get("tags") + if isinstance(tags, (list, tuple, set)): + for tag in tags: + text = str(tag or "").strip() + if text: + yield text diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index 936296f..2d84c76 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -7,7 +7,7 @@ import os from PySide6.QtCore import QMimeData from PySide6.QtWidgets import QListWidget, QListWidgetItem -from ... import accounts, appconfig, db, image_studio, image_studio_export, prompts +from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, prompts from .. import file_manager from ..widgets import * from ..workers import ( @@ -278,6 +278,7 @@ class ImageStudioTab(QWidget): self._build_ui() self._connect_signals() + self._refresh_model_hint() self.refresh_accounts() self.refresh_templates() self.refresh_projects() @@ -434,7 +435,7 @@ class ImageStudioTab(QWidget): form.addRow("比例", self.aspect_combo) layout.addLayout(form) - self.billing_label = QLabel("cmhub 托管模型:扣点以返回结果为准") + self.billing_label = QLabel("cmhub 托管默认档:扣点以返回结果为准") self.billing_label.setObjectName("imageStudioBillingLabel") self.billing_label.setWordWrap(True) layout.addWidget(self.billing_label) @@ -1045,7 +1046,7 @@ class ImageStudioTab(QWidget): self.progress_bar.setRange(0, count) self.progress_bar.setValue(0) self.log_view.clear() - self._append_log(f"[AI工场] 本轮生图开始:{count} 张,来源 cmhub 托管模型") + self._append_log(f"[AI工场] 本轮生图开始:{count} 张,使用 {self._cmhub_image_model_summary()}") worker = ImageStudioGenerateJobsWorker( self.current_project.id, source.id, @@ -1189,7 +1190,7 @@ class ImageStudioTab(QWidget): self.progress_bar.setRange(0, total) self.progress_bar.setValue(done) if payload.get("points_balance") is not None: - text = f"cmhub 托管模型:余额 {payload.get('points_balance')}" + text = f"{self._cmhub_image_model_summary()};余额 {payload.get('points_balance')}" if payload.get("points_cost") is not None: text += f",本张扣点 {payload.get('points_cost')}" self.billing_label.setText(text) @@ -1339,6 +1340,13 @@ class ImageStudioTab(QWidget): scrollbar = self.log_view.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) + def _refresh_model_hint(self): + self.billing_label.setText(self._cmhub_image_model_summary()) + + def _cmhub_image_model_summary(self): + alias = appconfig.cmhub_config(self.config).get("image_alias", "") + return cmhub_models.configured_alias_summary(alias) + def _message(self, title, text): box = QMessageBox(self) box.setWindowTitle(str(title or "提示")) diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index d78bd86..74839e8 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -7,6 +7,7 @@ import os from ... import ai as ai_module from ... import chrome +from ... import cmhub_models from ..widgets import * from ..workers import AIModelTestWorker as _RealAIModelTestWorker from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker @@ -1250,7 +1251,8 @@ class SettingsTab(QWidget): alias = str(model.get("alias") or "").strip() if not alias or alias in added: continue - combo.addItem(self._cmhub_alias_label(model), alias) + combo.addItem(cmhub_models.alias_label(model), alias) + combo.setItemData(combo.count() - 1, cmhub_models.alias_tooltip(model), Qt.ToolTipRole) added.add(alias) if selected and selected not in added: combo.addItem(f"{selected}(已保存)", selected) @@ -1272,32 +1274,6 @@ class SettingsTab(QWidget): items.append(model) return items - def _cmhub_alias_label(self, model): - alias = str(model.get("alias") or "").strip() - price_text = self._cmhub_price_text(model.get("prices")) - parts = [alias] - if price_text: - parts.append(price_text) - if model.get("requires_image"): - parts.append("需参考图") - return " · ".join(parts) - - def _cmhub_price_text(self, prices): - if not isinstance(prices, list): - return "" - parts = [] - for item in prices[:3]: - if not isinstance(item, dict): - continue - cost = item.get("points_cost") - if cost is None: - cost = item.get("cost") - if cost is None: - continue - resolution = item.get("resolution") or item.get("name") or "" - parts.append(f"{resolution}:{cost}点" if resolution else f"{cost}点") - return "/".join(parts) - def _cmhub_alias_count(self, operation): combo = self.cmhub_title_alias_combo if operation == "title" else self.cmhub_image_alias_combo return sum(1 for index in range(combo.count()) if combo.itemData(index)) diff --git a/app/gui/workers.py b/app/gui/workers.py index 42dba76..de3846a 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -57,7 +57,7 @@ def _format_image_studio_event(event): if result: prefix += f":{result}" if detail: - prefix += f",{diagnostics.redact_log_text(detail)}" + prefix += f",{_image_studio_user_detail(detail)}" if event.get("points_cost") is not None: prefix += f",扣点 {event.get('points_cost')}" if event.get("points_balance") is not None: @@ -65,6 +65,17 @@ def _format_image_studio_event(event): return prefix +def _image_studio_user_detail(detail): + text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip() + text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text) + text = _USER_LOG_PATH_RE.sub("[接口路径已隐藏]", text) + text = text.replace("GET [链接已隐藏]", "请求 cmhub") + text = text.replace("POST [链接已隐藏]", "请求 cmhub") + if len(text) > 180: + return text[:177] + "..." + return text + + class ImageStudioPullImagesWorker(BaseWorker): """Read Shopee main image URLs for one AI studio project in background.""" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 86daa2f..8173b77 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -141,7 +141,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 `ai` 段只放**选择 + 全局生成参数**: - `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 不在此处保存。 +- `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。 - `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。 - `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。 - `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。 @@ -201,6 +201,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 - 文件必须 gitignore,不提交;UI 展示打码。 - `appconfig.load_cmhub_config()` 缺文件时返回空 Key;普通产品默认 backend 仍为 cmhub,但未配置 Key/Base URL/别名时生成阶段会给出清晰配置错误,不静默回退 direct。 - `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去⑤设置配置,不静默回退 direct;cmhub HTTP 404 映射为 `CMHubError(code="not_found")`,提示检查 Base URL 或实例是否已部署 `/api/v1/models`。 +- cmhub `/models` 如返回 `display_name/tags/recommended_for/tier/prices`,GUI 优先用这些字段生成中文档位说明;缺少这些字段时按 alias/tag 的保守规则兜底到“默认档”。客户端不得把 OpenAI 原始模型名作为默认执行事实。 ### 5.2 SQLite `cmshopee.db` @@ -405,7 +406,7 @@ data/images///__new. # AI 生成的新 单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成: 1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 -2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images///__new.jpg`。选择 `cover` 时仅处理已有 `new_title` 且缺新封面的任务;选择 `title_cover` 时标题和封面按缺失组件增量补齐。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。 +2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images///__new.jpg`。选择 `cover` 时仅处理已有 `new_title` 且缺新封面的任务;选择 `title_cover` 时标题和封面按缺失组件增量补齐。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。用户日志只展示“cmhub 托管档位 / 别名 / 扣点 / 余额 / call_id”,不得输出 cmhub 内部接口路径、Provider 请求体、Authorization、Key 或完整 prompt。 - T-545/T-564 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际 submit+poll 在途并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图并发一致,同样最大 5。这样批量时“下一批生图任务”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图任务接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。 - cmhub 生图连接超时取 `ai.cmhub.connect_timeout`(默认 66 秒);异步 submit 读取等待 30 秒,poll 单次读取等待 15 秒,本地总等待预算 900 秒,撞预算或用户停止时保留 `image_task_id/image_task_key` 供下次续查;poll 返回 `failed/expired`(cmhub 已退点)时清空 `image_task_id/image_task_key`,后续重试会生成新的幂等键并重新 submit。下载层读取等待 900 秒,最多安全重试 3 次,只复用同一个 `image_url`,不重新请求 cmhub 生图;下载总耗时超过 20 秒时写“图片下载较慢”警告。`backend=direct` 兼容路径仍按模型 `timeout_seconds` 或 `resolution_timeouts[resolution]`(512→180/1k→240/2k→360/4k→600)取返回超时。 - T-548 已实现:cmhub 图片下载后端新增 `ai.cmhub.download_with_curl`(`auto`/`true`/`false`,默认 `auto`)。Windows 且检测到系统 curl 时优先用 curl 下载,否则或 curl 执行失败时回退 requests;生成、models、balance 仍走共享 requests Session。curl 下载前仍执行公网 URL 校验;URL 写入临时 curl 配置文件并通过 `-K` 传入,避免带 token 的 `image_url` 出现在进程命令行;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。 @@ -418,6 +419,7 @@ data/images///__new. # AI 生成的新 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 生成后 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”。 +- ⑥AI工场固定使用⑤保存的 cmhub 生图 alias。生成区显示“cmhub 托管默认档 / 高质量档 / 省点档”、当前生图别名、余额和扣点;档位是产品说明层,真实模型可由 cmhub 后台调整,客户端不保存 OpenAI slug。 提示词管理: diff --git a/docs/api.md b/docs/api.md index af89871..4cf5e0d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -315,7 +315,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li - `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。 - `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。 - 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。 -- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。 +- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 可读取 `display_name/tags/recommended_for/tier/prices` 生成“默认档 / 高质量档 / 省点档”中文说明,但执行层只保存 cmhub alias。 - `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。 - direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定 600s。②批量生图异步 submit 读取等待 36s、poll 单次读取等待 15s、本地总预算 900s,图片下载读取等待 900s;`gen_cover()` 旧同步兼容路径仍用 900s 读取等待。 - 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。 @@ -389,7 +389,7 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None - 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool///` 下分 `originals/generated/exports`。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 原图下载走 `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。 +- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。AI工场生成区只展示托管档位、生图别名、扣点和余额,不展示 Provider URL、OpenAI Key 或上游接口路径。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 - 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。 - 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 @@ -440,7 +440,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 ⑤ 设置当前要点(T-501): - `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。 -- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。 +- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名,显示托管档位、展示名、扣点和需参考图提示;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。 - T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。 - `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。 - 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。 diff --git a/docs/routes.md b/docs/routes.md index 32a0acd..120824c 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,7 +14,7 @@ | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 | | ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 | -| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | +| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | | ⑥ AI工场 | 按账号+商品建立图片项目;只读拉取蝦皮原主图 URL;单击原图下载进入照片池;选择源图后用 cmhub 托管模型生成多张主图/详情图候选;管理 AI工场完整提示词模板;终选拖放与导出由后续任务接入 | 不触线上,中 | 任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 @@ -162,7 +162,7 @@ - cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。刷新别名/测试连接使用当前输入框内容发起请求,但不自动保存 URL/API Key,成功文案需提醒“记得点保存设置持久化”。 - T-532 需求:测试连接/查余额成功时,如果 cmhub `/balance` 返回账号名、用户名或邮箱等可识别信息,成功提示应显示 `cmhub 账号「<账号名>」连接成功:...`,让用户确认当前 API Key 属于哪个 cmhub 账号;当前接口结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,优先显示 `account.display_name`,没有时再用 `account.username` / `user` 等兜底;邮箱需脱敏,没有账号信息时保留 `cmhub 连接成功:...` 兜底文案。 - API Key 存在 `data/config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - - 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。 + - 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示“默认档 / 高质量档 / 省点档”、展示名、单价/需原图提示;网关临时不可达时保留已存别名。 - 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。 - T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。 @@ -195,7 +195,7 @@ - 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。 - 照片池展示原图、生成主图、生成详情图和在途/失败任务状态;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。 - 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。 -- 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面只显示扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 +- 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面显示当前托管档位(默认/高质量/省点)、生图别名、扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 - 「继续查询任务」会恢复当前项目已提交、生成中或下载失败但已有 `task_id` 的 cmhub 生图任务;恢复时只 poll/download 原任务,不再次 submit,不重复扣点。照片池中的任务行显示排队/生成/失败/过期/停止状态,并附带扣点、余额和 `call_id`,便于运营和技术排障。 - 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。 - 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。 diff --git a/docs/tasks/T-598.md b/docs/tasks/T-598.md index 7979465..5d7da80 100644 --- a/docs/tasks/T-598.md +++ b/docs/tasks/T-598.md @@ -3,7 +3,7 @@ id: T-598 title: AI工场托管模型档位展示、默认别名与日志口径收口 phase: 8 deps: [T-597] -status: TODO +status: DONE created: 2026-07-11 --- @@ -37,4 +37,11 @@ T-597 选出 GPT / OpenAI 托管模型档位后,用户仍不应该看到一堆 ## 执行记录 -(完成后记录 UI 改动、文档同步和验证命令。) +2026-07-11 完成: + +- 新增 `app/cmhub_models.py`,统一 cmhub 托管模型 alias 的中文档位识别与展示文案:默认档 / 高质量档 / 省点档,优先使用 `/models` 返回的 `display_name/tags/recommended_for/tier/prices`,缺失时保守兜底。 +- ⑤设置页 cmhub 生文/生图别名下拉改为展示“档位 + 展示名/别名 + 扣点 + 需参考图”,仍只把 alias 写入 `ai.cmhub.title_alias/image_alias`。 +- ⑥AI工场生成区显示当前使用的 cmhub 托管档位和生图别名;开始生成日志、余额/扣点提示同步使用中文档位口径。 +- AI工场 worker 日志 detail 进一步隐藏 URL 和 `/api`、`/generated/images` 等接口/静态路径,避免把上游路径暴露给用户。 +- 同步 `docs/04-architecture.md`、`docs/routes.md`、`docs/api.md` 的模型展示与日志口径。 +- 验证:主工作树 `python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`py -3.10 -m unittest discover -s tests -p "test_cmhub_models.py"` 通过;`py -3.10` 指定新增/修改的 3 个 GUI 测试通过;`git diff --check` 通过。主工作树全量 `py -3.10 -m unittest discover -s tests` 失败 3 项,均由本任务外的默认封面提示词脏改动导致(期望 `papa1`,实际未跟踪/删除文件让默认模板显示为 `默认` 的 mojibake)。已另建干净 worktree 只应用 T-598 diff,`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(375 个测试)、`git diff --check` 全部通过。 diff --git a/tests/test_cmhub_models.py b/tests/test_cmhub_models.py new file mode 100644 index 0000000..f206099 --- /dev/null +++ b/tests/test_cmhub_models.py @@ -0,0 +1,49 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app import cmhub_models + + +class CMHubModelDisplayTests(unittest.TestCase): + def test_alias_label_prefers_display_name_tier_and_points(self): + label = cmhub_models.alias_label( + { + "alias": "image-hq", + "display_name": "商品图高质量", + "operation_type": "image", + "tier": "high_quality", + "requires_image": True, + "prices": [{"resolution": "1K", "points_cost": 5}], + } + ) + + self.assertIn("高质量档", label) + self.assertIn("商品图高质量", label) + self.assertIn("1K:5点", label) + self.assertIn("需参考图", label) + self.assertNotIn("OpenAI", label) + + def test_tier_detection_uses_tags_and_safe_default(self): + self.assertEqual( + cmhub_models.TIER_ECONOMICAL, + cmhub_models.model_tier({"alias": "draft", "tags": ["high-volume", "low-cost"]}), + ) + self.assertEqual( + cmhub_models.TIER_HIGH_QUALITY, + cmhub_models.model_tier({"alias": "image-hd"}), + ) + self.assertEqual(cmhub_models.TIER_DEFAULT, cmhub_models.model_tier({"alias": "image"})) + + def test_configured_alias_summary_is_user_facing(self): + summary = cmhub_models.configured_alias_summary("image-hd") + + self.assertIn("cmhub 托管高质量档", summary) + self.assertIn("生图别名 image-hd", summary) + self.assertIn("扣点以返回结果为准", summary) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui.py b/tests/test_gui.py index 775619d..f0904e1 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -41,6 +41,7 @@ from app.gui import ( WriteBackWorker, ) from app.gui import file_manager +import app.gui.workers as gui_workers from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog from app.gui.main_window import _fit_and_center_window @@ -649,6 +650,73 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_image_studio_generation_log_uses_cmhub_tier_summary(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.ai_config(cfg) + cfg["ai"]["backend"] = "cmhub" + cfg["ai"]["cmhub"]["image_alias"] = "image-hd" + db.init_db(cfg["db_path"]) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + source = image_studio.add_asset( + project.id, + "original", + local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), + path=cfg["db_path"], + ) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + tab._select_project(project.id) + tab._select_source_asset(source) + tab.prompt_edit.setPlainText("生成商品主图") + + class FakeStudioWorker: + def __init__(self): + self.progress = DummySignal() + self.log = DummySignal() + self.finished = DummySignal() + self.failed = DummySignal() + + def cancel(self): + pass + + fake_worker = FakeStudioWorker() + with mock.patch( + "app.gui.tabs.image_studio.ImageStudioGenerateJobsWorker", + return_value=fake_worker, + ), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=FakeThread()): + tab.start_generation() + + log_text = tab.log_view.toPlainText() + self.assertIn("cmhub 托管高质量档", log_text) + self.assertIn("生图别名 image-hd", log_text) + tab._on_generate_progress({"points_balance": 66, "points_cost": 2}) + self.assertIn("高质量档", tab.billing_label.text()) + self.assertIn("余额 66", tab.billing_label.text()) + self.assertIn("本张扣点 2", tab.billing_label.text()) + + self.assert_removed(temp_dir) + + def test_image_studio_event_log_hides_provider_urls(self): + message = gui_workers._format_image_studio_event( + { + "step": "cover_poll", + "result": "failed", + "detail": "GET https://cmhub.example.com/api/v1/generate/image/tasks/t1 failed /generated/images/a.png", + } + ) + + self.assertIn("请求 cmhub", message) + self.assertIn("[接口路径已隐藏]", message) + self.assertNotIn("https://", message) + self.assertNotIn("/api/v1", message) + self.assertNotIn("/generated/images", message) + def test_startup_update_gate_forced_blocks_and_opens_download(self): boxes = [] @@ -1793,8 +1861,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("title-priced", title_aliases) self.assertIn("title-saved", title_aliases) self.assertNotIn("title-free", title_aliases) + self.assertIn("默认档", tab.cmhub_title_alias_combo.itemText(0)) self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0)) self.assertTrue(any("需参考图" in label for label in image_labels)) + self.assertTrue(any("默认档" in label for label in image_labels)) self.assertIn("余额 55", tab.cmhub_result_label.text()) self.assert_removed(temp_dir)