From 4731742042caf4d8eaf1a4e90fc3035485bcae3c Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sun, 1 Nov 2026 09:48:09 +0800 Subject: [PATCH] fix(ai): clarify config save feedback and model selection --- docs/user-guide.html | 7 +- finance/ai.py | 26 +-- frontend/src/App.tsx | 11 +- frontend/src/dialogs.tsx | 356 +++++++++++++++++++++++-------- frontend/src/style.css | 88 ++++++++ tests/e2e/ai-config.spec.ts | 410 ++++++++++++++++++++++++++++++++++++ tests/test_assistant_api.py | 33 ++- 7 files changed, 823 insertions(+), 108 deletions(-) create mode 100644 tests/e2e/ai-config.spec.ts diff --git a/docs/user-guide.html b/docs/user-guide.html index fb592c6..3a72280 100644 --- a/docs/user-guide.html +++ b/docs/user-guide.html @@ -261,8 +261,9 @@
  1. 选择服务商,填写 Base URL(本地服务必填)。
  2. 填写 API Key(本地服务通常可留空)。
  3. -
  4. 点击获取可用模型:系统从服务的 /v1/models 接口拉取模型列表。本地服务装了多个模型(如 Ollama 里的 qwen2.5:7b、llama3.1:8b)时,可在下拉列表中挑选,不用手敲名字。
  5. -
  6. 选中模型后勾选「启用 AI 模型理解问题」,点击保存配置。
  7. +
  8. 首次配置先保持未启用,点击保存配置保存服务地址和密钥。页面立即提示保存结果,并自动获取可用模型;OpenAI 未填写 Base URL 时使用默认地址。
  9. +
  10. 下方「已保存的配置」显示已保存模型、服务地址、启用状态和密钥配置状态;「可选模型」直接列出该服务的模型,可点击获取可用模型刷新。重新打开配置窗口时也会自动获取。
  11. +
  12. 从列表选择模型或手动填写模型名,勾选「启用 AI 模型理解问题」,再点击保存配置。修改尚未保存时显示「待保存」;保存成功后「当前使用」标记更新,助手立即使用新的启用状态。
  13. 回到页面顶部打开「财务分析助手」,勾选「使用已配置的 AI 模型理解问题」并同意后即可提问。
@@ -276,7 +277,7 @@

9.3 常见问题

diff --git a/finance/ai.py b/finance/ai.py index 76c6b4d..eaee881 100644 --- a/finance/ai.py +++ b/finance/ai.py @@ -55,22 +55,24 @@ def org_models_endpoint(org): return (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), settings.OPENAI_API_KEY +def config_data(cfg): + return { + "enabled": cfg.enabled if cfg else False, + "provider": cfg.provider if cfg else "local", + "base_url": cfg.base_url if cfg else "", + "model": cfg.model if cfg else "", + "api_key_set": bool(cfg and cfg.api_key), + "updated_at": cfg.updated_at.isoformat() if cfg else None, + } + + @endpoint(("GET", "PUT"), roles=("admin",)) def ai_config(request): require_full_admin(request) org = request.member.organization if request.method == "GET": cfg = AIConfig.objects.filter(organization=org).first() - return JsonResponse( - { - "enabled": cfg.enabled if cfg else False, - "provider": cfg.provider if cfg else "local", - "base_url": cfg.base_url if cfg else "", - "model": cfg.model if cfg else "", - "api_key_set": bool(cfg and cfg.api_key), - "updated_at": cfg.updated_at.isoformat() if cfg else None, - } - ) + return JsonResponse(config_data(cfg)) data = body(request) enabled = data.get("enabled") provider = data.get("provider") @@ -106,7 +108,7 @@ def ai_config(request): if api_key: defaults["api_key"] = api_key if not cfg: - AIConfig.objects.create(organization=org, **defaults) + cfg = AIConfig.objects.create(organization=org, **defaults) else: for field, value in defaults.items(): setattr(cfg, field, value) @@ -121,7 +123,7 @@ def ai_config(request): "key_changed": key_changed, }, ) - return JsonResponse({"ok": True}) + return JsonResponse({"ok": True, **config_data(cfg)}) @endpoint(roles=("admin",)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0c7d8e..5cbc3d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1540,7 +1540,16 @@ export function App() { {(modal === "users" || modal === "audit") && ( setModal(null)} /> )} - {modal === "ai" && setModal(null)} />} + {modal === "ai" && ( + setModal(null)} + onSaved={(config) => + setSession((current) => + current ? { ...current, ai_enabled: config.enabled } : current, + ) + } + /> + )} ); } diff --git a/frontend/src/dialogs.tsx b/frontend/src/dialogs.tsx index 35653ed..ead1367 100644 --- a/frontend/src/dialogs.tsx +++ b/frontend/src/dialogs.tsx @@ -471,7 +471,13 @@ export function EvidenceDialog({ ); } -export function AIConfigDialog({ onClose }: { onClose: () => void }) { +export function AIConfigDialog({ + onClose, + onSaved, +}: { + onClose: () => void; + onSaved: (config: AIConfig) => void; +}) { const [value, setValue] = useState({ enabled: false, provider: "local", @@ -480,45 +486,97 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) { api_key_set: false, updated_at: null, }), + [savedConfig, setSavedConfig] = useState(null), [key, setKey] = useState(""), [models, setModels] = useState([]), [error, setError] = useState(""), + [message, setMessage] = useState(""), + [modelsError, setModelsError] = useState(""), + [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [listing, setListing] = useState(false); + const modelRequest = useRef(0); + const connectionChanged = Boolean( + savedConfig && + (value.provider !== savedConfig.provider || + value.base_url !== savedConfig.base_url || + key), + ); + const availableModels = [ + ...new Set([savedConfig?.model || "", ...models]), + ].filter(Boolean); + const canListModels = Boolean( + savedConfig?.updated_at && + (savedConfig.base_url || savedConfig.provider === "openai"), + ); useEffect(() => { + let active = true; api("/ai-config") - .then((r) => setValue(r)) - .catch((e) => setError((e as Error).message)); + .then((r) => { + if (!active) return; + setValue(r); + setSavedConfig(r); + if (r.updated_at && (r.base_url || r.provider === "openai")) + void fetchModels(); + }) + .catch((e) => { + if (active) + setError(`加载配置失败:${(e as Error).message},请关闭后重试。`); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + modelRequest.current++; + }; }, []); - async function fetchModels() { - setListing(true); + function updateValue(patch: Partial) { + setValue((v) => ({ ...v, ...patch })); + setMessage(""); setError(""); + } + async function fetchModels() { + const ticket = ++modelRequest.current; + setListing(true); + setModelsError(""); try { const r = await api<{ items: string[] }>("/ai-models"); + if (ticket !== modelRequest.current) return; setModels(r.items); - if (!r.items.length) setError("该服务没有返回可用模型"); } catch (e) { - setError((e as Error).message); + if (ticket === modelRequest.current) + setModelsError( + `获取模型列表失败:${(e as Error).message}。已保存的配置不受影响,可重试或手动填写模型名。`, + ); } finally { - setListing(false); + if (ticket === modelRequest.current) setListing(false); } } async function save() { setBusy(true); setError(""); + setMessage(""); try { - await api("/ai-config", "PUT", { + const config = await api("/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 || "尚未选择"} +

+ )} +
+ )}

只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端, 配置修改会记入操作审计。

+
+
+ {message && ( +

+ {message} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + +
); 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(