fix(ai): clarify config save feedback and model selection
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { AIConfig } from "../../frontend/src/types";
|
||||
|
||||
const initialConfig: AIConfig = {
|
||||
enabled: true,
|
||||
provider: "local",
|
||||
base_url: "http://models.test/v1",
|
||||
model: "model-a",
|
||||
api_key_set: true,
|
||||
updated_at: "2026-11-01T00:00:00Z",
|
||||
};
|
||||
|
||||
async function openSettings(
|
||||
page: Page,
|
||||
config: AIConfig | null = initialConfig,
|
||||
) {
|
||||
await page.route("**/api/session", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
csrf: "test-csrf",
|
||||
user: {
|
||||
id: 1,
|
||||
name: "测试管理员",
|
||||
role: "admin",
|
||||
organization: "测试组织",
|
||||
departments: [],
|
||||
},
|
||||
ai_enabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/datasets", (route) =>
|
||||
route.fulfill({ json: { items: [] } }),
|
||||
);
|
||||
await page.goto("/");
|
||||
if ((page.viewportSize()?.width || 1280) < 700)
|
||||
await page.getByLabel("展开导航", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "AI 模型", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "AI 模型配置" });
|
||||
await expect(dialog).toBeVisible();
|
||||
if (config)
|
||||
await expect(dialog.getByLabel("模型", { exact: true })).toHaveValue(
|
||||
config.model,
|
||||
);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
test("保存成功立即提示,不等待模型列表返回", async ({ page }) => {
|
||||
await page.route("**/api/ai-config", (route) =>
|
||||
route.fulfill({ json: { ok: true, ...initialConfig } }),
|
||||
);
|
||||
let releaseModels!: () => void;
|
||||
const modelsPending = new Promise<void>((resolve) => {
|
||||
releaseModels = resolve;
|
||||
});
|
||||
await page.route("**/api/ai-models", async (route) => {
|
||||
await modelsPending;
|
||||
await route.fulfill({ json: { items: ["model-a", "model-b"] } });
|
||||
});
|
||||
const dialog = await openSettings(page);
|
||||
await dialog.getByLabel("API Key", { exact: true }).fill("test-key");
|
||||
try {
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toBeVisible({
|
||||
timeout: 2000,
|
||||
});
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(dialog.getByLabel("API Key", { exact: true })).toHaveValue("");
|
||||
await expect(dialog.getByRole("button", { name: "保存中…" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
releaseModels();
|
||||
}
|
||||
});
|
||||
|
||||
test("保存失败显示原因并保留 Key 和未保存的模型", async ({ page }) => {
|
||||
await page.route("**/api/ai-config", (route) =>
|
||||
route.request().method() === "PUT"
|
||||
? route.fulfill({ status: 400, json: { error: "Base URL 无效" } })
|
||||
: route.fulfill({ json: initialConfig }),
|
||||
);
|
||||
await page.route("**/api/ai-models", (route) =>
|
||||
route.fulfill({ json: { items: ["model-a", "model-b"] } }),
|
||||
);
|
||||
const dialog = await openSettings(page);
|
||||
await dialog.getByLabel("API Key", { exact: true }).fill("test-new-key");
|
||||
await dialog.getByLabel("模型", { exact: true }).fill("model-b");
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
|
||||
await expect(dialog.getByRole("alert")).toContainText(
|
||||
"保存失败:Base URL 无效",
|
||||
);
|
||||
await expect(dialog.getByLabel("API Key", { exact: true })).toHaveValue(
|
||||
"test-new-key",
|
||||
);
|
||||
await expect(dialog.getByLabel("模型", { exact: true })).toHaveValue(
|
||||
"model-b",
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole("region", { name: "已保存的配置" }),
|
||||
).toContainText("当前使用的模型:model-a");
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "保存配置", exact: true }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test("可选模型直接列出,选择后保存才生效,重新打开仍保留", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1512, height: 1100 });
|
||||
let savedConfig = { ...initialConfig };
|
||||
const saves: Array<Record<string, unknown>> = [];
|
||||
await page.route("**/api/ai-config", (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
const data = route.request().postDataJSON();
|
||||
saves.push(data);
|
||||
savedConfig = {
|
||||
...savedConfig,
|
||||
enabled: data.enabled,
|
||||
provider: data.provider,
|
||||
base_url: data.base_url,
|
||||
model: data.model,
|
||||
api_key_set: Boolean(data.api_key) || savedConfig.api_key_set,
|
||||
updated_at: "2026-11-01T00:01:00Z",
|
||||
};
|
||||
}
|
||||
return route.fulfill({ json: { ok: true, ...savedConfig } });
|
||||
});
|
||||
await page.route("**/api/ai-models", (route) =>
|
||||
route.fulfill({ json: { items: ["model-a", "model-b"] } }),
|
||||
);
|
||||
const dialog = await openSettings(page);
|
||||
const options = dialog.getByRole("region", { name: "可选模型" });
|
||||
const summary = dialog.getByRole("region", { name: "已保存的配置" });
|
||||
await expect(
|
||||
options.getByRole("radio", { name: "model-a", exact: true }),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
options.getByRole("radio", { name: "model-b", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(summary).toContainText("当前使用的模型:model-a");
|
||||
|
||||
await options.getByRole("radio", { name: "model-b", exact: true }).check();
|
||||
await expect(dialog.getByLabel("模型", { exact: true })).toHaveValue(
|
||||
"model-b",
|
||||
);
|
||||
await expect(options).toContainText("待保存");
|
||||
await expect(summary).toContainText("当前使用的模型:model-a");
|
||||
expect(saves).toHaveLength(0);
|
||||
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await expect(summary).toContainText("当前使用的模型:model-b");
|
||||
await expect(options).not.toContainText("待保存");
|
||||
expect(saves).toHaveLength(1);
|
||||
expect(saves[0]).toMatchObject({ model: "model-b", api_key: "" });
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("ai-settings-success.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await dialog.getByLabel("关闭", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "AI 模型", exact: true }).click();
|
||||
await expect(summary).toContainText("当前使用的模型:model-b");
|
||||
await expect(
|
||||
options.getByRole("radio", { name: "model-b", exact: true }),
|
||||
).toBeChecked();
|
||||
await expect(dialog.getByLabel("API Key", { exact: true })).toHaveValue("");
|
||||
await expect(dialog.getByLabel("API Key", { exact: true })).toHaveAttribute(
|
||||
"placeholder",
|
||||
"已配置,留空保持不变",
|
||||
);
|
||||
});
|
||||
|
||||
test("拉取模型失败仍保留保存成功结果和已保存模型", async ({ page }) => {
|
||||
let saved = false;
|
||||
await page.route("**/api/ai-config", (route) => {
|
||||
if (route.request().method() === "PUT") saved = true;
|
||||
return route.fulfill({ json: { ok: true, ...initialConfig } });
|
||||
});
|
||||
await page.route("**/api/ai-models", (route) =>
|
||||
saved
|
||||
? route.fulfill({ status: 502, json: { error: "无法连接模型服务" } })
|
||||
: route.fulfill({ json: { items: ["model-a", "model-b"] } }),
|
||||
);
|
||||
const dialog = await openSettings(page);
|
||||
await expect(
|
||||
dialog.getByRole("radio", { name: "model-b", exact: true }),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole("alert")).toContainText(
|
||||
"获取模型列表失败:无法连接模型服务",
|
||||
);
|
||||
await expect(dialog.getByRole("alert")).not.toContainText("保存失败");
|
||||
await expect(
|
||||
dialog.getByRole("region", { name: "已保存的配置" }),
|
||||
).toContainText("当前使用的模型:model-a");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "保存配置", exact: true }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test("OpenAI 默认地址也会加载列表,停用的已保存模型不会显示为当前使用", async ({
|
||||
page,
|
||||
}) => {
|
||||
const config: AIConfig = {
|
||||
...initialConfig,
|
||||
enabled: false,
|
||||
provider: "openai",
|
||||
base_url: "",
|
||||
};
|
||||
let modelRequests = 0;
|
||||
await page.route("**/api/ai-config", (route) =>
|
||||
route.fulfill({ json: { ok: true, ...config } }),
|
||||
);
|
||||
await page.route("**/api/ai-models", (route) => {
|
||||
modelRequests++;
|
||||
return route.fulfill({ json: { items: ["model-a", "model-b"] } });
|
||||
});
|
||||
const dialog = await openSettings(page, config);
|
||||
const summary = dialog.getByRole("region", { name: "已保存的配置" });
|
||||
await expect(summary).toContainText("已保存的模型:model-a");
|
||||
await expect(summary).not.toContainText("当前使用的模型");
|
||||
await expect(
|
||||
dialog.getByRole("radio", { name: "model-b", exact: true }),
|
||||
).toBeVisible();
|
||||
const requestsBeforeSave = modelRequests;
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => modelRequests).toBeGreaterThan(requestsBeforeSave);
|
||||
});
|
||||
|
||||
test("初次加载配置失败时禁止保存,不覆盖已有配置", async ({ page }) => {
|
||||
let saves = 0;
|
||||
await page.route("**/api/ai-config", (route) => {
|
||||
if (route.request().method() === "PUT") saves++;
|
||||
return route.fulfill({ status: 503, json: { error: "服务暂时不可用" } });
|
||||
});
|
||||
const dialog = await openSettings(page, null);
|
||||
await expect(dialog.getByRole("alert")).toContainText(
|
||||
"加载配置失败:服务暂时不可用",
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "保存配置", exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(dialog.getByLabel("API Key", { exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByLabel("模型", { exact: true })).toBeDisabled();
|
||||
await expect(
|
||||
dialog.getByRole("region", { name: "已保存的配置" }),
|
||||
).toHaveCount(0);
|
||||
expect(saves).toBe(0);
|
||||
});
|
||||
|
||||
test("清空本地地址并停用后保存,忽略旧服务延迟返回的模型", async ({ page }) => {
|
||||
let savedConfig = { ...initialConfig };
|
||||
await page.route("**/api/ai-config", (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
const { enabled, provider, base_url, model } = route
|
||||
.request()
|
||||
.postDataJSON();
|
||||
savedConfig = { ...savedConfig, enabled, provider, base_url, model };
|
||||
}
|
||||
return route.fulfill({ json: { ok: true, ...savedConfig } });
|
||||
});
|
||||
let releaseModels!: () => void;
|
||||
const modelsPending = new Promise<void>((resolve) => {
|
||||
releaseModels = resolve;
|
||||
});
|
||||
let modelRequests = 0;
|
||||
await page.route("**/api/ai-models", async (route) => {
|
||||
modelRequests++;
|
||||
await modelsPending;
|
||||
await route.fulfill({ json: { items: ["obsolete-model"] } });
|
||||
});
|
||||
const dialog = await openSettings(page);
|
||||
try {
|
||||
await expect.poll(() => modelRequests).toBe(1);
|
||||
await dialog
|
||||
.getByRole("checkbox", { name: "启用 AI 模型理解问题" })
|
||||
.uncheck();
|
||||
await dialog.getByLabel("Base URL", { exact: true }).fill("");
|
||||
await dialog.getByLabel("模型", { exact: true }).fill("");
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toBeVisible();
|
||||
|
||||
const response = page.waitForResponse("**/api/ai-models");
|
||||
releaseModels();
|
||||
await (await response).finished();
|
||||
await page.evaluate(async () => {
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
});
|
||||
|
||||
const options = dialog.getByRole("region", { name: "可选模型" });
|
||||
await expect(options.getByRole("radio")).toHaveCount(0);
|
||||
await expect(
|
||||
options.getByRole("button", { name: "获取可用模型" }),
|
||||
).toBeDisabled();
|
||||
await expect(options).not.toContainText("正在获取可用模型");
|
||||
expect(modelRequests).toBe(1);
|
||||
} finally {
|
||||
releaseModels();
|
||||
}
|
||||
});
|
||||
|
||||
for (const field of ["Base URL", "API Key"]) {
|
||||
test(`修改 ${field} 后须先保存,旧服务模型不可选`, async ({ page }) => {
|
||||
await page.route("**/api/ai-config", (route) =>
|
||||
route.fulfill({ json: initialConfig }),
|
||||
);
|
||||
await page.route("**/api/ai-models", (route) =>
|
||||
route.fulfill({ json: { items: ["model-a", "model-b"] } }),
|
||||
);
|
||||
const dialog = await openSettings(page);
|
||||
const options = dialog.getByRole("region", { name: "可选模型" });
|
||||
await expect(
|
||||
options.getByRole("radio", { name: "model-b", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog
|
||||
.getByLabel(field, { exact: true })
|
||||
.fill(
|
||||
field === "Base URL"
|
||||
? "http://another-models.test/v1"
|
||||
: "test-changed-key",
|
||||
);
|
||||
await expect(options).toContainText(
|
||||
"服务配置已修改,请先保存后获取可选模型。",
|
||||
);
|
||||
await expect(options.getByRole("radio")).toHaveCount(0);
|
||||
await expect(
|
||||
options.getByRole("button", { name: "获取可用模型" }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
dialog.getByRole("region", { name: "已保存的配置" }),
|
||||
).toContainText("当前使用的模型:model-a");
|
||||
|
||||
await dialog
|
||||
.getByLabel(field, { exact: true })
|
||||
.fill(field === "Base URL" ? initialConfig.base_url : "");
|
||||
await expect(
|
||||
options.getByRole("radio", { name: "model-a", exact: true }),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
options.getByRole("button", { name: "获取可用模型" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
test("桌面和手机长模型名无横向溢出", async ({ page }) => {
|
||||
const longModel = `finance-analysis-${"long-model-name-".repeat(10)}2026`;
|
||||
const config = { ...initialConfig, model: longModel };
|
||||
await page.route("**/api/ai-config", (route) =>
|
||||
route.fulfill({ json: config }),
|
||||
);
|
||||
await page.route("**/api/ai-models", (route) =>
|
||||
route.fulfill({ json: { items: [longModel, "model-b"] } }),
|
||||
);
|
||||
await page.setViewportSize({ width: 1512, height: 1100 });
|
||||
const dialog = await openSettings(page, config);
|
||||
const options = dialog.getByRole("region", { name: "可选模型" });
|
||||
await expect(
|
||||
options.getByRole("radio", { name: longModel, exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("ai-settings-desktop.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await dialog.getByLabel("关闭", { exact: true }).click();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByLabel("展开导航", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "AI 模型", exact: true }).click();
|
||||
const selected = options.getByRole("radio", { name: longModel, exact: true });
|
||||
await expect(selected).toBeChecked();
|
||||
await selected.scrollIntoViewIfNeeded();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await options.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
await dialog.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await expect(
|
||||
dialog.getByRole("status").filter({ hasText: "配置已保存" }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("ai-settings-mobile.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ def test_model_intent_polls_in_progress_and_retries_invalid_response(workspace,
|
||||
polled["posts"] += 1
|
||||
# 第一次 POST 返回损坏文本(触发整体重试),第二次 POST 返回 in_progress
|
||||
if polled["posts"] == 1:
|
||||
|
||||
class Bad:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
@@ -87,7 +88,9 @@ def test_model_intent_polls_in_progress_and_retries_invalid_response(workspace,
|
||||
def get(self, *args, **kwargs):
|
||||
polled["n"] += 1
|
||||
assert args[0].endswith("/responses/resp-1")
|
||||
return completed_response({"action": "anomalies", "metric": "revenue", "group": "department", "filters": []})
|
||||
return completed_response(
|
||||
{"action": "anomalies", "metric": "revenue", "group": "department", "filters": []}
|
||||
)
|
||||
|
||||
with patch("finance.assistant.httpx.Client", ClientMock):
|
||||
intent = model_intent("看异常", ds, {"metric": "revenue", "group": "department", "filters": {}})
|
||||
@@ -152,6 +155,34 @@ def test_ai_config_requires_full_admin_and_masks_key(workspace):
|
||||
assert Audit.objects.filter(action="ai_config.update").exists()
|
||||
|
||||
|
||||
def test_ai_config_save_returns_saved_config_and_preserves_masked_key(workspace):
|
||||
ds, _, client, _, _ = workspace
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"provider": "local",
|
||||
"base_url": "http://127.0.0.1:11434/v1/",
|
||||
"api_key": "saved-config-test-secret",
|
||||
"model": "qwen2.5:7b",
|
||||
}
|
||||
|
||||
for api_key, model in [(payload["api_key"], "qwen2.5:7b"), ("", "llama3.1:8b")]:
|
||||
payload.update(api_key=api_key, model=model)
|
||||
response = client.put("/api/ai-config", data=payload, content_type="application/json")
|
||||
assert response.status_code == 200
|
||||
saved = response.json()
|
||||
shown = client.get("/api/ai-config").json()
|
||||
assert saved == {"ok": True, **shown}
|
||||
assert saved["enabled"] is True
|
||||
assert saved["provider"] == "local"
|
||||
assert saved["base_url"] == "http://127.0.0.1:11434/v1"
|
||||
assert saved["model"] == model
|
||||
assert saved["api_key_set"] is True
|
||||
assert saved["updated_at"]
|
||||
assert "api_key" not in saved
|
||||
assert "saved-config-test-secret" not in response.content.decode()
|
||||
assert AIConfig.objects.get(organization=ds.organization).api_key == "saved-config-test-secret"
|
||||
|
||||
|
||||
def test_ai_models_lists_local_models(workspace):
|
||||
ds, _, client, _, _ = workspace
|
||||
client.put(
|
||||
|
||||
Reference in New Issue
Block a user