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:
+154
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user