Files
yingqing/core/frontend/src/routes/admin/admin-models.tsx
T

481 lines
24 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 { useCallback, useEffect, useState } from "react";
import { Server, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { SystemLoading } from "../../components/loading";
import { CustomSelect } from "../../components/custom-select";
import type { AdminModel, AdminProvider } from "../../types";
import { pts } from "../stage-config";
function sortAdminModels(list: AdminModel[]): AdminModel[] {
// 默认最前,启用次之,停用靠后;同档按能力、供应商
return [...list].sort((a, b) => {
if (Boolean(a.is_default) !== Boolean(b.is_default)) return a.is_default ? -1 : 1;
if ((a.status === "active") !== (b.status === "active")) return a.status === "active" ? -1 : 1;
const cap = String(a.capability || "").localeCompare(String(b.capability || ""));
if (cap !== 0) return cap;
return String(a.provider_name || a.provider || "").localeCompare(String(b.provider_name || b.provider || ""));
});
}
type Notify = (type: "success" | "error" | "info", text: string) => void;
const PAGE_SIZE = 10;
const CAPABILITIES = ["text", "image", "video", "vision", "audio", "export"];
function statusPill(active: boolean) {
return active
? <span className="pill ok"><span className="dot" />启用</span>
: <span className="pill neutral"><span className="dot" />停用</span>;
}
const EMPTY_PROVIDER = { id: "", name: "", display_name: "", base_url: "", api_key: "", status: "active" };
const VIDEO_RES_OPTIONS = ["480p", "720p", "1080p", "4k"];
type VideoTier = { resolution: string; points_per_second: string; points_per_second_with_ref: string };
type ModelForm = {
id: string;
provider: string;
name: string;
display_name: string;
capability: string;
endpoint: string;
unit_price: string;
status: string;
durationsText: string;
videoTiers: VideoTier[];
};
const EMPTY_MODEL: ModelForm = {
id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "",
unit_price: "", status: "active", durationsText: "4,5,6,8,10,12,15",
videoTiers: [{ resolution: "480p", points_per_second: "20", points_per_second_with_ref: "" }],
};
function readModelForm(m: AdminModel): ModelForm {
const meta = (m.metadata || {}) as Record<string, unknown>;
const caps = (meta.capabilities || {}) as Record<string, unknown>;
const pricing = (meta.points_pricing || {}) as Record<string, unknown>;
const durations = Array.isArray(caps.durations) ? caps.durations.map(String) : [];
const tiersRaw = Array.isArray(pricing.tiers) ? pricing.tiers : [];
const videoTiers: VideoTier[] = tiersRaw
.filter((row): row is Record<string, unknown> => !!row && typeof row === "object")
.map((row) => ({
resolution: String(row.resolution || ""),
points_per_second: String(row.points_per_second ?? ""),
points_per_second_with_ref: String(row.points_per_second_with_ref ?? ""),
}));
let unit = m.unit_price || "";
if (m.capability === "image" && pricing.points_per_image != null) unit = String(pricing.points_per_image);
if ((m.capability === "text" || m.capability === "vision") && pricing.points_per_call != null) {
unit = String(pricing.points_per_call);
}
return {
id: m.id,
provider: m.provider,
name: m.name,
display_name: m.display_name,
capability: m.capability,
endpoint: m.endpoint,
unit_price: unit,
status: m.status,
durationsText: durations.length ? durations.join(",") : "4,5,6,8,10,12,15",
videoTiers: videoTiers.length
? videoTiers
: [{ resolution: "480p", points_per_second: "", points_per_second_with_ref: "" }],
};
}
function buildMetadata(form: ModelForm): Record<string, unknown> {
if (form.capability === "image") {
const pts = Number(form.unit_price || 0);
return { points_pricing: { mode: "per_image", points_per_image: Number.isFinite(pts) ? pts : 0 } };
}
if (form.capability === "text" || form.capability === "vision") {
const pts = Number(form.unit_price || 0);
return { points_pricing: { mode: "per_call", points_per_call: Number.isFinite(pts) ? pts : 0 } };
}
if (form.capability === "video") {
const durations = form.durationsText
.split(/[,,\s]+/)
.map((x) => Number(x))
.filter((n) => Number.isFinite(n) && n > 0);
const tiers = form.videoTiers
.filter((row) => row.resolution && row.points_per_second !== "")
.map((row) => {
const item: Record<string, unknown> = {
resolution: row.resolution,
points_per_second: Number(row.points_per_second),
};
if (row.points_per_second_with_ref !== "") {
item.points_per_second_with_ref = Number(row.points_per_second_with_ref);
}
return item;
});
return {
capabilities: {
resolutions: tiers.map((t) => String(t.resolution)),
durations,
operations: ["video_generate"],
},
points_pricing: { mode: "per_second", tiers },
};
}
return {};
}
export function AdminModelsPage({ notify }: { notify: Notify }) {
const [providers, setProviders] = useState<AdminProvider[]>([]);
const [models, setModels] = useState<AdminModel[]>([]);
const [modelPage, setModelPage] = useState(1);
const [loading, setLoading] = useState(true);
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null);
const [modelModal, setModelModal] = useState<ModelForm | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
setProviders(ps);
setModels(sortAdminModels(ms));
} catch {
notify("error", "加载模型供应商失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { void load(); }, [load]);
async function toggleProvider(p: AdminProvider) {
try {
await adminApi.updateProvider(p.id, { status: p.status === "active" ? "disabled" : "active" });
notify("success", "供应商状态已更新");
await load();
} catch { notify("error", "操作失败"); }
}
async function toggleModel(m: AdminModel) {
try {
await adminApi.updateModel(m.id, { status: m.status === "active" ? "disabled" : "active" });
notify("success", "模型状态已更新");
await load();
} catch { notify("error", "操作失败"); }
}
async function setDefault(m: AdminModel) {
try {
await adminApi.setDefaultModel(m.id);
notify("success", `已设为 ${m.capability} 默认模型`);
await load();
} catch { notify("error", "设默认失败"); }
}
async function delModel(m: AdminModel) {
try {
await adminApi.deleteModel(m.id);
notify("success", "模型已删除");
await load();
} catch { notify("error", "删除失败"); }
}
async function saveProvider() {
if (!provModal || saving) return;
if (!provModal.name.trim() || !provModal.display_name.trim()) { notify("error", "请填写名称"); return; }
setSaving(true);
try {
const payload: Record<string, unknown> = { display_name: provModal.display_name, base_url: provModal.base_url, status: provModal.status };
if (provModal.api_key) payload.api_key = provModal.api_key;
if (provModal.id) {
await adminApi.updateProvider(provModal.id, payload);
} else {
await adminApi.createProvider({ name: provModal.name, display_name: provModal.display_name, base_url: provModal.base_url, api_key: provModal.api_key || undefined, status: provModal.status });
}
notify("success", "供应商已保存");
setProvModal(null);
await load();
} catch (e) { notify("error", e instanceof Error ? e.message : "保存失败"); }
finally { setSaving(false); }
}
async function saveModel() {
if (!modelModal || saving) return;
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); return; }
if (modelModal.capability === "video") {
const ok = modelModal.videoTiers.some((row) => row.resolution && row.points_per_second !== "");
if (!ok) { notify("error", "视频模型请至少添加一档分辨率和每秒积分"); return; }
}
setSaving(true);
try {
const metadata = buildMetadata(modelModal);
if (modelModal.id) {
await adminApi.updateModel(modelModal.id, {
display_name: modelModal.display_name,
endpoint: modelModal.endpoint,
unit_price: modelModal.unit_price || "0",
status: modelModal.status,
metadata,
});
} else {
await adminApi.createModel({
provider: modelModal.provider,
name: modelModal.name,
display_name: modelModal.display_name || modelModal.name,
capability: modelModal.capability,
endpoint: modelModal.endpoint,
unit_price: modelModal.unit_price || "0",
status: modelModal.status,
metadata,
} as Parameters<typeof adminApi.createModel>[0] & { metadata: Record<string, unknown> });
}
notify("success", "模型已保存");
setModelModal(null);
await load();
} catch (e) { notify("error", e instanceof Error ? e.message : "保存失败"); }
finally { setSaving(false); }
}
return (
<>
<div className="page-head">
<div>
<h1>模型供应商</h1>
<div className="sub"><span className="mono">{providers.length} 供应商 · {models.length} 模型</span> · 热插拔中转站 · 定价 · 设默认</div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => setProvModal({ ...EMPTY_PROVIDER })}>+ 供应商</button>
<button className="btn btn-primary" type="button" onClick={() => setModelModal({ ...EMPTY_MODEL, provider: providers[0]?.id || "" })}>+ 模型</button>
</div>
</div>
{loading ? (
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="server" />
) : (
<>
<div className="admin-detail-subhead">供应商</div>
<div className="admin-table-wrap">
<table className="t admin-table">
<thead><tr><th>名称</th><th>显示名</th><th>Base URL</th><th>密钥</th><th>模型数</th><th>状态</th><th className="col-actions">操作</th></tr></thead>
<tbody>
{providers.map((p) => (
<tr key={p.id}>
<td className="mono admin-code">{p.name}</td>
<td>{p.display_name}</td>
<td className="mono col-time">{p.base_url || <span className="muted">—</span>}</td>
<td>{p.has_api_key ? <span className="pill ok"><span className="dot" />已配</span> : <span className="pill neutral"><span className="dot" />无</span>}</td>
<td className="num">{p.model_count}</td>
<td>{statusPill(p.status === "active")}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setProvModal({ id: p.id, name: p.name, display_name: p.display_name, base_url: p.base_url, api_key: "", status: p.status })}>编辑</button>
<button className={`btn btn-sm btn-ghost${p.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleProvider(p)}>{p.status === "active" ? "停用" : "启用"}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="admin-detail-subhead" style={{ marginTop: 28 }}>模型</div>
<div className="admin-table-wrap">
<table className="t admin-table">
<thead><tr><th>供应商</th><th>模型</th><th>能力</th><th>单价(积分/次)</th><th>状态</th><th>默认</th><th className="col-actions">操作</th></tr></thead>
<tbody>
{models.slice((modelPage - 1) * PAGE_SIZE, modelPage * PAGE_SIZE).map((m) => (
<tr key={m.id}>
<td className="mono">{m.provider_name}</td>
<td>{m.display_name || m.name}</td>
<td><span className="pill neutral"><span className="dot" />{m.capability}</span></td>
<td className="num mono">{pts(m.unit_price)} 积分</td>
<td>{statusPill(m.status === "active")}</td>
<td>{m.is_default ? <span className="pill info"><span className="dot" />默认</span> : <span className="muted">—</span>}</td>
<td className="col-actions">
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}>设默认</button>}
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal(readModelForm(m))}>编辑</button>
<button className={`btn btn-sm btn-ghost${m.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleModel(m)}>{m.status === "active" ? "停用" : "启用"}</button>
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
<Pager page={modelPage} total={models.length} pageSize={PAGE_SIZE} onChange={setModelPage} alwaysShow />
</div>
</>
)}
{provModal && (
<div className="modal-bg show">
<div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Server size={16} /></div>
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setProvModal(null)}><X size={14} /></button>
</div>
<div className="modal-b">
<div className="field">
<label className="field-label">name <span className="field-hint">唯一标识 · 决定路由</span></label>
<input className="input" type="text" value={provModal.name} disabled={Boolean(provModal.id)} onChange={(e) => setProvModal((p) => p && ({ ...p, name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">显示名</label>
<input className="input" type="text" value={provModal.display_name} onChange={(e) => setProvModal((p) => p && ({ ...p, display_name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">Base URL</label>
<input className="input" type="text" placeholder="留空走 .env 回退" value={provModal.base_url} onChange={(e) => setProvModal((p) => p && ({ ...p, base_url: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">API Key <span className="field-hint">{provModal.id ? "留空=不改" : "可留空走 .env"}</span></label>
<input className="input" type="text" placeholder="••••" value={provModal.api_key} onChange={(e) => setProvModal((p) => p && ({ ...p, api_key: e.target.value }))} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setProvModal(null)}>取消</button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void saveProvider()}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
</div>
)}
{modelModal && (
<div className="modal-bg show">
<div className="modal" role="dialog" aria-modal="true" aria-label="模型">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Server size={16} /></div>
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModelModal(null)}><X size={14} /></button>
</div>
<div className="modal-b">
<div className="field">
<label className="field-label">供应商 <span className="req">*</span></label>
<CustomSelect
fill
value={modelModal.provider}
disabled={Boolean(modelModal.id)}
placeholder="选择供应商…"
onChange={(next) => setModelModal((m) => m && ({ ...m, provider: next }))}
options={providers.map((p) => ({ value: p.id, label: p.display_name }))}
/>
</div>
<div className="field-row">
<div className="field">
<label className="field-label">模型名 <span className="req">*</span></label>
<input className="input" type="text" value={modelModal.name} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">能力</label>
<CustomSelect
fill
value={modelModal.capability}
disabled={Boolean(modelModal.id)}
onChange={(next) => setModelModal((m) => m && ({ ...m, capability: next }))}
options={CAPABILITIES.map((c) => ({ value: c, label: c }))}
/>
</div>
</div>
<div className="field">
<label className="field-label">显示名</label>
<input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">endpoint</label>
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
</div>
{modelModal.capability === "image" ? (
<div className="field">
<label className="field-label">积分规则 · 每张</label>
<input className="input" type="number" min={0} step={1} placeholder="例如 10" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
<div className="field-hint">老板填写:用该模型出 1 张图扣多少积分</div>
</div>
) : null}
{modelModal.capability === "text" || modelModal.capability === "vision" ? (
<div className="field">
<label className="field-label">积分规则 · 每次</label>
<input className="input" type="number" min={0} step={1} placeholder="例如 4" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
<div className="field-hint">老板填写:调用 1 次扣多少积分</div>
</div>
) : null}
{modelModal.capability === "video" ? (
<>
<div className="field">
<label className="field-label">可用时长(秒,逗号分隔)</label>
<input className="input" type="text" value={modelModal.durationsText} onChange={(e) => setModelModal((m) => m && ({ ...m, durationsText: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">分辨率积分(每秒)</label>
<div className="field-hint">每个分辨率一档;含视频参考可另填更高积分,不填则同普通价</div>
<div style={{ display: "grid", gap: 8, marginTop: 8 }}>
{modelModal.videoTiers.map((tier, index) => (
<div key={index} className="field-row" style={{ alignItems: "end" }}>
<div className="field">
<label className="field-label">分辨率</label>
<CustomSelect
fill
value={tier.resolution}
onChange={(next) => setModelModal((m) => {
if (!m) return m;
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, resolution: next } : row);
return { ...m, videoTiers };
})}
options={VIDEO_RES_OPTIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
<div className="field">
<label className="field-label">积分/秒</label>
<input className="input" type="number" min={0} step={1} value={tier.points_per_second} onChange={(e) => setModelModal((m) => {
if (!m) return m;
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second: e.target.value } : row);
return { ...m, videoTiers };
})} />
</div>
<div className="field">
<label className="field-label">含视频参考·积分/秒</label>
<input className="input" type="number" min={0} step={1} placeholder="可选" value={tier.points_per_second_with_ref} onChange={(e) => setModelModal((m) => {
if (!m) return m;
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second_with_ref: e.target.value } : row);
return { ...m, videoTiers };
})} />
</div>
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal((m) => m && ({ ...m, videoTiers: m.videoTiers.filter((_, i) => i !== index) }))}>删除</button>
</div>
))}
</div>
<button
className="btn btn-sm"
type="button"
style={{ marginTop: 8 }}
onClick={() => setModelModal((m) => m && ({
...m,
videoTiers: [...m.videoTiers, { resolution: "720p", points_per_second: "", points_per_second_with_ref: "" }],
}))}
>
+ 添加分辨率
</button>
</div>
</>
) : null}
{!["image", "text", "vision", "video"].includes(modelModal.capability) ? (
<div className="field">
<label className="field-label">单价(积分/次)</label>
<input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
</div>
) : null}
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setModelModal(null)}>取消</button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void saveModel()}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
</div>
)}
</>
);
}