feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、 /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计 - 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM), 可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量 - 助手调用走生效配置,Copilot 文案同步更新 - 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -33,6 +34,7 @@ import { api, download, setCsrf } from "./api";
|
||||
import { Chart } from "./Chart";
|
||||
import {
|
||||
AdminDialog,
|
||||
AIConfigDialog,
|
||||
FormulaExportDialog,
|
||||
Copilot,
|
||||
EvidenceDialog,
|
||||
@@ -83,6 +85,7 @@ export function App() {
|
||||
| "dictionary"
|
||||
| "users"
|
||||
| "audit"
|
||||
| "ai"
|
||||
| null
|
||||
| "formula"
|
||||
>(null),
|
||||
@@ -543,6 +546,10 @@ export function App() {
|
||||
<ShieldCheck />
|
||||
操作审计
|
||||
</button>
|
||||
<button onClick={() => setModal("ai")}>
|
||||
<Cpu />
|
||||
AI 模型
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
@@ -1476,6 +1483,7 @@ export function App() {
|
||||
{(modal === "users" || modal === "audit") && (
|
||||
<AdminDialog mode={modal} onClose={() => setModal(null)} />
|
||||
)}
|
||||
{modal === "ai" && <AIConfigDialog onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+149
-1
@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea
|
||||
import { api } from "./api";
|
||||
import {
|
||||
labels,
|
||||
type AIConfig,
|
||||
type Context,
|
||||
type Dataset,
|
||||
type Metric,
|
||||
@@ -471,6 +472,153 @@ export function EvidenceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function AIConfigDialog({ onClose }: { onClose: () => void }) {
|
||||
const [value, setValue] = useState<AIConfig>({
|
||||
enabled: false,
|
||||
provider: "local",
|
||||
base_url: "",
|
||||
model: "",
|
||||
api_key_set: false,
|
||||
updated_at: null,
|
||||
}),
|
||||
[key, setKey] = useState(""),
|
||||
[models, setModels] = useState<string[]>([]),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[listing, setListing] = useState(false);
|
||||
useEffect(() => {
|
||||
api<AIConfig>("/ai-config")
|
||||
.then((r) => setValue(r))
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}, []);
|
||||
async function fetchModels() {
|
||||
setListing(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await api<{ items: string[] }>("/ai-models");
|
||||
setModels(r.items);
|
||||
if (!r.items.length) setError("该服务没有返回可用模型");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setListing(false);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api("/ai-config", "PUT", {
|
||||
enabled: value.enabled,
|
||||
provider: value.provider,
|
||||
base_url: value.base_url,
|
||||
model: value.model,
|
||||
api_key: key,
|
||||
});
|
||||
setKey("");
|
||||
setValue((v) => ({ ...v, api_key_set: v.api_key_set || Boolean(key) }));
|
||||
if (value.base_url) await fetchModels();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal title="AI 模型配置" onClose={onClose} wide>
|
||||
<div className="dialog-body">
|
||||
<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>
|
||||
)}
|
||||
<p className="muted">
|
||||
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
|
||||
配置修改会记入操作审计。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Copilot({
|
||||
ds,
|
||||
ctx,
|
||||
@@ -550,7 +698,7 @@ export function Copilot({
|
||||
checked={useModel}
|
||||
onChange={(e) => setUseModel(e.target.checked)}
|
||||
/>
|
||||
使用外部 AI 理解问题
|
||||
使用已配置的 AI 模型理解问题
|
||||
</label>
|
||||
{useModel && (
|
||||
<label className="check">
|
||||
|
||||
+10
-2
@@ -107,15 +107,23 @@ export interface Analysis {
|
||||
current: string[];
|
||||
baseline: string[];
|
||||
};
|
||||
summary: Record<Metric, Value>;
|
||||
summary: Record<string, Value>;
|
||||
rows: Row[];
|
||||
trend: ({ period: string } & Record<Metric, Numeric>)[];
|
||||
trend: ({ period: string } & Record<string, Numeric>)[];
|
||||
row_count: number;
|
||||
anomaly_count: number;
|
||||
comparable: boolean;
|
||||
notice: string;
|
||||
version: string;
|
||||
}
|
||||
export interface AIConfig {
|
||||
enabled: boolean;
|
||||
provider: "openai" | "local";
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_set: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
export interface Session {
|
||||
csrf: string;
|
||||
user: {
|
||||
|
||||
Reference in New Issue
Block a user