refactor(admin): 模型配置 - 能力与请求协议改为品牌卡片单选
This commit is contained in:
@@ -248,7 +248,8 @@ function modelMenuMeta(model: string, capability?: ModelCapability): { descripti
|
||||
|
||||
export function ModelIcon({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(modelOptionName(model));
|
||||
return icon ? <img src={icon} alt="" className="size-3.5 shrink-0 dark:invert" /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
|
||||
const monochrome = icon === "/icons/openai.svg" || icon === "/icons/grok.svg";
|
||||
return icon ? <img src={icon} alt="" className={cn("size-3.5 shrink-0", monochrome && "dark:invert")} /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
export function resolveModelIcon(model: string) {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type ReactNode } 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 { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { AudioLines, Check, Film, Flame, FlaskConical, Image, MessageSquareText, Network, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { SYSTEM_MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSummary, type ModelProtocol } from "@/lib/model-protocols";
|
||||
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 { 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 FormValues = {
|
||||
modelKey: string;
|
||||
displayName?: string;
|
||||
capability: ChannelModel["capability"];
|
||||
capability: EditableCapability;
|
||||
protocol: ModelProtocol;
|
||||
billingMode: ChannelModel["billingMode"];
|
||||
unitPrice: number;
|
||||
@@ -36,7 +40,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
|
||||
const modelCapability = Form.useWatch("capability", form);
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
const modelKey = Form.useWatch("modelKey", form) || "";
|
||||
|
||||
const reload = async () => {
|
||||
if (!channel) return;
|
||||
@@ -79,7 +83,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const startCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: undefined, protocol: undefined, billingMode: "fixed_request", unitPrice: 0, enabled: true });
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, enabled: true });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -145,13 +149,25 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (changed: Partial<FormValues>) => {
|
||||
if (!changed.capability) return;
|
||||
if (changed.capability !== "video") form.setFieldValue("billingMode", "fixed_request");
|
||||
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 columns: ColumnsType<ChannelModel> = [
|
||||
{
|
||||
title: "模型",
|
||||
render: (_, item) => (
|
||||
<div>
|
||||
<div className="font-medium">{item.displayName || item.modelKey}</div>
|
||||
<div className="text-xs text-foreground/45">{item.modelKey}</div>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35"><ModelIcon model={item.modelKey} /></span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
|
||||
<div className="truncate text-xs text-foreground/45">{item.modelKey}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -208,23 +224,19 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
/>
|
||||
</TableSurface>
|
||||
<Drawer title={editing ? "编辑模型" : "新增模型"} open={editorOpen} size="min(720px, 100vw)" onClose={() => setEditorOpen(false)} styles={{ body: { paddingBottom: 88 } }} extra={editing ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={startCreate}>新增</Button> : null}>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="modelKey" label="模型标识" rules={[{ required: true, message: "请输入模型标识" }]}>
|
||||
<Input placeholder="gpt-image-2" />
|
||||
<Input prefix={<span className="grid size-6 place-items-center"><ModelIcon model={modelKey} /></span>} placeholder="例如:deepseek-chat、gpt-5、glm-4.5" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label="显示名称">
|
||||
<Input placeholder="不填则使用模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="capability" label="能力" rules={[{ required: true }]}>
|
||||
<Select onChange={(value) => { if (value !== "video") form.setFieldValue("billingMode", "fixed_request"); const current = form.getFieldValue("protocol") as ModelProtocol; if (modelProtocolCapability(current) !== value) form.setFieldValue("protocol", SYSTEM_MODEL_PROTOCOL_OPTIONS.flatMap((group) => group.options).find((item) => modelProtocolCapability(item.value) === value)?.value); }} options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
|
||||
<CapabilityCardPicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]} extra="协议决定创建路径、Content-Type、请求字段和轮询方式。">
|
||||
<Select options={SYSTEM_MODEL_PROTOCOL_OPTIONS.map((group) => ({ ...group, options: group.options.filter((item) => modelProtocolCapability(item.value) === modelCapability) }))} />
|
||||
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} />
|
||||
</Form.Item>
|
||||
<div className="mb-5 rounded-md border border-border/70 bg-foreground/[.025] px-3 py-2.5">
|
||||
<div className="text-xs font-semibold">实际请求预览</div>
|
||||
<div className="mt-1 font-mono text-[var(--fs-label)] leading-5 text-foreground/60">{modelProtocolSummary(modelProtocol)}</div>
|
||||
</div>
|
||||
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
|
||||
</Form.Item>
|
||||
@@ -247,6 +259,118 @@ 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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user