feat(settings): rename custom gateway models

This commit is contained in:
chengma
2026-07-21 09:23:08 +08:00
parent 0da2d8a3c0
commit d76a1de864
6 changed files with 238 additions and 3 deletions
+19
View File
@@ -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)
+92
View File
@@ -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:
+1 -1
View File
@@ -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 一起隐藏。
+5 -2
View File
@@ -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`。
+42
View File
@@ -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")
+79
View File
@@ -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)