fix(ai): clarify config save feedback and model selection
This commit was merged in pull request #10.
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user