From c47e9dee95d8e65708b368fb8cfb5833e806d906 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 23 Jun 2026 11:44:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ai-outfit):=20=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=94=B9=E4=B8=BA=E3=80=8C=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=AE=9A=E5=90=8D=E3=80=8D=E3=80=81=E5=8E=BB=E6=8E=89=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=20(=C2=A719.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标题模型是「配一次就固定」的,不像分辨率/话术需每次选;左栏 ~360px 已叠两个 提示词组,去掉下拉更干净,还消掉「误选图片模型」的坑。模型名放 app_config.title_model (默认 GPT-5.5 文本),运行时按名字查 ai_models.json —— 换模型只改配置不改代码(docs/11 §17.3/§17.6)。 - ai_outfit_panel.py:_build_title_group 去掉标题模型下拉;apply_config 改记 title_model 名字、_emit_config 不再写(UI 不覆盖);_selected_title_model_config → 纯函数 _find_title_model(按 name 查 + 校验,返回 (config, error))+ UI 包装 _resolve_title_model_config;找不到/未配置/图片接口时明确报错 - config_service:DEFAULT_CONFIG.title_model 默认改为 "GPT-5.5 文本" - docs/ai_models.sample.json:补一条 name=「GPT-5.5 文本」的 chat 文本模型样板(占位 key) - 测试:面板断言无标题模型下拉;_find_title_model 命中/缺失/无模型三例;全套 py37 通过 - 冒烟:seeded ai_models.json + apply_config → title_model 名字解析到 gpt-5.5(chat),缺失→报错 Co-Authored-By: Claude Opus 4.8 --- docs/11-ai-outfit.md | 32 ++++++++++++----- docs/ai_models.sample.json | 13 ++++++- src/app/widgets/ai_outfit_panel.py | 56 +++++++++++++++++------------- src/services/config_service.py | 2 +- tasks.md | 40 ++++++++++++++++----- tests/test_ai_outfit_panel.py | 49 +++++++++++++++++++++++--- 6 files changed, 145 insertions(+), 47 deletions(-) diff --git a/docs/11-ai-outfit.md b/docs/11-ai-outfit.md index e20e641..467346d 100644 --- a/docs/11-ai-outfit.md +++ b/docs/11-ai-outfit.md @@ -250,7 +250,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 三栏布局: -- **左栏(数据源 + 标题/话术创作,~360px)**:`Excel` / `输出` 各为**行内一行**(标签 + 路径 + 浏览,省纵向空间);数据源概览(共 N 行 / 完成 / 待处理 / 失败);**「标题生成」组(§17)在上**:标题生成提示词编辑 +「标题模型」下拉 +「保存」+「生成标题」;**「穿搭生成话术」组在下**:话术编辑 +「保存话术」+「插入标题」+ 多套模板下拉(§7.2)。**最终提示词预览已移除(§17)**。图片 AI 模型下拉不在此(见右栏);标题模型下拉在本栏标题组内。 +- **左栏(数据源 + 标题/话术创作,~360px)**:`Excel` / `输出` 各为**行内一行**(标签 + 路径 + 浏览,省纵向空间);数据源概览(共 N 行 / 完成 / 待处理 / 失败);**「标题生成」组(§17)在上**:标题生成提示词编辑 +「保存」+「生成标题」(**无标题模型下拉**,模型由 `title_model` 配置定名,§17.3);**「穿搭生成话术」组在下**:话术编辑 +「保存话术」+「插入标题」+ 多套模板下拉(§7.2)。**最终提示词预览已移除(§17)**。图片 AI 模型下拉在右栏;标题模型不放界面。 - **中栏(结果 + 明细)**:顶部「最近结果」缩略图条——**只展示已完成的人物效果图**,新图自动加到最左、首图标「最新」,单击看大图、右键开所在文件夹;下方「处理明细」表(行 / 标题 / 货号 / 衣服图 / 状态 / 结果或原因),按 Excel 行顺序,状态用 完成 / 失败 / 生成中 / 待处理 / 跳过 徽章。 - **右栏(设置 + 运行,~400px)**:生成设置标题与「重试上次失败的行」同排;图片并发数 / 新请求间隔 / 单任务冷却 / 失败重试 / 分辨率 / JPG 质量保持 **3 列 × 2 行**;`AI 模型` 标签与下拉框同排;本次进度 + 统计(完成 / 失败 / 待处理);开始生成 / 停止生成同排;导出失败清单 / 打开输出目录同排;节省的纵向空间给实时日志(含 §8 心跳行)。 @@ -262,7 +262,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 ### 10.1 已知问题:左栏内容被中间面板裁掉(下拉框撑宽) -> **§17 更新:已作废。** 撑宽元凶「样本行下拉」随预览一并移除(§17);保留本节仅作历史记录。新「标题模型」下拉仍套用下方「修复方向」的 elide 策略,防止再撑宽。 +> **§17 更新:已作废。** 撑宽元凶「样本行下拉」随预览一并移除(§17);保留本节仅作历史记录。标题模型改为「配置定名」、不放界面(§17.3),左栏也不再有新下拉。 **现象**:「2 AI 穿搭」左栏组件右侧显示不完整,像被中间面板遮挡了一部分。 @@ -419,20 +419,36 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 ### 17.3 模型与提示词 -- **标题模型独立下拉**:图片/文本模型类型不同(图片模型返回图、不返回文字),左栏标题组内单独放「标题模型」下拉,与图片模型同源(`load_ai_models()`)但**独立记忆**所选(`app_config` 新键 `title_model`)。 +- **标题模型「配置定名」,不放下拉**(§17.6 决策):图片/文本模型类型不同(图片模型返回图、不返回文字),且标题模型是「配一次就固定」的东西,不像分辨率/话术需要每次选。因此**去掉「标题模型」下拉**,改由配置决定用哪条: + - `app_config.json` 的 `title_model` 记**模型名字**(对应 `ai_models.json` 某条的 `name`),默认 `"GPT-5.5 文本"`。 + - 运行时按该名字在 `load_ai_models()` 里**查**对应模型来用;界面上没有下拉,效果等于"固定用 gpt-5.5"。 + - **换模型只改配置、不改代码**:管理员在 `ai_models.json` 加/换文本模型条目,再把 `title_model` 改成它的 `name` 即可,无需重新打包。 + - **找不到/未配置时明确报错**(不静默猜测):「未找到标题模型『{名字}』,请在 ai_models.json 添加可生成文字的模型(chat/gemini),并在 app_config 的 title_model 指定其 name」。 + - 选到的模型若是纯图片接口(images/images_edits),由 `AiTextClient` 在调用时明确报错提示换文本模型。 +- **`ai_models.json` 需有一条文本/视觉模型**(管理员维护,含真实 key,不入库):`api_type` 设 `chat`、`model` 填中转的真实 id(如 `gpt-5.5`)、`name` 与 `title_model` 一致(默认 `GPT-5.5 文本`)。`docs/ai_models.sample.json` 已含 chat 示例可参照。 - **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板);默认文案面向电商女装(结合款式/版型/颜色/印花,输出一行中文标题,不加引号/表情/促销词)。 ### 17.4 界面与运行 -- **左栏布局**(§10 已同步):「标题生成」组在上、「穿搭生成话术」组在下;预览块移除。标题组含:标题提示词编辑、`标题模型`下拉、「保存」、「生成标题」。 -- **运行**:`_TitleWorker(QObject)` 跑在 `QThread`(仿 `_OutfitWorker`),按行顺序逐行生成、立即回填、刷新该行 GUI;失败记日志、跳过该行、继续;用右栏「新请求间隔」做行间节流;温和停止。 +- **左栏布局**(§10 已同步):「标题生成」组在上、「穿搭生成话术」组在下;预览块移除。标题组含:标题提示词编辑、「保存」、「生成标题」(**无标题模型下拉**,模型由 `title_model` 配置定名,§17.3)。 +- **运行**:`_TitleWorker(QObject)` 跑在 `QThread`(仿 `_OutfitWorker`),按行顺序逐行生成、立即回填、刷新该行 GUI;失败记日志、跳过该行、继续;用右栏「新请求间隔」做行间节流;温和停止。模型用 `_resolve_title_model_config()`(按 `title_model` 名字查 `ai_models.json`)。 - **互斥**:「生成标题」与「开始生成」运行时互斥(避免同表并发写)。 - 进度/结果走中栏「处理明细」表与右栏日志(不写 Excel 状态列)。 ### 17.5 验收要点(标题生成) -- 左栏有「标题生成」组(提示词 + 标题模型下拉 + 生成标题按钮),无预览块。 +- 左栏有「标题生成」组(提示词 + 保存 + 生成标题按钮),**无标题模型下拉**、无预览块。 +- `app_config.title_model` 指向的模型在 `ai_models.json` 存在 → 生成走该模型;改 `title_model` 名字即换模型,无需改代码。 - 选印花生成的 Excel(A=印花名、C=目录),点「生成标题」→ 每行 A 被改写为 AI 标题、明细表标题列实时刷新、D/E/F 未动。 -- 标题模型选到纯图片接口(images/images_edits)时明确报错提示换模型。 +- `title_model` 找不到对应模型 / 指向纯图片接口(images/images_edits)时明确报错提示。 - 生成完重载 Excel,紧接「开始生成」跑图用的是新标题。 -- 文本解析、`generate_title`(单文件/目录取首图/无图失败/客户端异常)、`write_title_result`(只改 A)单测通过,Python 3.7。 +- 文本解析、`generate_title`(单文件/目录取首图/无图失败/客户端异常)、`write_title_result`(只改 A)、`_resolve_title_model_config`(命中/缺失报错)单测通过,Python 3.7。 + +### 17.6 决策:标题模型「配置定名」而非下拉 + +最初实现(§19.18)给标题模型放了独立下拉。落地后复盘改为**配置定名、去下拉**: + +- **去下拉的理由**:标题模型是「配一次就固定」的(一个店铺/平台通常固定一个文本模型),不像分辨率/话术需要每次切换;左栏仅 ~360px、已叠两个提示词组,少一个下拉更干净;还消掉「误选图片模型导致报错」的坑。 +- **为什么不把模型名写死在代码里**:写死后换模型/升级要改 `.py` 重新打包分发。改放 `app_config.title_model`(默认 `GPT-5.5 文本`)后,换模型只改配置一行、重启生效,对非技术管理员友好。 +- **界面效果一致**:两种做法用户都看不到下拉、都固定一个模型;差别只在「换模型」的代价(改配置 vs 改代码)。 +- 标题提示词暂不做多套模板(同 §17 决策;真有多套切换需求再按 §7.2 范式补)。 diff --git a/docs/ai_models.sample.json b/docs/ai_models.sample.json index 81ad2dd..b359bcd 100644 --- a/docs/ai_models.sample.json +++ b/docs/ai_models.sample.json @@ -1,6 +1,17 @@ { - "_说明": "复制此文件到 ~/.cmbot/config/ai_models.json(Windows: %USERPROFILE%\\.cmbot\\config\\ai_models.json),把 api_key/url/model 换成真实值。含真实 key 的 ai_models.json 不要提交 git。界面「AI 模型」下拉显示每项的 name;下面列了 4 种 api_type 示例,按你的中转接口选一种保留即可,其余可删。", + "_说明": "复制此文件到 ~/.cmbot/config/ai_models.json(Windows: %USERPROFILE%\\.cmbot\\config\\ai_models.json),把 api_key/url/model 换成真实值。含真实 key 的 ai_models.json 不要提交 git。界面「AI 模型」下拉显示每项的 name;下面列了图片用的 4 种 api_type 示例(按你的中转接口选一种保留即可)+ 1 个标题生成用的文本模型。", + "_标题模型说明": "AI 穿搭页「生成标题」用文本模型(返回文字、不返回图)。把下面 name=「GPT-5.5 文本」这条的 url/model/api_key 换成你的真实文本模型,并让 app_config.json 的 title_model 与它的 name 一致(默认即「GPT-5.5 文本」)。详见 docs/11-ai-outfit.md §17.3。", "models": [ + { + "name": "GPT-5.5 文本", + "url": "https://your-relay.example.com/v1/chat/completions", + "model": "gpt-5.5", + "api_key": "sk-REPLACE_WITH_YOUR_KEY", + "api_type": "chat", + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": {} + }, { "name": "Nano Banana · 中转(chat)", "url": "https://your-relay.example.com/v1/chat/completions", diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index a558c82..ea3a43f 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -234,6 +234,7 @@ class AiOutfitPanel(QWidget): self._title_thread = None # 标题生成线程(§17) self._title_worker = None self._title_fail = 0 # running failure count for title stats + self._title_model_name = "" # configured 标题模型 name (§17.3 配置定名) self._row_to_table = {} # excel row_index -> table row self._failures = [] # list of OutfitResult (failed) self._last_resolution = "" # for "value actually changed" check (§10.2) @@ -373,7 +374,10 @@ class AiOutfitPanel(QWidget): return scroll def _build_title_group(self): - """标题生成组(§17):提示词 + 标题模型下拉 + 保存 + 生成标题。""" + """标题生成组(§17):提示词 + 保存 + 生成标题。 + + 无标题模型下拉——模型由 app_config.title_model「配置定名」(§17.3/§17.6)。 + """ box = QGroupBox() v = QVBoxLayout(box) v.addWidget(QLabel("标题生成提示词")) @@ -381,13 +385,6 @@ class AiOutfitPanel(QWidget): self._title_prompt_edit.setMinimumHeight(110) v.addWidget(self._title_prompt_edit) - model_row = QHBoxLayout() - model_row.addWidget(QLabel("标题模型")) - self._title_model_combo = QComboBox() - self._compact_combo(self._title_model_combo) - model_row.addWidget(self._title_model_combo, stretch=1) - v.addLayout(model_row) - btn_row = QHBoxLayout() save_title_btn = QPushButton("保存") save_title_btn.clicked.connect(self._save_title_prompt) @@ -582,10 +579,11 @@ class AiOutfitPanel(QWidget): # 标题生成提示词(单份,§17.3) self._title_prompt_edit.setPlainText(load_title_prompt()) + # 标题模型「配置定名」:记下名字,运行时按名查 ai_models.json(§17.3,无下拉) + self._title_model_name = str(config.get("title_model", "") or "") self._models = load_ai_models() self._fill_model_combo(self._model_combo, config.get("outfit_model", "")) - self._fill_model_combo(self._title_model_combo, config.get("title_model", "")) # Snapshot current dropdown values so a later user re-select of the same # item doesn't trigger the info popup (§10.2). @@ -622,7 +620,7 @@ class AiOutfitPanel(QWidget): "outfit_quality": self._quality.currentText(), "outfit_retry_failed": self._retry_failed_chk.isChecked(), "outfit_prompt_name": self._current_prompt_name, - "title_model": self._title_model_combo.currentText() if self._models else "", + # title_model 由 app_config 配置定名(§17.3),UI 不写、不覆盖。 }) # -- left actions --------------------------------------------------- @@ -786,23 +784,34 @@ class AiOutfitPanel(QWidget): if not silent: self.statusBar_message("标题提示词已保存") - def _selected_title_model_config(self): + def _find_title_model(self): + """Resolve the configured 标题模型 → (AiModelConfig|None, error|None). Pure. + + Looks up app_config.title_model (a name) in ai_models.json by 'name' + (matching how models are labelled), so 换模型 is a config edit (§17.3). + """ if not self._models: - QMessageBox.warning( - self, "未配置模型", - "尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。") - return None - idx = self._title_model_combo.currentIndex() - if idx < 0 or idx >= len(self._models): - QMessageBox.warning(self, "未选择标题模型", "请先选择标题模型。") - return None + return None, ("尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json " + "添加可生成文字的模型(chat/gemini)后重试。") + name = (self._title_model_name or "").strip() + data = next((m for m in self._models + if (m.get("name") or m.get("model")) == name), None) + if data is None: + return None, ("未找到标题模型「{}」。请在 ai_models.json 添加可生成文字的" + "模型(chat/gemini),并把 app_config 的 title_model 设为它的 " + "name。".format(name or "(未配置)")) from services.ai_image_service import AiModelConfig, api_config_errors - data = self._models[idx] errors = api_config_errors(data) if errors: - QMessageBox.warning(self, "模型配置有误", ";".join(errors)) + return None, "标题模型「{}」配置有误:{}".format(name, ";".join(errors)) + return AiModelConfig.from_dict(data), None + + def _resolve_title_model_config(self): + config, error = self._find_title_model() + if error: + QMessageBox.warning(self, "标题模型不可用", error) return None - return AiModelConfig.from_dict(data) + return config def _start_title(self): if self._thread is not None or self._title_thread is not None: @@ -811,7 +820,7 @@ class AiOutfitPanel(QWidget): if not excel: QMessageBox.information(self, "提示", "请先选择 Excel 文件。") return - model_config = self._selected_title_model_config() + model_config = self._resolve_title_model_config() if model_config is None: return prompt = self._title_prompt_edit.toPlainText().strip() @@ -905,7 +914,6 @@ class AiOutfitPanel(QWidget): self._title_btn.setText("生成中…" if running else "生成标题") self._start_btn.setEnabled(not running) # mutually exclusive with 开始生成 self._excel_edit.setEnabled(not running) - self._title_model_combo.setEnabled(not running and bool(self._models)) # -- switch info popups (user-only; §10.2) -------------------------- diff --git a/src/services/config_service.py b/src/services/config_service.py index 3957e01..f897b77 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -27,7 +27,7 @@ DEFAULT_CONFIG = { "outfit_quality": "均衡", "outfit_retry_failed": False, "outfit_prompt_name": "默认", # last-selected 话术模板 name (docs/11 §7.2) - "title_model": "", # last-selected 标题模型 name (docs/11 §17.3) + "title_model": "GPT-5.5 文本", # 标题模型名字(对应 ai_models.json 的 name,§17.3 配置定名) } _CONFIG_FILENAME = "app_config.json" diff --git a/tasks.md b/tasks.md index ec97623..0a2f464 100644 --- a/tasks.md +++ b/tasks.md @@ -1297,11 +1297,35 @@ 任务: -- [ ] `src/services/ai_text_service.py`(新建):`AiTextClient(config, session=None)` 复用 `ai_image_service` 管道;`generate_text(prompt, image_path=None)`(chat/gemini 带图视觉文本输出,images/images_edits 抛 `AiTextServiceError`);`extract_text_from_response`(chat/gemini 取文本,返回第一条标题:首个非空行、去序号/引号、单行化) -- [ ] `src/core/ai_title.py`(新建):`render_title_prompt(template, task)`(替换 `{title}`/`{product_id}`,不加图片输出要求尾巴);`generate_title(task, prompt_template, model_config, api_client=None) -> TitleResult`(目录行取 `list_directory_images` 首图,无图失败,never raises) -- [ ] `src/core/models.py`:新增 `TitleResult`(task/success/generated_title/error/attempts) -- [ ] `src/services/excel_service.py`:新增 `write_title_result(excel_path, row_index, title)`(只写 A 列并保存,不动 D/E/F) -- [ ] `src/services/config_service.py`:`DEFAULT_CONFIG` 增 `title_model`;新增 `load_title_prompt`/`save_title_prompt`(`title_prompt.txt`)+ `DEFAULT_TITLE_PROMPT` -- [ ] `src/app/widgets/ai_outfit_panel.py`:移除预览块(`_sample_combo`/`_preview_*`/`_refresh_preview`/`_fill_sample_combo`/`_reload_sample_rows` 及信号连接);新增「标题生成」组(提示词编辑 + `标题模型`下拉 + 保存 + 生成标题)置于话术组上方;`apply_config`/`_emit_config` 接 `title_model`+标题提示词;`_TitleWorker` 顺序逐行生成+立即回填+刷新明细表标题列,完成后重载 Excel;「生成标题」与「开始生成」互斥 -- [ ] 测试:`tests/test_ai_text_service.py`(文本解析含多行只取第一条、images_edits 抛错、payload 含图);`tests/test_ai_title.py`(render、单文件/目录首图/无图失败/异常);`tests/test_excel_service.py` 加 `write_title_result` 只改 A;删预览后 `ai_outfit_panel` 离屏可构建 -- [ ] 验证:相关单测 + 全套 py37 通过(`test_config_service` 的 `ai_models.json` 顺序失败属并行 §19.13 遗留,与本改动无关);离屏冒烟(mock `AiTextClient`):选印花 Excel → 生成标题 → A 列改写、明细表刷新、D/E/F 未动、面板无预览控件 +- [x] `src/services/ai_text_service.py`(新建):`AiTextClient(config, session=None)` 复用 `ai_image_service` 管道;`generate_text(prompt, image_path=None)`(chat/gemini 带图视觉文本输出,images/images_edits 抛 `AiTextServiceError`);`extract_text_from_response`(chat/gemini 取文本,返回第一条标题:首个非空行、去序号/引号、单行化) +- [x] `src/core/ai_title.py`(新建):`render_title_prompt(template, task)`(替换 `{title}`/`{product_id}`,不加图片输出要求尾巴);`generate_title(task, prompt_template, model_config, api_client=None) -> TitleResult`(目录行取 `list_directory_images` 首图,无图失败,never raises) +- [x] `src/core/models.py`:新增 `TitleResult`(task/success/generated_title/error/attempts) +- [x] `src/services/excel_service.py`:新增 `write_title_result(excel_path, row_index, title)`(只写 A 列并保存,不动 D/E/F) +- [x] `src/services/config_service.py`:`DEFAULT_CONFIG` 增 `title_model`;新增 `load_title_prompt`/`save_title_prompt`(`title_prompt.txt`)+ `DEFAULT_TITLE_PROMPT` +- [x] `src/app/widgets/ai_outfit_panel.py`:移除预览块(`_sample_combo`/`_preview_*`/`_refresh_preview`/`_fill_sample_combo`/`_reload_sample_rows` 及信号连接);新增「标题生成」组(提示词编辑 + `标题模型`下拉 + 保存 + 生成标题)置于话术组上方;`apply_config`/`_emit_config` 接 `title_model`+标题提示词;`_TitleWorker` 顺序逐行生成+立即回填+刷新明细表标题列,完成后重载 Excel;「生成标题」与「开始生成」互斥 +- [x] 测试:`tests/test_ai_text_service.py`(文本解析含多行只取第一条、images_edits 抛错、payload 含图);`tests/test_ai_title.py`(render、单文件/目录首图/无图失败/异常);`tests/test_excel_service.py` 加 `write_title_result` 只改 A;删预览后 `ai_outfit_panel` 离屏可构建 +- [x] 验证:相关单测 + 全套 py37 通过(`test_config_service` 的 `ai_models.json` 顺序失败属并行 §19.13 遗留,与本改动无关);离屏冒烟(mock `AiTextClient`):选印花 Excel → 生成标题 → A 列改写、明细表刷新、D/E/F 未动、面板无预览控件 + +> 已提交 `2fa488b`(标题模型当时为下拉,§19.19 改为配置定名)。 + +### 19.19 标题模型改为「配置定名」、去掉下拉 — docs/11 §17.3 / §17.6 + +前置阅读: + +- `docs/11-ai-outfit.md`(§17.3 模型配置定名、§17.6 决策、§10 左栏) +- `src/app/widgets/ai_outfit_panel.py`(`_build_title_group`/`_fill_model_combo`/`_selected_title_model_config`/`apply_config`/`_emit_config`/`_set_title_running`) +- `src/services/config_service.py`(`DEFAULT_CONFIG` 的 `title_model`) +- `docs/ai_models.sample.json`(chat 文本模型示例) + +背景: + +§19.18 给标题模型放了独立下拉。复盘改为「配置定名、去下拉」:标题模型是「配一次就固定」的,不像分辨率/话术需要每次切换;左栏 ~360px 已叠两个提示词组,少一个下拉更干净;还消掉「误选图片模型」的坑。模型名放 `app_config.title_model`(默认 `GPT-5.5 文本`),运行时按名字查 `ai_models.json`,换模型只改配置不改代码。 + +任务: + +- [ ] `ai_outfit_panel.py`:`_build_title_group` 去掉「标题模型」下拉(`_title_model_combo` 及其 `_compact_combo`/填充/`_set_title_running` 里的禁用);`apply_config` 不再填标题下拉、`_emit_config` 不再写 `title_model`(值由配置/手动维护,UI 不覆盖) +- [ ] `ai_outfit_panel.py`:`_selected_title_model_config()` 改为 `_resolve_title_model_config()`——读 `app_config.title_model` 名字,在 `self._models` 里按 `name` 查;命中校验 `api_config_errors` 后返回 `AiModelConfig`;找不到/未配置时 `QMessageBox.warning` 明确报错(提示加文本模型并设 `title_model`)。`_start_title` 改用它;面板需持有当前 `title_model` 值(`apply_config` 存一份 `self._title_model_name`) +- [ ] `config_service.py`:`DEFAULT_CONFIG["title_model"]` 默认值由 `""` 改为 `"GPT-5.5 文本"`;注释改为「标题模型名字(对应 ai_models.json 的 name,§17.3 配置定名)」 +- [ ] `docs/ai_models.sample.json`:补一条 `name="GPT-5.5 文本"`、`api_type=chat`、`model=gpt-5.5`(占位 key)的文本模型示例,作为标题模型样板 +- [ ] 测试:更新 `test_ai_outfit_panel.py`(断言**无**标题模型下拉、`_title_model_combo` 不存在);新增 `_resolve_title_model_config` 命中/缺失(报错返回 None)用例 +- [ ] 验证:相关单测 + 全套 py37 通过;离屏冒烟:`title_model` 命中 `ai_models.json` → 生成走该模型;改名/缺失 → 明确报错 diff --git a/tests/test_ai_outfit_panel.py b/tests/test_ai_outfit_panel.py index 3297991..91af09a 100644 --- a/tests/test_ai_outfit_panel.py +++ b/tests/test_ai_outfit_panel.py @@ -73,8 +73,8 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): self.assertIn("图片并发数", labels) - def test_title_group_present_and_preview_removed(self): - """标题生成组存在,预览块已移除(docs/11 §17)。""" + def test_title_group_present_no_dropdown_preview_removed(self): + """标题生成组存在;无标题模型下拉(配置定名 §17.3);预览块已移除。""" from PySide6.QtWidgets import QLabel panel = self._panel() @@ -82,13 +82,12 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): labels = [label.text() for label in panel.findChildren(QLabel)] self.assertIn("标题生成提示词", labels) - self.assertIn("标题模型", labels) + self.assertNotIn("标题模型", labels) # 下拉已去掉(§17.3 配置定名) self.assertNotIn("最终生成要求预览", labels) - # The preview widgets and methods are gone. + self.assertFalse(hasattr(panel, "_title_model_combo")) self.assertFalse(hasattr(panel, "_preview_view")) self.assertFalse(hasattr(panel, "_sample_combo")) self.assertTrue(hasattr(panel, "_title_btn")) - self.assertTrue(hasattr(panel, "_title_model_combo")) def test_title_prompt_defaults_loaded(self): from services.config_service import DEFAULT_TITLE_PROMPT @@ -98,6 +97,46 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT) + def test_find_title_model_resolves_by_config_name(self): + """title_model 名字命中 ai_models.json → 返回 AiModelConfig。""" + from services.ai_image_service import AiModelConfig + + panel = self._panel() + panel._models = [ + {"name": "GPT-5.5 文本", "url": "https://r/v1/chat/completions", + "model": "gpt-5.5", "api_key": "sk-x", "api_type": "chat"}, + {"name": "图片模型", "url": "https://r/v1/images/edits", + "model": "img", "api_key": "sk-y", "api_type": "images_edits"}, + ] + panel._title_model_name = "GPT-5.5 文本" + + config, error = panel._find_title_model() + + self.assertIsNone(error) + self.assertIsInstance(config, AiModelConfig) + self.assertEqual(config.model, "gpt-5.5") + + def test_find_title_model_missing_name_errors(self): + panel = self._panel() + panel._models = [{"name": "别的模型", "url": "https://r/v1/chat/completions", + "model": "m", "api_key": "sk-x", "api_type": "chat"}] + panel._title_model_name = "GPT-5.5 文本" + + config, error = panel._find_title_model() + + self.assertIsNone(config) + self.assertIn("GPT-5.5 文本", error) + + def test_find_title_model_no_models_errors(self): + panel = self._panel() + panel._models = [] + panel._title_model_name = "GPT-5.5 文本" + + config, error = panel._find_title_model() + + self.assertIsNone(config) + self.assertTrue(error) + if __name__ == "__main__": unittest.main()