Skip to content

Commit bdbbb5f

Browse files
committed
feat(ai): 设置页 Provider 管理面板上线,AiServicePanel 瘦身(v0.11.6 M1)
1 parent 767d9d9 commit bdbbb5f

4 files changed

Lines changed: 346 additions & 223 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/**
2+
* AiProviderSettings — Provider 管理面板(v0.11.6 M1,BYOK 多端点)。
3+
*
4+
* @ai-context: 列表卡片(名称/状态/默认模型/密钥/默认标记)+ 操作(编辑/测试/
5+
* 启用/删除/设默认)+ "添加 Provider"向导(预设模板:SiliconFlow/
6+
* DeepSeek/OpenRouter/Ollama/自定义)。密钥只写不回传——视图仅
7+
* 显示"已配置";测试连接走 ai_provider_test(最小 chat 请求)。
8+
*/
9+
import { useEffect, useState } from "react";
10+
import { invoke } from "@tauri-apps/api/core";
11+
import type { AiProviderInput, AiProviderView } from "../types";
12+
13+
const btn: React.CSSProperties = {
14+
padding: "5px 10px",
15+
fontSize: 12,
16+
borderRadius: 6,
17+
border: "1px solid #d1d5db",
18+
background: "#fff",
19+
cursor: "pointer",
20+
};
21+
22+
const inputStyle: React.CSSProperties = {
23+
flex: 1,
24+
padding: "5px 8px",
25+
fontSize: 12,
26+
borderRadius: 6,
27+
border: "1px solid #d1d5db",
28+
minWidth: 0,
29+
};
30+
31+
/** 预设模板(前端向导数据源;与后端 preset_templates 对应) */
32+
const PRESET_OPTIONS: { kind: AiProviderInput["kind"]; name: string; baseUrl: string; models: string }[] = [
33+
{ kind: "openAiCompat", name: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1", models: "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B" },
34+
{ kind: "openAiCompat", name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", models: "deepseek-chat" },
35+
{ kind: "openAiCompat", name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", models: "openai/gpt-4o-mini" },
36+
{ kind: "ollama", name: "Ollama(本地)", baseUrl: "http://127.0.0.1:11434/v1", models: "qwen2.5:7b" },
37+
];
38+
39+
export default function AiProviderSettings() {
40+
const [providers, setProviders] = useState<AiProviderView[]>([]);
41+
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
42+
const [busy, setBusy] = useState(false);
43+
const [editing, setEditing] = useState<AiProviderView | null>(null);
44+
const [showAdd, setShowAdd] = useState(false);
45+
46+
useEffect(() => {
47+
void load();
48+
// eslint-disable-next-line react-hooks/exhaustive-deps
49+
}, []);
50+
51+
const load = async () => {
52+
try {
53+
setProviders(await invoke<AiProviderView[]>("ai_provider_list"));
54+
} catch (e) {
55+
setMsg({ kind: "err", text: `读取 Provider 列表失败:${e}` });
56+
}
57+
};
58+
59+
const run = async (op: () => Promise<unknown>, okText?: string) => {
60+
setBusy(true);
61+
try {
62+
await op();
63+
if (okText) setMsg({ kind: "ok", text: okText });
64+
await load();
65+
} catch (e) {
66+
setMsg({ kind: "err", text: `${e}` });
67+
} finally {
68+
setBusy(false);
69+
}
70+
};
71+
72+
const testProvider = (id: string) =>
73+
run(async () => {
74+
const reply = await invoke<string>("ai_provider_test", { id });
75+
setMsg({ kind: "ok", text: `连接成功:${reply}` });
76+
});
77+
78+
const removeProvider = (id: string) =>
79+
run(() => invoke("ai_provider_remove", { id }), "已删除");
80+
81+
const setDefault = (id: string) =>
82+
run(() => invoke("ai_set_default_provider", { id }), "已设为默认");
83+
84+
const toggleEnabled = (p: AiProviderView) =>
85+
run(
86+
() =>
87+
invoke("ai_provider_update", {
88+
id: p.id,
89+
input: {
90+
name: p.name,
91+
kind: p.kind,
92+
baseUrl: p.baseUrl,
93+
models: p.models,
94+
defaultModel: p.defaultModel,
95+
enabled: !p.enabled,
96+
fallbackOrder: p.fallbackOrder,
97+
},
98+
}),
99+
p.enabled ? "已禁用" : "已启用",
100+
);
101+
102+
const saveKey = (id: string) => {
103+
const key = window.prompt("输入 API 密钥(将加密保存到系统凭据保护)");
104+
if (key === null) return;
105+
run(() => invoke("ai_provider_save_key", { id, apiKey: key.trim() }), "密钥已保存");
106+
};
107+
108+
const clearKey = (id: string) =>
109+
run(() => invoke("ai_provider_clear_key", { id }), "密钥已清除");
110+
111+
return (
112+
<div style={{ fontSize: 12, color: "#1f2937" }}>
113+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
114+
<span style={{ fontWeight: 600, fontSize: 13 }}>AI 服务提供商</span>
115+
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={() => setShowAdd(true)} disabled={busy}>
116+
+ 添加 Provider
117+
</button>
118+
</div>
119+
120+
{providers.length === 0 && (
121+
<div style={{ color: "#9ca3af", marginBottom: 8 }}>
122+
尚未配置 Provider——添加后即可启用 AI 功能(支持 SiliconFlow/DeepSeek/OpenRouter/Ollama 本地等)。
123+
</div>
124+
)}
125+
126+
{providers.map((p) => (
127+
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 6, padding: 8, marginBottom: 8 }}>
128+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
129+
<span style={{ fontWeight: 600 }}>{p.name}</span>
130+
{p.isDefault && <span style={{ color: "#0d9488", fontWeight: 600 }}>● 默认</span>}
131+
<span style={{ color: p.enabled ? "#0d9488" : "#9ca3af" }}>{p.enabled ? "已启用" : "已禁用"}</span>
132+
{p.hasKey && <span style={{ color: "#6b7280" }}>密钥已配置</span>}
133+
</div>
134+
<div style={{ color: "#6b7280", marginBottom: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
135+
{p.baseUrl} · {p.defaultModel}
136+
</div>
137+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
138+
<button style={btn} onClick={() => setEditing(p)} disabled={busy}>编辑</button>
139+
<button style={btn} onClick={() => void testProvider(p.id)} disabled={busy}>测试连接</button>
140+
{!p.isDefault && <button style={btn} onClick={() => void setDefault(p.id)} disabled={busy}>设为默认</button>}
141+
<button style={btn} onClick={() => void toggleEnabled(p)} disabled={busy}>{p.enabled ? "禁用" : "启用"}</button>
142+
{p.hasKey ? (
143+
<button style={btn} onClick={() => void clearKey(p.id)} disabled={busy}>清除密钥</button>
144+
) : (
145+
<button style={btn} onClick={() => void saveKey(p.id)} disabled={busy}>配置密钥</button>
146+
)}
147+
{!p.isDefault && (
148+
<button style={{ ...btn, color: "#dc2626" }} onClick={() => void removeProvider(p.id)} disabled={busy}>删除</button>
149+
)}
150+
</div>
151+
</div>
152+
))}
153+
154+
{showAdd && (
155+
<ProviderForm
156+
onCancel={() => setShowAdd(false)}
157+
onDone={(text) => {
158+
setShowAdd(false);
159+
setMsg({ kind: "ok", text });
160+
void load();
161+
}}
162+
onError={(text) => setMsg({ kind: "err", text })}
163+
/>
164+
)}
165+
166+
{editing && (
167+
<ProviderForm
168+
initial={editing}
169+
onCancel={() => setEditing(null)}
170+
onDone={(text) => {
171+
setEditing(null);
172+
setMsg({ kind: "ok", text });
173+
void load();
174+
}}
175+
onError={(text) => setMsg({ kind: "err", text })}
176+
/>
177+
)}
178+
179+
{msg && (
180+
<div style={{ fontSize: 11, color: msg.kind === "ok" ? "#0d9488" : "#dc2626", marginTop: 4 }}>
181+
{msg.text}
182+
</div>
183+
)}
184+
</div>
185+
);
186+
}
187+
188+
/** Provider 表单(添加/编辑共用:预设模板 + 字段 + 密钥) */
189+
function ProviderForm({
190+
initial,
191+
onCancel,
192+
onDone,
193+
onError,
194+
}: {
195+
initial?: AiProviderView;
196+
onCancel: () => void;
197+
onDone: (text: string) => void;
198+
onError: (text: string) => void;
199+
}) {
200+
const [preset, setPreset] = useState(PRESET_OPTIONS[0]);
201+
const [name, setName] = useState(initial?.name ?? "");
202+
const [baseUrl, setBaseUrl] = useState(initial?.baseUrl ?? "");
203+
const [models, setModels] = useState(initial?.models.join("\n") ?? "");
204+
const [defaultModel, setDefaultModel] = useState(initial?.defaultModel ?? "");
205+
const [apiKey, setApiKey] = useState("");
206+
const [busy, setBusy] = useState(false);
207+
208+
const applyPreset = (i: number) => {
209+
const p = PRESET_OPTIONS[i];
210+
setPreset(p);
211+
if (!initial) {
212+
setName(p.name);
213+
setBaseUrl(p.baseUrl);
214+
setModels(p.models);
215+
setDefaultModel(p.models);
216+
}
217+
};
218+
219+
const submit = async () => {
220+
const modelList = models.split("\n").map((m) => m.trim()).filter(Boolean);
221+
if (!name.trim() || !baseUrl.trim() || modelList.length === 0 || !defaultModel.trim()) {
222+
onError("请填写名称、端点、模型列表与默认模型");
223+
return;
224+
}
225+
const input: AiProviderInput = {
226+
name: name.trim(),
227+
// 编辑时保留原 kind(预设只影响新增向导)
228+
kind: initial?.kind ?? preset.kind,
229+
baseUrl: baseUrl.trim(),
230+
models: modelList,
231+
defaultModel: defaultModel.trim(),
232+
enabled: initial?.enabled ?? true,
233+
fallbackOrder: [],
234+
apiKey: apiKey.trim() || undefined,
235+
};
236+
setBusy(true);
237+
try {
238+
if (initial) {
239+
await invoke("ai_provider_update", { id: initial.id, input });
240+
onDone("已保存");
241+
} else {
242+
await invoke("ai_provider_add", { input });
243+
onDone("已添加");
244+
}
245+
} catch (e) {
246+
onError(`${e}`);
247+
} finally {
248+
setBusy(false);
249+
}
250+
};
251+
252+
return (
253+
<div style={{ border: "1px solid #0d9488", borderRadius: 6, padding: 10, marginBottom: 10, background: "#fafafa" }}>
254+
<div style={{ fontWeight: 600, marginBottom: 6 }}>{initial ? "编辑 Provider" : "添加 Provider"}</div>
255+
{!initial && (
256+
<div style={{ marginBottom: 8 }}>
257+
<span style={{ marginRight: 6 }}>模板:</span>
258+
{PRESET_OPTIONS.map((p, i) => (
259+
<button
260+
key={p.name}
261+
style={{ ...btn, marginRight: 4, background: preset.name === p.name ? "#0d9488" : "#fff", color: preset.name === p.name ? "#fff" : "#1f2937", border: "none" }}
262+
onClick={() => applyPreset(i)}
263+
>
264+
{p.name}
265+
</button>
266+
))}
267+
</div>
268+
)}
269+
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
270+
<input style={inputStyle} placeholder="名称(如:我的 DeepSeek)" value={name} onChange={(e) => setName(e.target.value)} />
271+
<input style={{ ...inputStyle, width: 160 }} placeholder="端点 URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
272+
</div>
273+
<div style={{ marginBottom: 6 }}>
274+
<input style={inputStyle} placeholder="模型列表(每行一个)" value={models} onChange={(e) => setModels(e.target.value)} />
275+
</div>
276+
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
277+
<input style={inputStyle} placeholder="默认模型" value={defaultModel} onChange={(e) => setDefaultModel(e.target.value)} />
278+
<input
279+
style={{ ...inputStyle, width: 200 }}
280+
type="password"
281+
placeholder={initial?.hasKey ? "已配置 · 留空不改" : "API 密钥"}
282+
value={apiKey}
283+
onChange={(e) => setApiKey(e.target.value)}
284+
/>
285+
</div>
286+
<div style={{ display: "flex", gap: 8 }}>
287+
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={() => void submit()} disabled={busy}>
288+
保存
289+
</button>
290+
<button style={btn} onClick={onCancel} disabled={busy}>
291+
取消
292+
</button>
293+
</div>
294+
</div>
295+
);
296+
}

0 commit comments

Comments
 (0)