feat: tighten ai concurrency settings

This commit is contained in:
chengma
2026-07-08 08:55:01 +08:00
parent c2ae126aaf
commit 56e96d3ae1
10 changed files with 103 additions and 12 deletions
+30
View File
@@ -40,6 +40,10 @@ CMHUB_CONFIG_PATH = os.path.join(default_data_dir(), "config", "cmhub.json")
CATEGORIES = {"text", "image"}
API_TYPES = {"chat", "images_edits", "auto"}
AI_BACKENDS = {"direct", "cmhub"}
AI_CONCURRENCY_MIN = 1
AI_CONCURRENCY_MAX = 5
AI_RETRY_MIN = 0
AI_RETRY_MAX = 10
RUNTIME_CONFIG_KEYS = {
"config_path",
"ai_models_path",
@@ -350,12 +354,38 @@ def _normalize_config_values(config):
return config
ai = config.get("ai")
if isinstance(ai, dict):
ai["title_concurrency"] = _clamp_int(
ai.get("title_concurrency"),
AI_CONCURRENCY_MIN,
AI_CONCURRENCY_MAX,
DEFAULT_CONFIG["ai"]["title_concurrency"],
)
ai["image_concurrency"] = _clamp_int(
ai.get("image_concurrency"),
AI_CONCURRENCY_MIN,
AI_CONCURRENCY_MAX,
DEFAULT_CONFIG["ai"]["image_concurrency"],
)
ai["retry"] = _clamp_int(
ai.get("retry"),
AI_RETRY_MIN,
AI_RETRY_MAX,
DEFAULT_CONFIG["ai"]["retry"],
)
cmhub = ai.get("cmhub")
if isinstance(cmhub, dict):
cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", ""))
return config
def _clamp_int(value, minimum, maximum, default):
try:
number = int(value)
except (TypeError, ValueError):
number = int(default)
return min(int(maximum), max(int(minimum), number))
def _assert_no_secrets(config):
def visit(value, path):
if isinstance(value, dict):
+9 -3
View File
@@ -142,13 +142,19 @@ class SettingsTab(QWidget):
self.default_image_model_combo.setObjectName("defaultImageModelCombo")
self.title_concurrency_spin = QSpinBox()
self.title_concurrency_spin.setObjectName("titleConcurrencySpin")
self.title_concurrency_spin.setRange(1, 64)
self.title_concurrency_spin.setRange(
appconfig.AI_CONCURRENCY_MIN,
appconfig.AI_CONCURRENCY_MAX,
)
self.image_concurrency_spin = QSpinBox()
self.image_concurrency_spin.setObjectName("imageConcurrencySpin")
self.image_concurrency_spin.setRange(1, 64)
self.image_concurrency_spin.setRange(
appconfig.AI_CONCURRENCY_MIN,
appconfig.AI_CONCURRENCY_MAX,
)
self.retry_spin = QSpinBox()
self.retry_spin.setObjectName("retrySpin")
self.retry_spin.setRange(0, 20)
self.retry_spin.setRange(appconfig.AI_RETRY_MIN, appconfig.AI_RETRY_MAX)
self.resolution_combo = QComboBox()
self.resolution_combo.setObjectName("resolutionCombo")
for resolution in self.RESOLUTION_ITEMS:
+1 -1
View File
@@ -411,7 +411,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
- 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。
- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。⑤仍只展示一个「图片并发」设置;当 backend 为 cmhub 且用户填写值大于 5 时,运行时自动按 5 执行,并在②运行日志中显示“图片并发 X,cmhub实际生图并发 5,下载并发 5”。
- 并发数、重试、分辨率、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”。
提示词管理:
+1 -1
View File
@@ -152,7 +152,7 @@
| T-540 | 打包版本号与 release 产物命名统一 | T-537, T-538, T-524 | 参考 `D:\chengma\cmbot` 的轻量版本控制方式,但本阶段不引入 Launcher/manifest/自动更新。方案:① 新增 `app/version.py`,统一维护 `APP_NAME="蝦皮圈優化助手"`、`APP_CODE_NAME="cmshopee"`、`APP_VERSION`,作为 GUI 标题、打包脚本、release 目录、zip 文件名和 `version.txt` 的唯一版本源;② 主窗口标题改为 `蝦皮圈優化助手 v<APP_VERSION>`,PySide6 缺失提示继续读中文品牌名;③ `scripts/build_exe.ps1` 从 `app/version.py` 读取版本,并固定通过 Windows Python Launcher 调用 `py -3.10`,返回版本必须是 `3.10.x`;④ PyInstaller 仍输出 `dist\cmshopee\` 作为中间产物,再组装 `release\cmshopee-<APP_VERSION>\` 和 `release\cmshopee-<APP_VERSION>-portable.zip`,发布目录内写 `version.txt`(无 BOM)和中文 `README.txt`;⑤ 发布目录/zip 仍不得包含 `data/`、DB、配置、图片、日志、Chrome 登录态或运营 Excel;当前 PyInstaller 6.11.1 onedir 结构必须包含 `cmshopee.exe` + `_internal\`,脚本会校验 `_internal\` 存在;⑥ 测试覆盖版本文件解析、窗口标题版本、构建脚本 release 命名/排除数据/固定 `py -3.10` 打包。边界:只做手动发版版本号一致性和 release 组装;不做在线更新、启动器、manifest、sha256 下载校验、强制更新或自动替换程序目录 | DONE |
| T-543 | 状态栏语义色与统一提示入口 | T-511, T-523, T-536 | 需求:当前左下角状态栏提示几乎都通过 `statusBar().showMessage(...)` 直接写入,颜色一致,用户很难快速区分“已保存/已完成”“正在处理”“没有可生成任务”“未登录/数据目录不可写”等不同严重程度。方案:① 在 `MainWindow` 增加统一入口 `show_status(message, level="muted")`,使用 `docs/ui-color-design.md` 已定语义色板映射 `muted/info/success/warning/danger`,内部先设置状态栏文字颜色再 `showMessage()`,普通/就绪提示必须重置为 muted 或默认色,避免上一条红/绿状态残留;② 逐步替换主窗口和 ①②③④⑤ Tab 传入的 `status_callback=self.statusBar().showMessage` 以及直接调用,新增代码必须显式传 level,不长期依赖中文关键词猜测;③ 颜色口径:普通/导航/就绪用 muted 或默认,进行中用 info,保存/完成/回写成功用 success,当前筛选无任务、请先采集/配置等用户可处理问题用 warning,失败、阻断、未登录、数据目录不可写、点数不足等中止当前动作的问题用 danger;④ 状态栏只改文字色,不做大面积背景,不替代弹窗、空状态、按钮禁用、运行日志或任务状态列;⑤ 复用 `app/gui/widgets.py` 语义色常量,不新增第二套颜色;⑥ GUI 单测覆盖 `show_status()` 各等级颜色、普通提示会清掉前一条错误颜色,以及至少一个保存成功/无任务/失败路径调用正确等级。边界:只改 GUI 提示层和测试;不改业务流程、DB schema、AI HTTP、Excel、Shopee/CDP、运行日志持久化 | DONE |
| T-544 | 启动时检查强制升级(第一版:提示下载,不自动覆盖) | T-540, T-538, T-524 | 需求:程序启动后先请求服务器版本接口;如果线上版本高于当前程序版本且服务端要求强制升级,则必须弹窗阻断进入主界面,用户只能下载新版或退出。第一版方案:① 新增 `app/update_check.py`,读取当前版本 `APP_VERSION`,请求固定版本接口(后续可放配置/构建常量),超时短且错误中文化;② 服务端返回建议结构:`latest_version`、`min_supported_version`、`force_update`、`download_url`、`sha256`、`message`。比较规则:当前版本 `< min_supported_version` 必须升级;或 `force_update=true` 且当前版本 `< latest_version` 必须升级;版本比较使用语义化数字段比较,不用字符串字典序;③ 启动流程在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查;命中强制升级时弹中文阻断框,显示当前版本/线上版本/升级说明,按钮只保留「下载新版」和「退出程序」,点击下载用系统浏览器打开 `download_url` 后退出或保持阻断,不允许继续进入主界面;④ 网络失败、接口超时、JSON 非法、版本字段缺失时第一版**允许进入软件**,只在本地诊断日志记录/可选状态提示,不因服务器或用户断网导致全员不可用;只有服务端明确返回强制升级才阻断;⑤ 第一版**不自动下载并覆盖** `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;用户仍按打包文档手动下载 zip、关闭程序、覆盖程序文件和 `_internal/`、保留 `data/`;⑥ 单测覆盖版本比较、强制升级判定、网络失败允许进入、非法响应允许进入、启动流程强制阻断弹窗和打开下载链接。边界:只做启动检查与强制提示,不改 DB schema、业务流程、AI、Excel、Shopee/CDP,不自动替换任何本地文件 | DONE |
| T-547 | ⑤ 并发/重试上限收紧(标题/图片并发≤5、失败重试≤10) | T-517, T-545 | 需求:把「标题并发数」「图片并发数」上限设为 5、「失败重试次数」上限设为 10。现状:`app/gui/tabs/settings.py` 里 `title_concurrency_spin`/`image_concurrency_spin` 范围 `(1,64)`、`retry_spin` 范围 `(0,20)`;默认 4/4/2;cmhub 生图内部已按 `min(image_concurrency,5)` 限流(T-545),本任务把用户可选上限与之对齐。方案:① UI 范围——`title_concurrency_spin.setRange(1,5)`、`image_concurrency_spin.setRange(1,5)`、`retry_spin.setRange(0,10)`;默认 4/4/2 不变(在新范围内)。② 真实约束(不只 UI)——`app/appconfig.py` 加载/保存 `ai` 段时把 `title_concurrency`/`image_concurrency` 夹到 `1..5`、`retry` 夹到 `0..10`,兼容旧 `config.json` 里的超限值(如 `image_concurrency=64` 归一到 5、`retry=20` 归一到 10),避免手工配置或历史配置绕过上限。③ 生成编排语义不变:T-545 的 `min(image_concurrency,5)` 仍成立、与新上限一致;标题阶段用 `title_concurrency`(现≤5)。测试:`test_gui` 断言三个 spin 的新范围;`test_appconfig` 断言超限 config 值被夹紧、正常值不变。边界:只改⑤设置页 spin 范围 + `appconfig` 夹紧/校验 + 单测;不改默认值、生成编排逻辑、cmhub HTTP、DB、Excel、CDP/Shopee | TODO |
| T-547 | ⑤ 并发/重试上限收紧(标题/图片并发≤5、失败重试≤10) | T-517, T-545 | 需求:把「标题并发数」「图片并发数」上限设为 5、「失败重试次数」上限设为 10。现状:`app/gui/tabs/settings.py` 里 `title_concurrency_spin`/`image_concurrency_spin` 范围 `(1,64)`、`retry_spin` 范围 `(0,20)`;默认 4/4/2;cmhub 生图内部已按 `min(image_concurrency,5)` 限流(T-545),本任务把用户可选上限与之对齐。方案:① UI 范围——`title_concurrency_spin.setRange(1,5)`、`image_concurrency_spin.setRange(1,5)`、`retry_spin.setRange(0,10)`;默认 4/4/2 不变(在新范围内)。② 真实约束(不只 UI)——`app/appconfig.py` 加载/保存 `ai` 段时把 `title_concurrency`/`image_concurrency` 夹到 `1..5`、`retry` 夹到 `0..10`,兼容旧 `config.json` 里的超限值(如 `image_concurrency=64` 归一到 5、`retry=20` 归一到 10),避免手工配置或历史配置绕过上限。③ 生成编排语义不变:T-545 的 `min(image_concurrency,5)` 仍成立、与新上限一致;标题阶段用 `title_concurrency`(现≤5)。测试:`test_gui` 断言三个 spin 的新范围;`test_appconfig` 断言超限 config 值被夹紧、正常值不变。边界:只改⑤设置页 spin 范围 + `appconfig` 夹紧/校验 + 单测;不改默认值、生成编排逻辑、cmhub HTTP、DB、Excel、CDP/Shopee | DONE |
## 里程碑
+1 -1
View File
@@ -312,7 +312,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。
- `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,cmhub 生图请求和图片下载读取等待固定 650s,且生图读超时不自动重发。
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 生图使用内部实际并发 `min(image_concurrency, 5)`,拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;下载失败最多安全重试 3 次,只复用同一个 `image_url`,不会重新调用 cmhub 生图接口;下载总耗时超过 20 秒时写“图片下载较慢”警告;direct 兼容路径暂保持原 `image_concurrency` 语义。
- 调试 cmhub 图片 URL:默认不在日志或 DB 中记录完整 `image_url`。若本机调试需要查看,启动程序前设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的“cmhub 图片 URL”,但该调试行不写入 SQLite `run_log_events`。
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -162,7 +162,7 @@
- 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。
- T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
- direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧。
- 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;⑤「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 650 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。
- 路径与端口(T-501b/T-506/T-539 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、CDP 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
+8
View File
@@ -1483,3 +1483,11 @@
- 代码:`app/ai.py` 的 cmhub `image_url` 下载层新增最多 3 次安全重试,只复用同一个 `image_url`,不重新调用 cmhub 生图接口,避免重复扣点;下载总耗时超过 20 秒时发出 `cover_download` warning 事件。`app/gui/workers.py` 将该事件显示为“图片下载较慢”运行日志。
- 文档:同步 `docs/04-architecture.md`、`docs/api.md` 和 `docs/current-state.md`。
- 验证:`py -3.10 -m unittest discover -s tests -p "test_ai.py"` 通过(30 tests);`py -3.10 -m unittest discover -s tests -p "test_gui.py"` 通过(102 tests);`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(240 tests)。
## 【2026-07-08】T-547 · ⑤并发/重试上限收紧
- 需求:⑤设置页把「标题并发数」「图片并发数」上限收紧到 5,把「失败重试次数」上限收紧到 10,并避免历史 `config.json` 或手工配置绕过 UI 限制。
- 代码:`app/gui/tabs/settings.py` 的标题/图片并发 spin 范围改为 1..5,失败重试 spin 范围改为 0..10;`app/appconfig.py` 新增 AI 并发/重试范围常量和 `_clamp_int()`,加载/保存配置时把 `ai.title_concurrency`、`ai.image_concurrency`、`ai.retry` 夹紧到新范围,默认值 4/4/2 不变。
- 测试:`tests/test_appconfig.py` 覆盖旧配置超限值会归一到 1/5/10、正常值不变;`tests/test_gui.py` 覆盖三个 spin 的新范围。
- 文档:`docs/06-tasks.md` 将 T-547 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md` 和 `docs/current-state.md`。
- 验证:`py -3.10 -m unittest discover -s tests -p "test_appconfig.py"` 通过(15 tests);`py -3.10 -m unittest discover -s tests -p "test_gui.py"` 通过(102 tests);`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(244 tests)。
+39
View File
@@ -62,6 +62,45 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_ai_concurrency_and_retry_are_clamped(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
with open(config_path, "w", encoding="utf-8") as fh:
json.dump(
{
"ai": {
"title_concurrency": 0,
"image_concurrency": 64,
"retry": 20,
}
},
fh,
)
loaded = appconfig.load_config(config_path)
ai = appconfig.ai_config(loaded)
self.assertEqual(1, ai["title_concurrency"])
self.assertEqual(5, ai["image_concurrency"])
self.assertEqual(10, ai["retry"])
saved = appconfig.save_config(
{
"ai": {
"title_concurrency": 3,
"image_concurrency": 4,
"retry": 2,
}
},
path=config_path,
)
ai = appconfig.ai_config(saved)
self.assertEqual(3, ai["title_concurrency"])
self.assertEqual(4, ai["image_concurrency"])
self.assertEqual(2, ai["retry"])
self.assert_removed(temp_dir)
def test_data_paths_resolve_under_default_data_dir(self):
with self.make_temp_dir() as temp_dir:
data_root = os.path.join(temp_dir, "data")
+7
View File
@@ -788,6 +788,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
self.addCleanup(tab.close)
self.assertEqual(1, tab.title_concurrency_spin.minimum())
self.assertEqual(5, tab.title_concurrency_spin.maximum())
self.assertEqual(1, tab.image_concurrency_spin.minimum())
self.assertEqual(5, tab.image_concurrency_spin.maximum())
self.assertEqual(0, tab.retry_spin.minimum())
self.assertEqual(10, tab.retry_spin.maximum())
text_roles = [
tab.default_text_model_combo.itemData(index)
for index in range(tab.default_text_model_combo.count())