fix(ai): clarify config save feedback and model selection
Verify / verify (pull_request) Failing after 1s
AI Review / review (pull_request) Successful in 10s
Verify / verify (push) Failing after 2m33s

This commit was merged in pull request #10.
This commit is contained in:
Jayway Liu
2026-11-01 09:48:09 +08:00
parent 3c3799910d
commit 4731742042
7 changed files with 823 additions and 108 deletions
+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>
);
}
+265 -91
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,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>
);
+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;
}