From d76a1de8644aa46112fa9073d0b3ca8f0ac0381c Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 21 Jul 2026 09:23:08 +0800 Subject: [PATCH] feat(settings): rename custom gateway models --- app/appconfig.py | 19 +++++++++ app/gui/tabs/settings.py | 92 ++++++++++++++++++++++++++++++++++++++++ docs/routes.md | 2 +- docs/tasks/T-681.md | 7 ++- tests/test_appconfig.py | 42 ++++++++++++++++++ tests/test_gui.py | 79 ++++++++++++++++++++++++++++++++++ 6 files changed, 238 insertions(+), 3 deletions(-) diff --git a/app/appconfig.py b/app/appconfig.py index 67a73ae..64e7df2 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -1003,6 +1003,25 @@ def update_ai_model(model_name, path=AI_MODELS_PATH, **fields) -> None: save_ai_models_config(config, path=path) +def rename_ai_model(model_name, new_name, path=AI_MODELS_PATH) -> dict: + """Rename one AI model while preserving every other stored field.""" + + config = load_ai_models_config(path) + index = _model_index(config["models"], model_name) + renamed = copy.deepcopy(config["models"][index]) + renamed["name"] = str(new_name or "").strip() + if not renamed["name"]: + raise ConfigError("AI 模型名称不能为空") + if renamed["name"] != model_name and any( + model["name"] == renamed["name"] for model in config["models"] + ): + raise ConfigError(f"AI 模型名称已存在:{renamed['name']}") + normalized = _normalize_ai_model(renamed) + config["models"][index] = normalized + save_ai_models_config(config, path=path) + return copy.deepcopy(normalized) + + def delete_ai_model(name, path=AI_MODELS_PATH) -> None: config = load_ai_models_config(path) index = _model_index(config["models"], name) diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 06d1b6c..c72766c 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -140,6 +140,7 @@ class SettingsTab(QWidget): self.model_combo = QComboBox() self.model_combo.setObjectName("aiModelCombo") self.add_model_button = QPushButton("新增") + self.rename_model_button = QPushButton("重命名") self.delete_model_button = QPushButton("删除") self.enabled_checkbox = QCheckBox("启用") @@ -250,6 +251,7 @@ class SettingsTab(QWidget): model_picker_layout = QHBoxLayout() model_picker_layout.addWidget(self.model_combo, 1) model_picker_layout.addWidget(self.add_model_button) + model_picker_layout.addWidget(self.rename_model_button) model_picker_layout.addWidget(self.delete_model_button) action_layout = QHBoxLayout() @@ -427,6 +429,7 @@ class SettingsTab(QWidget): self.model_combo.currentIndexChanged.connect(self.load_selected_model) self.add_model_button.clicked.connect(self.add_model) + self.rename_model_button.clicked.connect(self.rename_model) self.delete_model_button.clicked.connect(self.delete_model) self.save_model_button.clicked.connect(self.save_model) self.test_connection_button.clicked.connect(self.test_connection) @@ -716,6 +719,12 @@ class SettingsTab(QWidget): try: if self.current_model_name is None: appconfig.add_ai_model(model, path=self.ai_models_path) + elif model["name"] != self.current_model_name: + self._rename_model_and_migrate_defaults( + self.current_model_name, + model["name"], + replacement=model, + ) else: appconfig.update_ai_model( self.current_model_name, @@ -728,6 +737,32 @@ class SettingsTab(QWidget): self.refresh_models(selected=model["name"]) self._set_status(f"AI 模型已保存:{model['name']}") + def rename_model(self, checked=False): + model = self._current_model() + if model is None: + return + old_name = model["name"] + new_name, accepted = QInputDialog.getText( + self, + "重命名 AI 模型", + "模型名称:", + QLineEdit.Normal, + old_name, + ) + if not accepted: + return + new_name = new_name.strip() + if new_name == old_name: + self._set_status("AI 模型名称未改变") + return + try: + self._rename_model_and_migrate_defaults(old_name, new_name) + except Exception as exc: + self._show_error(exc) + return + self.refresh_models(selected=new_name) + self._set_status(f"AI 模型已重命名:{old_name} → {new_name}") + def delete_model(self, checked=False): model = self._current_model() if model is None: @@ -1153,6 +1188,7 @@ class SettingsTab(QWidget): ): widget.setEnabled(has_model and not testing) self.add_model_button.setEnabled(not testing) + self.rename_model_button.setEnabled(has_model and not testing) self.delete_model_button.setEnabled( has_model and not testing and self._can_delete_model(self._current_model()) ) @@ -1474,6 +1510,62 @@ class SettingsTab(QWidget): def _current_model(self): return self._model_by_name(self.current_model_name) + def _rename_model_and_migrate_defaults(self, old_name, new_name, replacement=None): + """Persist a model rename and keep stored/default UI references valid.""" + + original_model = appconfig.get_model(old_name, path=self.ai_models_path) + persisted_config = appconfig.load_config(self.config_path) + persisted_changed = self._migrate_default_model_references( + persisted_config, + old_name, + new_name, + ) + renamed = False + try: + if replacement is None: + saved_model = appconfig.rename_ai_model( + old_name, + new_name, + path=self.ai_models_path, + ) + else: + appconfig.update_ai_model( + old_name, + path=self.ai_models_path, + **replacement, + ) + saved_model = appconfig.get_model(new_name, path=self.ai_models_path) + renamed = True + if persisted_changed: + appconfig.save_config(persisted_config, path=self.config_path) + except Exception: + if renamed: + try: + appconfig.update_ai_model( + new_name, + path=self.ai_models_path, + **original_model, + ) + except Exception as rollback_exc: + raise appconfig.ConfigError( + "模型重命名失败,且无法恢复模型清单。请先备份数据目录后再重试。" + ) from rollback_exc + raise + self._migrate_default_model_references(self.config, old_name, new_name) + return saved_model + + @staticmethod + def _migrate_default_model_references(config, old_name, new_name): + ai_cfg = appconfig.ai_config(config) + changed = False + for key in ("default_text_model", "default_image_model"): + if ai_cfg.get(key) == old_name: + ai_cfg[key] = new_name + changed = True + if changed: + config["ai"] = ai_cfg + return changed + def _model_by_name(self, name): for model in self.models: if model.get("name") == name: diff --git a/docs/routes.md b/docs/routes.md index 6555e79..4fd6ef7 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -178,7 +178,7 @@ - T-679b 后,⑥商品套图在自定义网关下先本地校验已启用的图片模型、接口类型、网址、模型 ID、密钥与连接超时;不合格时阻止创建 job 并引导到⑤补齐。合格后可创建同步直连 job,确认框显示数量、主图/参考图、逐图模式、比例映射与“自定义网关不计点数,实际费用以服务商为准”,并说明首图为主要商品参考、主体一致性可能弱于默认网关;不请求 cmhub 模型目录、余额或价格。AI帮写仍禁用并说明仅默认网关可用。已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 - T-679c 后,「继续查询已提交图片」只显示并处理 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的任务,切换到自定义网关后仍可继续查询旧默认网关任务;direct job 永不进入该入口。结果卡、项目历史和全局历史固定显示 job 已保存的“默认网关 / 自定义网关”,不因⑤设置切换改写;同轮混合来源显示“默认网关、自定义网关”。direct 失败只能手动“重新生成”,并再次显示完整生成确认及“服务商可能已对上次未确认请求计费,本次可能再次收费”的默认取消警告。确认后新建 job,不自动重试或复用旧 job;默认网关历史、导出、删除撤销和继续查询保持原有行为。 - T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - - 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可维护并立即保存,切换回默认网关不会清除这些配置。 + - 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可通过「新增 / 重命名 / 删除」维护并立即保存,切换回默认网关不会清除这些配置。重命名会保留模型的连接参数和本地 API Key,并同步迁移 `config.json` 中标题/图片默认模型对旧名称的引用;取消、空名称或重名不会写入。 - AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。自定义模型角色选择只在自定义网关面板显示。 - 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;设置中的「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 900 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。 - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。 diff --git a/docs/tasks/T-681.md b/docs/tasks/T-681.md index 23d4531..4b6a7a2 100644 --- a/docs/tasks/T-681.md +++ b/docs/tasks/T-681.md @@ -3,7 +3,7 @@ id: T-681 title: 自定义网关模型名称重命名 phase: 5 deps: [T-677, T-680] -status: TODO +status: DONE created: 2026-07-21 --- @@ -64,4 +64,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 在自定义网关模型选择框右侧加入「重命名」按钮,固定操作顺序为「新增 / 重命名 / 删除」;无当前模型或连接测试运行中时不可用。输入对话框以当前名称预填,取消或名称未变不写入。 +- `appconfig.rename_ai_model()` 统一执行名称清理、空名称/重名校验,并保留模型其余字段。设置页重命名后立即同步 `config.json` 中标题/图片默认模型引用;模型配置写入成功而默认引用保存失败时会回滚模型清单,避免留下旧名称引用。既有「保存」修改模型名称时复用同一迁移与回滚逻辑。 +- 更新 `docs/routes.md`,并增加配置层与 GUI 回归测试:字段/API Key 保留、空名称/重名拒绝、按钮顺序与启用状态、文本/图片默认引用迁移、取消不写入。 +- 验证通过:`py -3.10 -m unittest tests.test_appconfig tests.test_gui`(233 项)、`py -3.10 -m unittest discover -s tests`(653 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 33294ed..71d0bfa 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -565,6 +565,48 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_rename_ai_model_preserves_fields_and_rejects_invalid_name(self): + with self.make_temp_dir() as temp_dir: + models_path = os.path.join(temp_dir, "ai_models.json") + appconfig.add_ai_model( + { + "name": "Text 2", + "category": "text", + "enabled": False, + "url": "https://example.invalid/v1/chat/completions", + "model": "demo-model", + "api_key": "sk-1234567890", + "api_type": "chat", + "connect_timeout_seconds": 12, + "timeout_seconds": 45, + "extra_body": {"temperature": 0}, + }, + path=models_path, + ) + before = appconfig.get_model("Text 2", path=models_path) + + renamed = appconfig.rename_ai_model( + "Text 2", + " 文本模型 ", + path=models_path, + ) + + self.assertEqual("文本模型", renamed["name"]) + self.assertEqual( + {**before, "name": "文本模型"}, + appconfig.get_model("文本模型", path=models_path), + ) + with self.assertRaisesRegex(appconfig.ConfigError, "模型名称不能为空"): + appconfig.rename_ai_model("文本模型", " ", path=models_path) + with self.assertRaisesRegex(appconfig.ConfigError, "模型名称已存在"): + appconfig.rename_ai_model( + "文本模型", + "GPT-5.5 文本", + path=models_path, + ) + + self.assert_removed(temp_dir) + def test_ai_model_constraints_and_connection_validation(self): with self.make_temp_dir() as temp_dir: models_path = os.path.join(temp_dir, "ai_models.json") diff --git a/tests/test_gui.py b/tests/test_gui.py index 1df99c5..8b90872 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2489,6 +2489,85 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_settings_tab_renames_model_and_migrates_default_references(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.default_config()["ai"] + cfg["ai"]["default_text_model"] = "GPT-5.5 文本" + appconfig.save_config(cfg, path=cfg["config_path"]) + statuses = [] + tab = SettingsTab( + config=cfg, + ai_models_path=cfg["ai_models_path"], + status_callback=statuses.append, + ) + self.addCleanup(tab.close) + old_model = appconfig.get_model("GPT-5.5 文本", path=cfg["ai_models_path"]) + + picker_layout = tab.model_combo.parentWidget().layout() + self.assertIs(tab.add_model_button, picker_layout.itemAt(1).widget()) + self.assertIs(tab.rename_model_button, picker_layout.itemAt(2).widget()) + self.assertIs(tab.delete_model_button, picker_layout.itemAt(3).widget()) + self.assertTrue(tab.rename_model_button.isEnabled()) + + with mock.patch( + "app.gui.tabs.settings.QInputDialog.getText", + return_value=("自定义文本模型", True), + ): + tab.rename_model() + + self.assertEqual("自定义文本模型", tab.current_model_name) + self.assertEqual( + {**old_model, "name": "自定义文本模型"}, + appconfig.get_model("自定义文本模型", path=cfg["ai_models_path"]), + ) + self.assertIsNone(tab._model_by_name("GPT-5.5 文本")) + self.assertEqual( + "自定义文本模型", + tab.default_text_model_combo.currentData(), + ) + saved = appconfig.load_config(cfg["config_path"]) + self.assertEqual("自定义文本模型", saved["ai"]["default_text_model"]) + self.assertEqual("Nano Banana 2", saved["ai"]["default_image_model"]) + self.assertIn( + "AI 模型已重命名:GPT-5.5 文本 → 自定义文本模型", + statuses[-1], + ) + + with mock.patch( + "app.gui.tabs.settings.QInputDialog.getText", + return_value=("", False), + ): + tab.rename_model() + self.assertEqual("自定义文本模型", tab.current_model_name) + + tab.model_combo.setCurrentIndex(tab.model_combo.findData("Nano Banana 2")) + with mock.patch( + "app.gui.tabs.settings.QInputDialog.getText", + return_value=("自定义图片模型", True), + ): + tab.rename_model() + saved = appconfig.load_config(cfg["config_path"]) + self.assertEqual("自定义图片模型", saved["ai"]["default_image_model"]) + self.assertEqual( + "自定义图片模型", + tab.default_image_model_combo.currentData(), + ) + + with mock.patch( + "app.gui.tabs.settings.QInputDialog.getText", + return_value=("自定义文本模型", True), + ), mock.patch("app.gui.tabs.settings.QMessageBox.warning") as warning: + tab.rename_model() + warning.assert_called_once() + self.assertEqual("自定义图片模型", tab.current_model_name) + self.assertEqual( + "自定义图片模型", + appconfig.load_config(cfg["config_path"])["ai"]["default_image_model"], + ) + + self.assert_removed(temp_dir) + def test_settings_tab_starts_connection_test_worker(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)