feat: 完成T-501设置页AI模型管理

- 新增SettingsTab,接入AI模型下拉、新增、删除、详情编辑和保存

- 新增AIModelTestWorker,后台调用appconfig.test_ai_model测试连接

- 密钥输入使用密码框打码,删除保护保持text/image模型类别约束

- 补充GUI测试覆盖模型加载、保存删除和测试连接worker

- 同步任务看板、current-state、routes、api和progress
This commit is contained in:
chengma
2026-06-29 08:45:04 +08:00
parent d0cd408126
commit bf961e62d6
7 changed files with 603 additions and 23 deletions
+154 -1
View File
@@ -9,7 +9,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import gui
from app import accounts, db, prompts
from app import accounts, appconfig, db, prompts
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QTableVie
from app.gui import (
AccountDialog,
AccountsTab,
AIModelTestWorker,
ApplyTab,
ApplyWorker,
CollectWorker,
@@ -27,6 +28,7 @@ from app.gui import (
GenerateWorker,
GenerateTab,
MainWindow,
SettingsTab,
TAB_STYLE,
TAB_TITLES,
WriteBackWorker,
@@ -51,6 +53,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
"image_dir": os.path.join(temp_dir, "images"),
"db_path": os.path.join(temp_dir, "cmshopee.db"),
"debug_port_range": [9222, 9260],
"ai_models_path": os.path.join(temp_dir, "ai_models.json"),
}
def test_main_window_has_five_tabs_in_workflow_order(self):
@@ -71,6 +74,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(window.tabs.widget(0), CollectTab)
self.assertIsInstance(window.tabs.widget(1), GenerateTab)
self.assertIsInstance(window.tabs.widget(2), ApplyTab)
self.assertIsInstance(window.tabs.widget(4), SettingsTab)
self.assertEqual(
"回写旧数据到 Excel",
window.tabs.widget(0).write_back_button.text(),
@@ -78,6 +82,155 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_settings_tab_loads_ai_models_and_masks_key_field(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
models_path = cfg["ai_models_path"]
appconfig.save_ai_models_config(
{
"models": [
{
"name": "Text A",
"category": "text",
"enabled": True,
"url": "https://example.invalid/text",
"model": "text-model",
"api_key": "sk-text-secret",
"api_type": "chat",
"connect_timeout_seconds": 11,
"timeout_seconds": 0,
"extra_body": {"temperature": 0},
},
{
"name": "Image A",
"category": "image",
"enabled": True,
"url": "https://example.invalid/image",
"model": "image-model",
"api_key": "sk-image-secret",
"api_type": "auto",
"connect_timeout_seconds": 22,
"timeout_seconds": 0,
"extra_body": {},
},
]
},
path=models_path,
)
tab = SettingsTab(config=cfg, ai_models_path=models_path)
self.addCleanup(tab.close)
self.assertEqual(2, tab.model_combo.count())
self.assertEqual("Text A", tab.name_edit.text())
self.assertEqual("text", tab.category_combo.currentData())
self.assertEqual("chat", tab.api_type_combo.currentData())
self.assertEqual("text-model", tab.model_id_edit.text())
self.assertEqual("https://example.invalid/text", tab.url_edit.text())
self.assertEqual("sk-text-secret", tab.api_key_edit.text())
self.assertEqual(QLineEdit.Password, tab.api_key_edit.echoMode())
self.assertEqual(11, tab.connect_timeout_spin.value())
self.assertFalse(tab.delete_model_button.isEnabled())
self.assert_removed(temp_dir)
def test_settings_tab_adds_saves_and_deletes_model(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
models_path = cfg["ai_models_path"]
statuses = []
tab = SettingsTab(
config=cfg,
ai_models_path=models_path,
status_callback=statuses.append,
)
self.addCleanup(tab.close)
tab.add_model()
self.assertEqual("新文本模型", tab.current_model_name)
self.assertEqual(3, tab.model_combo.count())
tab.name_edit.setText("Text Custom")
tab.url_edit.setText("https://example.invalid/v1/chat/completions")
tab.model_id_edit.setText("demo-text")
tab.api_key_edit.setText("sk-custom-secret")
tab.api_type_combo.setCurrentIndex(tab.api_type_combo.findData("chat"))
tab.connect_timeout_spin.setValue(12)
tab.save_model()
saved = appconfig.get_model("Text Custom", path=models_path)
self.assertEqual("text", saved["category"])
self.assertEqual("demo-text", saved["model"])
self.assertEqual("sk-custom-secret", saved["api_key"])
self.assertEqual(12, saved["connect_timeout_seconds"])
self.assertIn("AI 模型已保存:Text Custom", statuses[-1])
self.assertTrue(tab.delete_model_button.isEnabled())
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes):
tab.delete_model()
names = [model["name"] for model in appconfig.list_ai_models(path=models_path)]
self.assertNotIn("Text Custom", names)
self.assertIn("AI 模型已删除:Text Custom", statuses[-1])
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)
models_path = cfg["ai_models_path"]
statuses = []
tab = SettingsTab(
config=cfg,
ai_models_path=models_path,
status_callback=statuses.append,
)
self.addCleanup(tab.close)
class FakeSignal:
def __init__(self):
self.callbacks = []
def connect(self, callback):
self.callbacks.append(callback)
class FakeThread:
def __init__(self):
self.finished = FakeSignal()
self.started = False
def start(self):
self.started = True
fake_thread = FakeThread()
with mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker:
tab.test_connection()
run_worker.assert_called_once()
self.assertIsInstance(tab.test_worker, AIModelTestWorker)
self.assertIs(tab.test_thread, fake_thread)
self.assertTrue(fake_thread.started)
self.assertFalse(tab.test_connection_button.isEnabled())
self.assertIn("正在测试 AI 模型连接", statuses[-1])
self.assert_removed(temp_dir)
def test_ai_model_test_worker_calls_appconfig(self):
with self.make_temp_dir() as temp_dir:
models_path = os.path.join(temp_dir, "ai_models.json")
worker = AIModelTestWorker("Text A", ai_models_path=models_path)
with mock.patch(
"app.gui.appconfig.test_ai_model",
return_value={"ok": True, "status": 200},
) as test_ai_model:
result = worker.execute()
test_ai_model.assert_called_once_with("Text A", path=models_path)
self.assertEqual({"ok": True, "status": 200, "name": "Text A"}, result)
self.assert_removed(temp_dir)
def test_generate_tab_has_prompt_editors_and_task_table(self):
with self.make_temp_dir() as temp_dir:
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")