Files
sundynix-agentix/sundynix-admin/src/components/ModelManager.tsx
T
Blizzard 95f3781076 feat(admin): 模型配置加「编辑」按钮 + 语音默认提示改 deepseek-v4-flash
- ModelManager: 每行加「编辑」→ 载入表单就地改(api_key 留空=沿用现有密钥),
  后端按 id 走更新而非新增;表单头/按钮随编辑态切换,带「取消」;省去删了重登记
- ModelsPage: 语音默认模型提示 deepseek-chat→deepseek-v4-flash(chat 2026/07/24 弃用)

注:语音模型实例已通过 admin API 从 deepseek-chat 热切到 deepseek-v4-flash(dispatcher
现读 v4-flash,无需重启)——验证了编辑/激活的 NATS 热更新链路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:07:13 +08:00

218 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
listModels,
saveModel,
setActive,
deleteModel,
testModel,
type Kind,
type Model,
type ModelInput,
} from "../api";
// 复用的模型控制面:列表(激活/脱敏key) + 登记表单 + 测试连接 + 激活/删除。
// 按 kind(chat/embedding) 区分,激活后经 NATS 热更新对应消费方。
export function ModelManager({
kind,
title,
baseUrlHint,
modelHint,
}: {
kind: Kind;
title: string;
baseUrlHint: string;
modelHint: string;
}) {
const empty: ModelInput = { kind, provider: "openai-compatible", base_url: "", api_key: "", model: "" };
const [models, setModels] = useState<Model[]>([]);
const [form, setForm] = useState<ModelInput>(empty);
const [msg, setMsg] = useState("");
const [testing, setTesting] = useState(false);
const refresh = () => listModels(kind).then(setModels).catch((e) => setMsg(`${e.message}`));
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kind]);
const set = (k: keyof ModelInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
const editing = !!form.id;
// onEdit 把某行载入表单就地编辑(api_key 留空=沿用现有密钥,后端按 id 更新而非新增)。
const onEdit = (m: Model) => {
setForm({ id: m.id, kind, provider: m.provider, base_url: m.base_url, api_key: "", model: m.model });
setMsg("");
};
const onSave = async () => {
try {
await saveModel({ ...form, kind });
setMsg(editing ? "✓ 已更新并热更新" : "✓ 已保存");
setForm(empty);
refresh();
} catch (e) {
setMsg(`${(e as Error).message}`);
}
};
const onTest = async () => {
setTesting(true);
try {
const r = await testModel({ ...form, kind });
setMsg(r.ok ? `✓ 连接成功(${r.message}` : `✗ 连接失败:${r.message}`);
} catch (e) {
setMsg(`${(e as Error).message}`);
} finally {
setTesting(false);
}
};
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="mb-3 text-sm font-semibold text-gray-700">{title}</h2>
<div className="overflow-hidden rounded border">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Provider</th>
<th className="px-3 py-2">Base URL</th>
<th className="px-3 py-2">Model</th>
<th className="px-3 py-2">API Key</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{models.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-xs text-gray-400">
使
</td>
</tr>
)}
{models.map((m) => (
<tr key={m.id} className="border-t">
<td className="px-3 py-2">
{m.active ? (
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] text-emerald-700"></span>
) : (
<span className="text-[10px] text-gray-400"></span>
)}
</td>
<td className="px-3 py-2 text-gray-600">{m.provider}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-600">{m.base_url}</td>
<td className="px-3 py-2 text-gray-800">{m.model}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-400">{m.api_key || "—"}</td>
<td className="px-3 py-2">
<div className="flex gap-2">
{!m.active && (
<button
onClick={() => setActive(m.id).then(() => { setMsg("✓ 已激活并热更新"); refresh(); })}
className="rounded border px-2 py-0.5 text-xs text-violet-600 hover:bg-violet-50"
>
</button>
)}
<button
onClick={() => onEdit(m)}
className="rounded border px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
</button>
<button
onClick={() => {
// 删除不可撤销,且删掉在用模型会直接打断线上推理——所以要二次确认,
// 且把"这条正在用"讲明白(此前是一点就删,无任何提示)。
const warn = m.active
? `${m.model}」正在使用中,删除后该 ${m.kind === "embedding" ? "向量" : "对话"}能力立即不可用。`
: `将删除「${m.model}」(${m.provider})。`;
if (!window.confirm(`${warn}\n此操作不可撤销,确定继续?`)) return;
void deleteModel(m.id).then(refresh);
}}
className="rounded border px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-50"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="max-w-xl">
<h2 className="mb-3 text-sm font-semibold text-gray-700">
{editing ? `编辑「${form.model}」(改完保存即热更新)` : "登记(开发期:第三方在线 APIOpenAI 兼容)"}
</h2>
<div className="grid grid-cols-2 gap-3">
<label className="text-xs text-gray-500">
Provider
<select
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
value={form.provider}
onChange={(e) => set("provider", e.target.value)}
>
<option value="openai-compatible">openai-compatible线 API</option>
<option value="vllm">vllm</option>
<option value="ollama">ollama</option>
</select>
</label>
<label className="text-xs text-gray-500">
Model
<input
className="mt-1 w-full rounded border px-2 py-1 text-sm"
value={form.model}
onChange={(e) => set("model", e.target.value)}
placeholder={modelHint}
/>
</label>
<label className="col-span-2 text-xs text-gray-500">
Base URL
<input
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
value={form.base_url}
onChange={(e) => set("base_url", e.target.value)}
placeholder={baseUrlHint}
/>
</label>
<label className="col-span-2 text-xs text-gray-500">
API Key
<input
type="password"
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
value={form.api_key}
onChange={(e) => set("api_key", e.target.value)}
placeholder={editing ? "留空=沿用现有密钥" : "sk-…"}
/>
</label>
</div>
<div className="mt-3 flex items-center gap-2">
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">
{editing ? "更新" : "保存"}
</button>
<button
onClick={onTest}
disabled={testing || !form.base_url}
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
>
{testing ? "测试中…" : "测试连接"}
</button>
{editing && (
<button
onClick={() => { setForm(empty); setMsg(""); }}
className="rounded border px-3 py-1 text-sm text-gray-500 hover:bg-gray-50"
>
</button>
)}
{msg && <span className="text-xs text-gray-600">{msg}</span>}
</div>
</section>
</div>
);
}