fix(ai): 修复模型配置保存反馈与选择状态 #10

Merged
jianwel merged 1 commits from codex/fix-ai-model-settings into main 2026-11-01 02:04:45 +00:00
7 changed files with 823 additions and 108 deletions
Showing only changes of commit 4731742042 - Show all commits
+4 -3
View File
@@ -261,8 +261,9 @@
<ol>
<li>选择服务商,填写 Base URL(本地服务必填)。</li>
<li>填写 API Key(本地服务通常可留空)。</li>
<li>点击<strong>获取可用模型</strong>:系统从服务的 <code>/v1/models</code> 接口拉取模型列表。本地服务装了多个模型(如 Ollama 里的 qwen2.5:7b、llama3.1:8b)时,可在下拉列表中挑选,不用手敲名字。</li>
<li>选中模型后勾选「启用 AI 模型理解问题」,点击<strong>保存配置</strong>。</li>
<li>首次配置先保持未启用,点击<strong>保存配置</strong>保存服务地址和密钥。页面立即提示保存结果,并自动获取可用模型;OpenAI 未填写 Base URL 时使用默认地址。</li>
<li>下方「已保存的配置」显示已保存模型、服务地址、启用状态和密钥配置状态;「可选模型」直接列出该服务的模型,可点击<strong>获取可用模型</strong>刷新。重新打开配置窗口时也会自动获取。</li>
<li>从列表选择模型或手动填写模型名,勾选「启用 AI 模型理解问题」,再点击<strong>保存配置</strong>。修改尚未保存时显示「待保存」;保存成功后「当前使用」标记更新,助手立即使用新的启用状态。</li>
<li>回到页面顶部打开「财务分析助手」,勾选「使用已配置的 AI 模型理解问题」并同意后即可提问。</li>
</ol>
<div class="note">
@@ -276,7 +277,7 @@
</div>
<h3>9.3 常见问题</h3>
<ul>
<li><strong>「获取可用模型」失败?</strong>确认本地服务已启动且 Base URL 正确(Ollama 默认 <code>http://127.0.0.1:11434/v1</code>),并先点击「保存配置」再获取。</li>
<li><strong>「获取可用模型」失败?</strong>模型列表获取与配置保存分别提示结果;列表失败不会撤销已保存的配置。确认服务已启动、Base URL 和 API Key 正确后重试,也可手动填写模型名。修改服务地址或密钥后需先保存,再获取新服务的模型列表。</li>
<li><strong>助手提示「尚未配置外部 AI 服务」?</strong>检查「AI 模型」中是否已启用、模型名是否已填写;OpenAI 还需 API Key。</li>
<li><strong>想停用 AI?</strong>取消勾选「启用 AI 模型理解问题」并保存,助手回到纯本地规则模式。</li>
</ul>
+13 -11
View File
@@ -55,14 +55,8 @@ def org_models_endpoint(org):
return (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), settings.OPENAI_API_KEY
@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(
{
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 "",
@@ -70,7 +64,15 @@ def ai_config(request):
"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(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",))
+10 -1
View File
@@ -1540,7 +1540,16 @@ export function App() {
{(modal === "users" || modal === "audit") && (
<AdminDialog mode={modal} onClose={() => setModal(null)} />
)}
{modal === "ai" && <AIConfigDialog onClose={() => setModal(null)} />}
{modal === "ai" && (
<AIConfigDialog
onClose={() => setModal(null)}
onSaved={(config) =>
setSession((current) =>
current ? { ...current, ai_enabled: config.enabled } : current,
)
}
/>
)}
</div>
);
}
+210 -36
View File
@@ -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<AIConfig>({
enabled: false,
provider: "local",
@@ -480,45 +486,97 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
api_key_set: false,
updated_at: null,
}),
[savedConfig, setSavedConfig] = useState<AIConfig | null>(null),
[key, setKey] = useState(""),
[models, setModels] = useState<string[]>([]),
[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<AIConfig>("/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<AIConfig>) {
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<AIConfig>("/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,11 +587,13 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
<p className="muted">
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
</p>
{loading && <p className="muted">正在加载已保存配置…</p>}
<fieldset className="ai-config-fields" disabled={!savedConfig || busy}>
<label className="check">
<input
type="checkbox"
checked={value.enabled}
onChange={(e) => setValue({ ...value, enabled: e.target.checked })}
onChange={(e) => updateValue({ enabled: e.target.checked })}
/>
启用 AI 模型理解问题
</label>
@@ -542,8 +602,7 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
<select
value={value.provider}
onChange={(e) =>
setValue({
...value,
updateValue({
provider: e.target.value as AIConfig["provider"],
})
}
@@ -563,7 +622,7 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
? "http://127.0.0.1:11434/v1"
: "https://api.openai.com/v1"
}
onChange={(e) => setValue({ ...value, base_url: e.target.value })}
onChange={(e) => updateValue({ base_url: e.target.value })}
/>
</label>
<label>
@@ -572,47 +631,162 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
type="password"
value={key}
placeholder={
value.api_key_set ? "已配置,留空保持不变" : "本地服务通常可留空"
value.api_key_set
? "已配置,留空保持不变"
: "本地服务通常可留空"
}
onChange={(e) => setKey(e.target.value)}
onChange={(e) => {
setKey(e.target.value);
setMessage("");
setError("");
}}
/>
</label>
<label>
模型
<input
list="ai-model-list"
value={value.model}
placeholder="模型名,如 qwen2.5:7b"
onChange={(e) => setValue({ ...value, model: e.target.value })}
onChange={(e) => updateValue({ model: e.target.value })}
/>
<datalist id="ai-model-list">
{models.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</label>
<footer>
<button onClick={onClose}>取消</button>
</fieldset>
{savedConfig && (
<>
<section className="ai-config-summary" aria-label="已保存的配置">
<h3>已保存的配置</h3>
{savedConfig.updated_at ? (
<>
<p>
{savedConfig.enabled
? "当前使用的模型:"
: "已保存的模型:"}
<strong>{savedConfig.model || "尚未选择"}</strong>
<span className="tag">
{savedConfig.enabled ? "已启用" : "未启用"}
</span>
</p>
<p className="muted">
{savedConfig.provider === "openai"
? "OpenAI"
: "OpenAI 兼容服务"}
{" · "}
{savedConfig.base_url ||
(savedConfig.provider === "openai"
? "https://api.openai.com/v1"
: "未设置地址")}
{" · "}API Key{" "}
{savedConfig.api_key_set ? "已配置" : "未配置"}
</p>
</>
) : (
<p className="muted">
尚未保存配置。请先保存服务地址和 API Key,再选择模型并启用。
</p>
)}
</section>
<section className="ai-models" aria-label="可选模型">
<div className="ai-models-heading">
<h3>可选模型</h3>
<button
type="button"
disabled={listing || busy}
onClick={fetchModels}
disabled={
listing || busy || connectionChanged || !canListModels
}
onClick={() => void fetchModels()}
>
{listing ? "获取中…" : "获取可用模型"}
</button>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "保存中…" : "保存配置"}
</button>
</footer>
{error && (
<p role="alert" className="error">
{error}
</div>
<p className="muted">
选择后点击“保存配置”生效;也可以在上方手动填写模型名。
</p>
{connectionChanged ? (
<p className="muted">
服务配置已修改,请先保存后获取可选模型。
</p>
) : (
<>
{listing && <p className="muted">正在获取可用模型…</p>}
{modelsError && (
<p role="alert" className="error">
{modelsError}
</p>
)}
{!listing &&
!modelsError &&
!models.length &&
canListModels && (
<p className="muted">
该服务没有返回可用模型,可重试或手动填写模型名。
</p>
)}
{savedConfig.updated_at && !canListModels && (
<p className="muted">
填写并保存 Base URL 后可获取模型列表。
</p>
)}
<div className="ai-model-options">
{availableModels.map((model) => (
<label key={model} className="ai-model-option">
<input
type="radio"
name="ai-model"
aria-label={model}
value={model}
checked={value.model === model}
disabled={busy}
onChange={() => updateValue({ model })}
/>
<span>{model}</span>
{savedConfig.model === model && (
<span className="tag">
{savedConfig.enabled ? "当前使用" : "已保存"}
</span>
)}
{value.model === model &&
value.model !== savedConfig.model && (
<span className="tag">待保存</span>
)}
</label>
))}
</div>
</>
)}
{value.model !== savedConfig.model && (
<p>
待保存的模型:<strong>{value.model || "尚未选择"}</strong>
</p>
)}
</section>
</>
)}
<p className="muted">
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
配置修改会记入操作审计。
</p>
<footer className="ai-config-footer">
<div className="ai-config-feedback">
{message && (
<p role="status" className="ai-config-success">
{message}
</p>
)}
{error && (
<p role="alert" className="error">
{error}
</p>
)}
</div>
<button onClick={onClose}>关闭</button>
<button
className="primary"
disabled={!savedConfig || busy}
onClick={save}
>
{busy ? "保存中…" : "保存配置"}
</button>
</footer>
</div>
</Modal>
);
+88
View File
@@ -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;
}
+410
View File
@@ -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,
});
});
+32 -1
View File
@@ -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(