("/ai-config", "PUT", {
enabled: value.enabled,
provider: value.provider,
base_url: value.base_url,
model: value.model,
api_key: key,
});
+ setValue(config);
+ setSavedConfig(config);
setKey("");
- setValue((v) => ({ ...v, api_key_set: v.api_key_set || Boolean(key) }));
- if (value.base_url) await fetchModels();
+ setMessage("配置已保存");
+ onSaved(config);
+ modelRequest.current++;
+ setListing(false);
+ setModelsError("");
+ if (connectionChanged) setModels([]);
+ if (config.base_url || config.provider === "openai") void fetchModels();
} catch (e) {
- setError((e as Error).message);
+ setError(`保存失败:${(e as Error).message}`);
} finally {
setBusy(false);
}
@@ -529,90 +587,206 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
-
-
-
-
-
-
- {error && (
-
- {error}
-
+ {loading && 正在加载已保存配置…
}
+
+ {savedConfig && (
+ <>
+
+ 已保存的配置
+ {savedConfig.updated_at ? (
+ <>
+
+ {savedConfig.enabled
+ ? "当前使用的模型:"
+ : "已保存的模型:"}
+ {savedConfig.model || "尚未选择"}
+
+ {savedConfig.enabled ? "已启用" : "未启用"}
+
+
+
+ {savedConfig.provider === "openai"
+ ? "OpenAI"
+ : "OpenAI 兼容服务"}
+ {" · "}
+ {savedConfig.base_url ||
+ (savedConfig.provider === "openai"
+ ? "https://api.openai.com/v1"
+ : "未设置地址")}
+ {" · "}API Key{" "}
+ {savedConfig.api_key_set ? "已配置" : "未配置"}
+
+ >
+ ) : (
+
+ 尚未保存配置。请先保存服务地址和 API Key,再选择模型并启用。
+
+ )}
+
+
+
+
可选模型
+
+
+
+ 选择后点击“保存配置”生效;也可以在上方手动填写模型名。
+
+ {connectionChanged ? (
+
+ 服务配置已修改,请先保存后获取可选模型。
+
+ ) : (
+ <>
+ {listing && 正在获取可用模型…
}
+ {modelsError && (
+
+ {modelsError}
+
+ )}
+ {!listing &&
+ !modelsError &&
+ !models.length &&
+ canListModels && (
+
+ 该服务没有返回可用模型,可重试或手动填写模型名。
+
+ )}
+ {savedConfig.updated_at && !canListModels && (
+
+ 填写并保存 Base URL 后可获取模型列表。
+
+ )}
+
+ {availableModels.map((model) => (
+
+ ))}
+
+ >
+ )}
+ {value.model !== savedConfig.model && (
+
+ 待保存的模型:{value.model || "尚未选择"}
+
+ )}
+
+ >
)}
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
配置修改会记入操作审计。
+
);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 474df62..06ab09a 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -976,6 +976,91 @@ dialog header h2 {
.dialog-body {
padding: 23px;
}
+.ai-config-fields {
+ border: 0;
+ padding: 0;
+ margin: 0 0 18px;
+ display: grid;
+ gap: 14px;
+ min-width: 0;
+}
+.ai-config-success {
+ padding: 12px 16px;
+ border: 1px solid #b8d8c9;
+ border-radius: 5px;
+ color: var(--green);
+ background: #edf7f1;
+}
+.ai-config-footer {
+ flex-wrap: wrap;
+}
+.ai-config-feedback {
+ flex: 1 1 220px;
+ min-width: 0;
+}
+.ai-config-feedback p {
+ margin: 0;
+}
+.ai-config-feedback:empty {
+ display: none;
+}
+.ai-config-summary {
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ background: #f7f9f8;
+ overflow-wrap: anywhere;
+}
+.ai-config-summary p:last-child {
+ margin-bottom: 0;
+}
+.ai-config-summary .tag {
+ margin-left: 10px;
+}
+.ai-models {
+ margin: 20px 0;
+}
+.ai-models-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+.ai-models-heading h3 {
+ margin: 0;
+}
+.ai-model-options {
+ display: grid;
+ gap: 8px;
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 4px;
+}
+.ai-model-option {
+ flex-direction: row;
+ align-items: center;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ padding: 12px;
+ cursor: pointer;
+}
+.ai-model-option:has(input:checked) {
+ border-color: var(--green);
+ background: #edf7f1;
+}
+.ai-model-option input {
+ flex: none;
+ width: 16px;
+ min-height: 16px;
+ margin: 0;
+ accent-color: var(--green);
+}
+.ai-model-option > span:first-of-type {
+ flex: 1;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
.dialog-body table input {
width: 145px;
}
@@ -1518,6 +1603,9 @@ details {
dialog footer > span {
width: 100%;
}
+ .ai-config-feedback {
+ flex-basis: 100%;
+ }
.form-row {
gap: 12px;
}
diff --git a/tests/e2e/ai-config.spec.ts b/tests/e2e/ai-config.spec.ts
new file mode 100644
index 0000000..2891e21
--- /dev/null
+++ b/tests/e2e/ai-config.spec.ts
@@ -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((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> = [];
+ 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((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,
+ });
+});
diff --git a/tests/test_assistant_api.py b/tests/test_assistant_api.py
index ad1c12f..74d7620 100644
--- a/tests/test_assistant_api.py
+++ b/tests/test_assistant_api.py
@@ -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(