diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index 8ef74c6..f3a125c 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -698,15 +698,26 @@ class AiOutfitPanel(QWidget): self._last_model = self._model_combo.currentText() if self._models else "" def _fill_model_combo(self, combo, selected_name): - """Fill a model dropdown from self._models (shared by 图片/标题 model, §17.3).""" + """Fill the image model dropdown, excluding the configured title model.""" combo.clear() if not self._models: combo.addItem("(未配置模型,请在 ai_models.json 添加)") combo.setEnabled(False) return + + title_model_name = (self._title_model_name or "").strip() + image_models = [ + m for m in self._models + if (m.get("name") or m.get("model")) != title_model_name + ] + if not image_models: + combo.addItem("(未配置可用图片模型,请在 ai_models.json 添加)") + combo.setEnabled(False) + return + combo.setEnabled(True) - for m in self._models: - combo.addItem(m.get("name") or m.get("model") or "(未命名)") + for m in image_models: + combo.addItem(m.get("name") or m.get("model") or "(未命名)", m) self._set_combo(combo, selected_name) def _set_combo(self, combo, value): @@ -718,7 +729,8 @@ class AiOutfitPanel(QWidget): self.config_changed.emit({ "outfit_excel": self._excel_edit.text(), "outfit_output_dir": self._output_edit.text(), - "outfit_model": self._model_combo.currentText() if self._models else "", + "outfit_model": self._model_combo.currentText() + if self._model_combo.currentData() else "", "outfit_concurrency": self._concurrency.value(), "outfit_request_interval": self._interval.value(), "outfit_task_cooldown": self._cooldown.value(), @@ -1073,9 +1085,9 @@ class AiOutfitPanel(QWidget): return self._last_model = name api_type = "auto" - idx = self._model_combo.currentIndex() - if self._models and 0 <= idx < len(self._models): - api_type = self._models[idx].get("api_type", "auto") or "auto" + data = self._model_combo.currentData() + if isinstance(data, dict): + api_type = data.get("api_type", "auto") or "auto" QMessageBox.information( self, "AI 模型已切换", "已切换模型到 {}。\n\n" @@ -1158,12 +1170,12 @@ class AiOutfitPanel(QWidget): self._stop_btn.setText("停止中…") def _selected_model_config(self): - if not self._models: + data = self._model_combo.currentData() + if not isinstance(data, dict): QMessageBox.warning( self, "未配置模型", - "尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。") + "尚未配置可用图片 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。") return None - data = self._models[self._model_combo.currentIndex()] from services.ai_image_service import AiModelConfig, api_config_errors errors = api_config_errors(data) if errors: @@ -1176,7 +1188,7 @@ class AiOutfitPanel(QWidget): self._stop_btn.setEnabled(running) self._stop_btn.setText("停止生成") # reset 「停止中…」 (§19.24) self._excel_edit.setEnabled(not running) - self._model_combo.setEnabled(not running and bool(self._models)) + self._model_combo.setEnabled(not running and bool(self._model_combo.currentData())) self._title_btn.setEnabled(not running) # mutually exclusive with 生成标题 # -- worker callbacks (UI thread) ----------------------------------- diff --git a/tasks.md b/tasks.md index d8917b2..10f007b 100644 --- a/tasks.md +++ b/tasks.md @@ -1537,7 +1537,7 @@ 任务: - [x] 文档更新:`docs/11-ai-outfit.md` 已明确右栏图片 `AI 模型` 下拉填充时跳过 `name == app_config.title_model` 的模型;不新增 `usage` 字段 -- [ ] `ai_outfit_panel.py`:`_fill_model_combo()` 按 `self._title_model_name` 过滤图片模型下拉;标题模型仍保留在 `self._models` 中供 `_resolve_title_model_config()` 使用 -- [ ] 行为:如果过滤后没有可选图片模型,显示「未配置可用图片模型」类占位并保持开始生成前校验提示 -- [ ] 测试:标题模型不出现在图片 AI 模型下拉;图片模型仍显示;标题生成仍能按 `title_model` 找到同名模型;只有标题模型时下拉为空/占位 -- [ ] 验证:`test_ai_outfit_panel.py`、全套测试、离屏启动 AI 穿搭页 +- [x] `ai_outfit_panel.py`:`_fill_model_combo()` 按 `self._title_model_name` 过滤图片模型下拉;标题模型仍保留在 `self._models` 中供 `_resolve_title_model_config()` 使用 +- [x] 行为:如果过滤后没有可选图片模型,显示「未配置可用图片模型」类占位并保持开始生成前校验提示 +- [x] 测试:标题模型不出现在图片 AI 模型下拉;图片模型仍显示;标题生成仍能按 `title_model` 找到同名模型;只有标题模型时下拉为空/占位 +- [x] 验证:`py_compile`、`test_ai_outfit_panel.py`、全套 `python -m unittest discover -s tests`、离屏启动 AI 穿搭页通过 diff --git a/tests/test_ai_outfit_panel.py b/tests/test_ai_outfit_panel.py index dfd4a67..9287d5f 100644 --- a/tests/test_ai_outfit_panel.py +++ b/tests/test_ai_outfit_panel.py @@ -174,6 +174,81 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): self.assertIsInstance(config, AiModelConfig) self.assertEqual(config.model, "gpt-5.5") + def test_image_model_combo_filters_configured_title_model(self): + """§19.29: 图片 AI 模型下拉不显示 app_config.title_model 同名模型。""" + 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": "GPT Image 2", "url": "https://r/v1/images/edits", + "model": "gpt-image", "api_key": "sk-y", "api_type": "images_edits"}, + {"name": "Nano Banana 2", "url": "https://r/v1/chat/completions", + "model": "nano", "api_key": "sk-z", "api_type": "auto"}, + ] + panel._title_model_name = "GPT-5.5 文本" + + panel._fill_model_combo(panel._model_combo, "GPT-5.5 文本") + + names = [panel._model_combo.itemText(i) for i in range(panel._model_combo.count())] + self.assertNotIn("GPT-5.5 文本", names) + self.assertEqual(names, ["GPT Image 2", "Nano Banana 2"]) + self.assertEqual(panel._model_combo.currentText(), "GPT Image 2") + self.assertTrue(panel._model_combo.isEnabled()) + self.assertEqual(panel._model_combo.currentData()["name"], "GPT Image 2") + + def test_selected_model_config_uses_filtered_combo_data(self): + """Filtered combo indices no longer match self._models; use item data.""" + 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": "GPT Image 2", "url": "https://r/v1/images/edits", + "model": "gpt-image", "api_key": "sk-y", "api_type": "images_edits"}, + {"name": "Nano Banana 2", "url": "https://r/v1/chat/completions", + "model": "nano", "api_key": "sk-z", "api_type": "auto"}, + ] + panel._title_model_name = "GPT-5.5 文本" + + panel._fill_model_combo(panel._model_combo, "Nano Banana 2") + config = panel._selected_model_config() + + self.assertEqual(config.model, "nano") + + def test_image_model_combo_keeps_title_model_available_for_title_generation(self): + """Filtering the image dropdown must not remove the title model from self._models.""" + 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": "GPT Image 2", "url": "https://r/v1/images/edits", + "model": "gpt-image", "api_key": "sk-y", "api_type": "images_edits"}, + ] + panel._title_model_name = "GPT-5.5 文本" + + panel._fill_model_combo(panel._model_combo, "GPT Image 2") + config, error = panel._find_title_model() + + self.assertIsNone(error) + self.assertIsInstance(config, AiModelConfig) + self.assertEqual(config.model, "gpt-5.5") + + def test_image_model_combo_only_title_model_shows_placeholder(self): + 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"}, + ] + panel._title_model_name = "GPT-5.5 文本" + + panel._fill_model_combo(panel._model_combo, "") + + self.assertEqual(panel._model_combo.count(), 1) + self.assertIn("未配置可用图片模型", panel._model_combo.currentText()) + self.assertFalse(panel._model_combo.isEnabled()) + self.assertIsNone(panel._model_combo.currentData()) + def test_find_title_model_missing_name_errors(self): panel = self._panel() panel._models = [{"name": "别的模型", "url": "https://r/v1/chat/completions",