feat(settings): rename custom gateway models
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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`。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user