fix(ai): clarify config save feedback and model selection
This commit was merged in pull request #10.
This commit is contained in:
@@ -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>
|
||||
|
||||
+14
-12
@@ -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",))
|
||||
|
||||
+10
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+265
-91
@@ -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,90 +587,206 @@ export function AIConfigDialog({ onClose }: { onClose: () => void }) {
|
||||
<p className="muted">
|
||||
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
|
||||
</p>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.enabled}
|
||||
onChange={(e) => setValue({ ...value, enabled: e.target.checked })}
|
||||
/>
|
||||
启用 AI 模型理解问题
|
||||
</label>
|
||||
<label>
|
||||
服务商
|
||||
<select
|
||||
value={value.provider}
|
||||
onChange={(e) =>
|
||||
setValue({
|
||||
...value,
|
||||
provider: e.target.value as AIConfig["provider"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="local">
|
||||
本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input
|
||||
value={value.base_url}
|
||||
placeholder={
|
||||
value.provider === "local"
|
||||
? "http://127.0.0.1:11434/v1"
|
||||
: "https://api.openai.com/v1"
|
||||
}
|
||||
onChange={(e) => setValue({ ...value, base_url: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
placeholder={
|
||||
value.api_key_set ? "已配置,留空保持不变" : "本地服务通常可留空"
|
||||
}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<input
|
||||
list="ai-model-list"
|
||||
value={value.model}
|
||||
placeholder="模型名,如 qwen2.5:7b"
|
||||
onChange={(e) => setValue({ ...value, model: e.target.value })}
|
||||
/>
|
||||
<datalist id="ai-model-list">
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<footer>
|
||||
<button onClick={onClose}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={listing || busy}
|
||||
onClick={fetchModels}
|
||||
>
|
||||
{listing ? "获取中…" : "获取可用模型"}
|
||||
</button>
|
||||
<button className="primary" disabled={busy} onClick={save}>
|
||||
{busy ? "保存中…" : "保存配置"}
|
||||
</button>
|
||||
</footer>
|
||||
{error && (
|
||||
<p role="alert" className="error">
|
||||
{error}
|
||||
</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) => updateValue({ enabled: e.target.checked })}
|
||||
/>
|
||||
启用 AI 模型理解问题
|
||||
</label>
|
||||
<label>
|
||||
服务商
|
||||
<select
|
||||
value={value.provider}
|
||||
onChange={(e) =>
|
||||
updateValue({
|
||||
provider: e.target.value as AIConfig["provider"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="local">
|
||||
本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input
|
||||
value={value.base_url}
|
||||
placeholder={
|
||||
value.provider === "local"
|
||||
? "http://127.0.0.1:11434/v1"
|
||||
: "https://api.openai.com/v1"
|
||||
}
|
||||
onChange={(e) => updateValue({ base_url: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
placeholder={
|
||||
value.api_key_set
|
||||
? "已配置,留空保持不变"
|
||||
: "本地服务通常可留空"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setMessage("");
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<input
|
||||
value={value.model}
|
||||
placeholder="模型名,如 qwen2.5:7b"
|
||||
onChange={(e) => updateValue({ model: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</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 || connectionChanged || !canListModels
|
||||
}
|
||||
onClick={() => void fetchModels()}
|
||||
>
|
||||
{listing ? "获取中…" : "获取可用模型"}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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