feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理

This commit is contained in:
ddCat
2026-08-06 15:34:45 +08:00
parent d023182b2f
commit b518489fdd
36 changed files with 1692 additions and 401 deletions

View File

@@ -125,7 +125,7 @@ export function AdminPageFrame({ title, description, actions, back, children }:
function MobileAdminNavigation() {
return (
<nav className="hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 bg-background px-3 py-2 lg:hidden" aria-label="管理后台分区">
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
{adminNavigation.flatMap((group) => group.items).map((item) => (
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-foreground/[.05] hover:text-foreground")}>
{item.icon}<span>{item.label}</span>

View File

@@ -1,17 +1,19 @@
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { AudioLines, Check, Film, Flame, FlaskConical, Image, MessageSquareText, Network, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { ModelIcon } from "@/components/model-picker";
import { cn } from "@/lib/utils";
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { AdminPageFrame } from "./admin-shell";
type EditableCapability = Exclude<ChannelModel["capability"], "">;
type EditableCapability = ModelCapabilityChoice;
type FormValues = {
modelKey: string;
@@ -24,6 +26,7 @@ type FormValues = {
outputTokenPrice: number;
cachedTokenPrice: number;
enabled: boolean;
capabilityConfig?: ModelCapabilityConfig;
};
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
@@ -86,13 +89,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const startCreate = () => {
setEditing(null);
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true });
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true, capabilityConfig: undefined });
setEditorOpen(true);
};
const startEdit = (item: ChannelModel) => {
setEditing(item);
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled });
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled, capabilityConfig: item.capability === "video" ? item.capabilityConfig || defaultModelCapabilityConfig(item.protocol) : undefined });
setEditorOpen(true);
};
@@ -112,6 +115,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: true,
enabled: values.enabled !== false,
capabilityConfig: values.capability === "video" ? values.capabilityConfig : undefined,
};
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
else await createAdminChannelModel(channel.id, payload);
@@ -128,13 +132,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
};
const testModel = async () => {
const values = await form.validateFields(["modelKey", "capability", "protocol"]);
const values = await form.validateFields(["modelKey", "capability", "protocol", ...(modelCapability === "video" ? ["capabilityConfig"] : [])]);
setTesting(true);
try {
const result = await testAdminChannelModel(channel.id, {
modelKey: values.modelKey.trim(),
capability: values.capability,
protocol: values.protocol,
capabilityConfig: values.capabilityConfig,
});
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
} catch (error) {
@@ -156,6 +161,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
};
const handleFormValuesChange = (changed: Partial<FormValues>) => {
if (changed.protocol && modelCapability === "video") {
form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol).video ? defaultModelCapabilityConfig(changed.protocol) : undefined);
}
if (!changed.capability) return;
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) {
@@ -163,7 +171,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
if (modelProtocolCapability(current) !== changed.capability) {
form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value);
const nextProtocol = MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value;
form.setFieldValue("protocol", nextProtocol);
form.setFieldValue("capabilityConfig", changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined);
}
};
@@ -246,6 +256,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
<ProtocolCardPicker capability={modelCapability} />
</Form.Item>
{modelCapability === "video" ? <Form.Item name="capabilityConfig" rules={[{ required: true, message: "请配置视频能力参数" }]}><ModelCapabilityEditor protocol={form.getFieldValue("protocol")} /></Form.Item> : null}
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
</Form.Item>
@@ -282,118 +293,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
);
}
const capabilityChoices: Array<{
value: EditableCapability;
label: string;
description: string;
icon: ReactNode;
brands: string[];
}> = [
{ value: "text", label: "文本", description: "对话与推理", icon: <MessageSquareText className="size-4" />, brands: ["openai", "deepseek", "glm"] },
{ value: "image", label: "图片", description: "生成与编辑", icon: <Image className="size-4" />, brands: ["openai", "gemini"] },
{ value: "video", label: "视频", description: "生成与续写", icon: <Film className="size-4" />, brands: ["grok", "gemini"] },
{ value: "audio", label: "音频", description: "语音与音效", icon: <AudioLines className="size-4" />, brands: ["openai"] },
];
function CapabilityCardPicker({ value, onChange }: { value?: EditableCapability; onChange?: (value: EditableCapability) => void }) {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="模型能力">
{capabilityChoices.map((item) => {
const selected = value === item.value;
return (
<button
key={item.value}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
)}
onClick={() => onChange?.(item.value)}
>
<span className={cn("grid size-8 place-items-center rounded-md", selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/65")}>{item.icon}</span>
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
<span className="mt-2 block text-sm font-semibold">{item.label}</span>
<span className="block text-xs text-foreground/48">{item.description}</span>
<BrandIconRow models={item.brands} className="mt-auto pt-2" />
</button>
);
})}
</div>
);
}
function ProtocolCardPicker({ capability, value, onChange }: { capability?: EditableCapability; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void }) {
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" role="radiogroup" aria-label="模型请求协议">
{protocols.map((protocol) => {
const selected = value === protocol.value;
return (
<button
key={protocol.value}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
)}
onClick={() => onChange?.(protocol.value)}
>
<div className="flex min-w-0 items-start gap-2.5 pr-6">
<ProtocolBrandMark protocol={protocol} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{protocol.label}</div>
<div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/48">{protocol.create}</div>
</div>
</div>
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-foreground/58">{protocol.media}</div>
<div className="mt-auto flex items-center justify-between gap-2 pt-2 text-[var(--fs-tiny)] text-foreground/42">
<span className="truncate">{protocol.contentType}</span>
{protocol.poll ? <span className="shrink-0">异步轮询</span> : <span className="shrink-0">同步响应</span>}
</div>
</button>
);
})}
</div>
);
}
function ProtocolBrandMark({ protocol }: { protocol: ModelProtocolDefinition }) {
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact />;
if (protocol.value.startsWith("volcengine-jimeng-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Sparkles className="size-4" /></span>;
if (protocol.value.startsWith("volcengine-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Flame className="size-4" /></span>;
if (protocol.value.startsWith("newapi-channel-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Network className="size-4" /></span>;
const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai";
return <BrandIconRow models={[brand]} compact />;
}
function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) {
return (
<span className={cn("flex items-center", compact ? "shrink-0 -space-x-1" : "-space-x-1", className)} aria-hidden="true">
{models.map((model) => (
<span key={model} className={cn("grid shrink-0 place-items-center rounded-md border border-border/70 bg-background", compact ? "size-8" : "size-6")} title={modelBrandLabel(model)}>
<ModelIcon model={model} />
</span>
))}
</span>
);
}
function modelBrandLabel(model: string) {
const labels: Record<string, string> = {
openai: "OpenAI",
deepseek: "DeepSeek",
glm: "智谱 GLM",
gemini: "Google Gemini",
grok: "xAI Grok",
};
return labels[model] || model;
}
function capabilityLabel(value: ChannelModel["capability"]) {
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
}