feat(*): 模型编辑与画布 - 统一编辑弹窗并稳定网格和后台切页
This commit is contained in:
@@ -33,7 +33,7 @@ import {
|
||||
ToggleLeft,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
@@ -164,7 +164,9 @@ export function AdminShell() {
|
||||
</Tooltip>
|
||||
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<MobileAdminNavigation />
|
||||
<Outlet />
|
||||
<Suspense fallback={<div className="p-8 text-sm text-foreground/60" role="status">正在加载管理页面…</div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
</ConfigProvider>
|
||||
|
||||
95
web/src/pages/admin/components/channel-model-editor-form.ts
Normal file
95
web/src/pages/admin/components/channel-model-editor-form.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import type { ChannelModel } from "@/services/api/wallet";
|
||||
import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form";
|
||||
|
||||
export type ChannelModelFormValues = {
|
||||
modelKey: string;
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
icon?: string;
|
||||
capability: ModelCapabilityChoice;
|
||||
protocol?: string;
|
||||
priceTiers: PriceTierFormValues[];
|
||||
enabled: boolean;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
};
|
||||
|
||||
export type EditorSection = "identity" | "capabilities" | "pricing";
|
||||
|
||||
export function editorSectionForField(name: (string | number)[]): EditorSection {
|
||||
return name[0] === "priceTiers" ? "pricing" : name[0] === "capabilityConfig" ? "capabilities" : "identity";
|
||||
}
|
||||
|
||||
export function initialChannelModelValues(item: ChannelModel | null, protocols: ModelProtocolDefinition[]): ChannelModelFormValues {
|
||||
const capability = item?.capability || "text";
|
||||
const protocol = item ? item.protocol : protocols.find((p) => p.capability === capability && p.enabled !== false)?.value;
|
||||
const upstreamModel = item?.providerModelKey || item?.modelKey || "";
|
||||
return {
|
||||
modelKey: item?.modelKey || "",
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: item?.displayName || "",
|
||||
icon: item?.icon || "",
|
||||
capability,
|
||||
protocol,
|
||||
priceTiers: item ? (item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)]) : [defaultPriceTier()],
|
||||
enabled: item?.enabled ?? true,
|
||||
capabilityConfig: capability === "audio" ? undefined : normalizeModelCapabilityConfig(item?.capabilityConfig || defaultModelCapabilityConfig(protocol, upstreamModel)),
|
||||
};
|
||||
}
|
||||
|
||||
// Clear incompatible selectors, but never silently convert prices or billing units.
|
||||
export function changeChannelModelCapability(values: ChannelModelFormValues, protocols: ModelProtocolDefinition[]): ChannelModelFormValues {
|
||||
const capability = values.capability;
|
||||
const protocol = protocols.find((p) => p.value === values.protocol && p.capability === capability && p.enabled !== false)?.value || protocols.find((p) => p.capability === capability && p.enabled !== false)?.value;
|
||||
return {
|
||||
...values,
|
||||
protocol,
|
||||
capabilityConfig: capability === "audio" ? undefined : defaultModelCapabilityConfig(protocol, values.providerModelKey?.trim() || values.modelKey.trim()),
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
...tier,
|
||||
operation: "*",
|
||||
quality: "*",
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
imageCount: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateChannelModelProtocol(capability: string, protocol: string | undefined, protocols: ModelProtocolDefinition[]) {
|
||||
if (!protocols.some((p) => p.value === protocol && p.capability === capability && p.enabled !== false)) {
|
||||
throw new Error("请选择当前能力下已启用的请求协议");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateChannelModelPrices(values: Pick<ChannelModelFormValues, "capability" | "protocol" | "priceTiers">) {
|
||||
const { capability, protocol, priceTiers } = values;
|
||||
if (!priceTiers?.length) throw new Error("请至少配置一个价格档");
|
||||
if (priceTiers.filter((tier) => tier.matchMode === "default").length > 1) throw new Error("只能配置一个所有规格统一价格");
|
||||
const operations: Record<string, string[]> = {
|
||||
text: ["text_generation"],
|
||||
image: ["text_to_image", "image_to_image"],
|
||||
video: ["text_to_video", "image_to_video", "video_to_video"],
|
||||
audio: [],
|
||||
};
|
||||
priceTiers.forEach((tier, index) => {
|
||||
const fail = (text: string) => {
|
||||
throw new Error(`价格档 ${index + 1}:${text}`);
|
||||
};
|
||||
if (!["fixed_request", "per_second", "token"].includes(tier.billingMode)) fail("请选择计费方式");
|
||||
if (tier.billingMode === "per_second" && capability !== "video") fail("按秒计费仅支持视频,请重新选择计费方式并核对价格");
|
||||
if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前协议不支持 Token 计费,请重新选择计费方式并核对价格");
|
||||
if (tier.matchMode === "advanced") {
|
||||
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
|
||||
const specific = (value: string | undefined) => Boolean(value && value !== "*");
|
||||
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0))))
|
||||
fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
|
||||
}
|
||||
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
|
||||
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");
|
||||
if (tier.billingMode === "token" && capability === "video" && tier.outputTokenPrice < 0.000001) fail("视频 Token 价格必须至少为 0.000001");
|
||||
});
|
||||
}
|
||||
421
web/src/pages/admin/components/channel-model-editor.tsx
Normal file
421
web/src/pages/admin/components/channel-model-editor.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, App, Button, Form, Input, Modal, Segmented, Switch, Tabs } from "antd";
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
import { ModelIconPicker } from "@/components/model-logo";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
import { ModelProtocolBrowser } from "@/components/model-protocol-browser";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultPriceTier, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
|
||||
import { PriceTierFields } from "./channel-model-price-tier-fields";
|
||||
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
|
||||
|
||||
export function ChannelModelEditor({
|
||||
channel,
|
||||
editing,
|
||||
protocols,
|
||||
protocolLoading,
|
||||
protocolError,
|
||||
onRetryProtocols,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
channel: ModelChannel;
|
||||
editing: ChannelModel | null;
|
||||
protocols: ModelProtocolDefinition[];
|
||||
protocolLoading: boolean;
|
||||
protocolError: string;
|
||||
onRetryProtocols: () => void;
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [initialValues] = useState(() => initialChannelModelValues(editing, protocols));
|
||||
const [activeSection, setActiveSection] = useState<EditorSection>("identity");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [configurationChanged, setConfigurationChanged] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const dirtyRef = useRef(false);
|
||||
const modelCapability = Form.useWatch("capability", form);
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
const modelKey = Form.useWatch("modelKey", form) || "";
|
||||
const providerModelKey = Form.useWatch("providerModelKey", form) || "";
|
||||
const capabilityConfig = Form.useWatch("capabilityConfig", form);
|
||||
const modelEnabled = Form.useWatch("enabled", form) !== false;
|
||||
const priceTiers = Form.useWatch("priceTiers", form) || [];
|
||||
const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default");
|
||||
const busy = saving || testing;
|
||||
|
||||
const requestClose = () => {
|
||||
if (busyRef.current) return;
|
||||
if (!dirtyRef.current) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
modal.confirm({ title: "放弃未保存的修改?", content: "当前修改尚未保存,关闭后将丢弃。", okText: "放弃修改", cancelText: "继续编辑", onOk: onClose });
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (changed: Partial<FormValues>) => {
|
||||
dirtyRef.current = true;
|
||||
if (changed.capability) {
|
||||
form.setFieldsValue(changeChannelModelCapability(form.getFieldsValue(true), protocols));
|
||||
setConfigurationChanged(true);
|
||||
} else if (changed.protocol) {
|
||||
form.setFieldValue("capabilityConfig", modelCapability === "audio" ? undefined : defaultModelCapabilityConfig(changed.protocol, providerModelKey.trim() || modelKey.trim()));
|
||||
setConfigurationChanged(true);
|
||||
}
|
||||
};
|
||||
|
||||
const validateEditor = async (names?: string[]): Promise<FormValues | undefined> => {
|
||||
busyRef.current = true;
|
||||
try {
|
||||
await form.validateFields(names);
|
||||
return form.getFieldsValue(true);
|
||||
} catch (error) {
|
||||
const fields = (error as { errorFields?: { name: (string | number)[] }[] }).errorFields;
|
||||
if (fields?.length) {
|
||||
setActiveSection(editorSectionForField(fields[0].name));
|
||||
requestAnimationFrame(() => form.scrollToField(fields[0].name, { block: "center", focus: true }));
|
||||
message.warning("请检查标记的配置项后重试");
|
||||
} else message.error(error instanceof Error ? error.message : "表单校验失败");
|
||||
return undefined;
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (busyRef.current) return;
|
||||
const values = await validateEditor();
|
||||
if (!values) return;
|
||||
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
|
||||
const capabilityConfig =
|
||||
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
|
||||
busyRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
modelKey: values.modelKey.trim(),
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: values.displayName?.trim() || values.modelKey.trim(),
|
||||
icon: values.icon?.trim() || "",
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
selector: skuSelectorFromForm(values.capability, tier),
|
||||
resolution: priceTierResolutionFromForm(values.capability, tier),
|
||||
videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier),
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
billingMode: tier.billingMode,
|
||||
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
|
||||
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: tier.priceConfigured !== false,
|
||||
enabled: tier.enabled !== false,
|
||||
})),
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig,
|
||||
};
|
||||
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
|
||||
else await createAdminChannelModel(channel.id, payload);
|
||||
try {
|
||||
await onSaved();
|
||||
} catch {
|
||||
message.warning("模型已保存,但列表刷新失败,请手动刷新");
|
||||
}
|
||||
onClose();
|
||||
message.success(editing ? "模型配置已更新" : "模型已添加");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存模型失败");
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testModel = async () => {
|
||||
if (busyRef.current) return;
|
||||
const values = await validateEditor(["modelKey", "providerModelKey", "capability", "protocol", "capabilityConfig"]);
|
||||
if (!values) return;
|
||||
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
|
||||
const capabilityConfig =
|
||||
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
|
||||
busyRef.current = true;
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminChannelModel(channel.id, {
|
||||
modelKey: values.modelKey.trim(),
|
||||
providerModelKey: upstreamModel,
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
capabilityConfig,
|
||||
});
|
||||
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "模型测试失败");
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={
|
||||
<div className="admin-model-editor-title">
|
||||
<span>{editing ? "编辑模型" : "新增模型"}</span>
|
||||
<p>
|
||||
{channel.name} · {editing?.displayName || editing?.modelKey || "配置调用方式与用户积分价格"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
width={1120}
|
||||
centered
|
||||
rootClassName="admin-modal-root admin-model-editor-modal"
|
||||
mask={{ closable: false }}
|
||||
keyboard={!busy}
|
||||
closable={!busy}
|
||||
onCancel={requestClose}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0, minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
|
||||
footer={
|
||||
<div className="admin-model-editor-footer-actions">
|
||||
<div className="admin-model-editor-footer-status">
|
||||
<Switch
|
||||
aria-label="启用模型"
|
||||
checked={modelEnabled}
|
||||
disabled={busy}
|
||||
onChange={(checked) => {
|
||||
dirtyRef.current = true;
|
||||
form.setFieldValue("enabled", checked);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong>{modelEnabled ? "模型启用" : "模型停用"}</strong>
|
||||
<small>保存后生效</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-model-editor-footer-primary">
|
||||
<Button
|
||||
icon={<FlaskConical className="size-4" />}
|
||||
loading={testing}
|
||||
disabled={saving || protocolLoading || Boolean(protocolError)}
|
||||
onClick={() => modal.confirm({ title: "测试模型连接?", content: "测试会向上游发送真实请求,可能产生供应商费用;不会保存当前修改。", okText: "开始测试", cancelText: "取消", onOk: testModel })}
|
||||
>
|
||||
测试模型
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={requestClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} disabled={testing || protocolLoading || Boolean(protocolError)} onClick={() => void save()}>
|
||||
{editing ? "保存修改" : "添加模型"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form className="admin-model-editor-form" form={form} initialValues={initialValues} layout="vertical" disabled={busy} inert={busy} onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="capabilityConfig" noStyle>
|
||||
<CapabilityConfigField />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" noStyle>
|
||||
<EnabledConfigField />
|
||||
</Form.Item>
|
||||
<Tabs
|
||||
activeKey={activeSection}
|
||||
onChange={(key) => setActiveSection(key as EditorSection)}
|
||||
animated={false}
|
||||
items={[
|
||||
{
|
||||
key: "identity",
|
||||
label: "基本信息",
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div className="admin-model-editor-tab-content">
|
||||
<section className="admin-model-editor-section">
|
||||
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
|
||||
<div className="admin-model-editor-section-content admin-model-identity-grid admin-model-identity-grid-with-icon">
|
||||
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, whitespace: true, message: "请输入产品模型标识" }]}>
|
||||
<Input
|
||||
prefix={
|
||||
<span className="grid size-6 place-items-center">
|
||||
<ModelIcon model={modelKey} />
|
||||
</span>
|
||||
}
|
||||
placeholder="例如:seedance-2-5"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。">
|
||||
<Input placeholder="留空则使用产品模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label="后台显示名称" tooltip="仅用于后台识别,不改变调用 ID。">
|
||||
<Input placeholder="不填则使用模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="模型 Logo">
|
||||
<ModelIconPicker />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
<section className="admin-model-editor-section">
|
||||
<SectionHeading title="能力与协议" description="先选任务类型,再选择对应的调用协议;更换后请核对参数与价格。" />
|
||||
<div className="admin-model-editor-section-content admin-model-protocol-grid">
|
||||
<Form.Item name="capability" label="模型能力" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
block
|
||||
options={[
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="admin-model-protocol-field"
|
||||
name="protocol"
|
||||
label="调用协议"
|
||||
rules={[{ validator: async (_, value) => validateChannelModelProtocol(form.getFieldValue("capability"), value, protocols) }]}
|
||||
>
|
||||
<ModelProtocolBrowser disabled={busy} capability={modelCapability} protocols={protocols} loading={protocolLoading} error={protocolError} />
|
||||
</Form.Item>
|
||||
{protocolError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="协议目录读取失败"
|
||||
description={protocolError}
|
||||
action={
|
||||
<Button size="small" onClick={onRetryProtocols}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "capabilities",
|
||||
label: "能力与参数",
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div className="admin-model-editor-tab-content">
|
||||
{configurationChanged && <Alert type="warning" showIcon title="能力或协议已变更" description="引用与参数已恢复为新协议默认值,旧规格条件已在切换能力时清除。价格与计费方式保留,请在保存前核对。" />}{" "}
|
||||
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-references">
|
||||
<SectionHeading title="引用与限制" description="按媒体类型纵向配置数量、大小、时长及通用约束。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
capability={modelCapability}
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="references"
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => {
|
||||
dirtyRef.current = true;
|
||||
form.setFieldValue("capabilityConfig", next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-parameters">
|
||||
<SectionHeading title="协议参数" description="配置可发送参数、支持值与默认值;仅影响当前模型。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
capability={modelCapability}
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="protocol"
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => {
|
||||
dirtyRef.current = true;
|
||||
form.setFieldValue("capabilityConfig", next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{modelCapability === "audio" && <Alert type="info" title="音频模型无需额外配置引用与参数" description="调用协议和积分定价仍需在对应分组中配置。" />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pricing",
|
||||
label: "积分定价",
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div className="admin-model-editor-tab-content">
|
||||
{configurationChanged && <Alert type="warning" showIcon title="请核对定价" description="能力或协议已变更。请重新检查规格条件、计费方式和金额;不会自动转换价格单位。" />}{" "}
|
||||
<section className="admin-model-editor-section">
|
||||
<SectionHeading title="用户积分价格" description="默认只需填写一个统一价格;需要区分生成方式、质量或尺寸时,再添加规格价格。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.List
|
||||
name="priceTiers"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
validateChannelModelPrices({ capability: form.getFieldValue("capability"), protocol: form.getFieldValue("protocol"), priceTiers: value });
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<PriceTierFields
|
||||
key={field.key}
|
||||
index={field.name}
|
||||
ordinal={index + 1}
|
||||
form={form}
|
||||
capability={modelCapability}
|
||||
protocol={modelProtocol}
|
||||
capabilityConfig={capabilityConfig}
|
||||
onDirty={() => {
|
||||
dirtyRef.current = true;
|
||||
}}
|
||||
onRemove={() => remove(field.name)}
|
||||
/>
|
||||
))}
|
||||
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier(hasDefaultPriceTier ? "advanced" : "default"))}>
|
||||
{hasDefaultPriceTier ? "新增规格价格" : "新增统一默认价格"}
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<header className="admin-model-editor-section-heading">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
function CapabilityConfigField(_: { value?: ModelCapabilityConfig; onChange?: (value: ModelCapabilityConfig) => void }) {
|
||||
return null;
|
||||
}
|
||||
function EnabledConfigField(_: { value?: boolean; onChange?: (value: boolean) => void }) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,36 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Checkbox, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd";
|
||||
import { App, Button, Checkbox, Input, Modal, Popconfirm, Select, Space } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { ModelIconPicker } from "@/components/model-logo";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
import { deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultPriceTier, legacyPriceTierToForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm, type PriceTierFormValues } from "./channel-model-price-tier-form";
|
||||
import { ChannelModelEditor } from "./channel-model-editor";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
type EditableCapability = ModelCapabilityChoice;
|
||||
|
||||
type FormValues = {
|
||||
modelKey: string;
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
icon?: string;
|
||||
capability: EditableCapability;
|
||||
protocol?: ModelProtocol;
|
||||
priceTiers: PriceTierFormValues[];
|
||||
enabled: boolean;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
};
|
||||
|
||||
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<ChannelModel[]>([]);
|
||||
@@ -41,8 +23,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [fetchPreviewModels, setFetchPreviewModels] = useState<string[]>([]);
|
||||
const [selectedFetchModels, setSelectedFetchModels] = useState<string[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [capability, setCapability] = useState<ChannelModel["capability"] | "all">("all");
|
||||
@@ -50,16 +30,20 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [availableProtocols, setAvailableProtocols] = useState<import("@/lib/model-protocols").ModelProtocolDefinition[]>([]);
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const modelCapability = Form.useWatch("capability", form);
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
const modelKey = Form.useWatch("modelKey", form) || "";
|
||||
const providerModelKey = Form.useWatch("providerModelKey", form) || "";
|
||||
const capabilityConfig = Form.useWatch("capabilityConfig", form);
|
||||
const modelEnabled = Form.useWatch("enabled", form) !== false;
|
||||
const priceTiers = Form.useWatch("priceTiers", form) || [];
|
||||
const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default");
|
||||
|
||||
const [protocolLoading, setProtocolLoading] = useState(true);
|
||||
const [protocolError, setProtocolError] = useState("");
|
||||
const loadProtocols = async () => {
|
||||
setProtocolLoading(true);
|
||||
setProtocolError("");
|
||||
try {
|
||||
setAvailableProtocols(await fetchPluginProviderCatalog("admin.system-channel"));
|
||||
} catch (error) {
|
||||
setAvailableProtocols([]);
|
||||
setProtocolError(error instanceof Error ? error.message : "无法读取协议目录");
|
||||
} finally {
|
||||
setProtocolLoading(false);
|
||||
}
|
||||
};
|
||||
const reload = async () => {
|
||||
if (!channel) return;
|
||||
setLoading(true);
|
||||
@@ -74,9 +58,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
void fetchPluginProviderCatalog("admin.system-channel")
|
||||
.then(setAvailableProtocols)
|
||||
.catch(() => setAvailableProtocols([]));
|
||||
void loadProtocols();
|
||||
setEditing(null);
|
||||
setEditorOpen(false);
|
||||
resetFetchPreview();
|
||||
@@ -139,105 +121,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const startCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
modelKey: "",
|
||||
providerModelKey: "",
|
||||
displayName: "",
|
||||
icon: "",
|
||||
capability: "text",
|
||||
protocol: availableProtocols.find((item) => item.capability === "text")?.value,
|
||||
priceTiers: [defaultPriceTier()],
|
||||
enabled: true,
|
||||
capabilityConfig: defaultModelCapabilityConfig(availableProtocols.find((item) => item.capability === "text")?.value, ""),
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: ChannelModel) => {
|
||||
setEditing(item);
|
||||
form.setFieldsValue({
|
||||
modelKey: item.modelKey,
|
||||
providerModelKey: item.providerModelKey || item.modelKey,
|
||||
displayName: item.displayName,
|
||||
icon: item.icon,
|
||||
capability: item.capability || undefined,
|
||||
protocol: item.protocol,
|
||||
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
|
||||
enabled: item.enabled,
|
||||
capabilityConfig:
|
||||
item.capability === "text" || item.capability === "image" || item.capability === "video"
|
||||
? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey))
|
||||
: undefined,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
|
||||
const capabilityConfig =
|
||||
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
modelKey: values.modelKey.trim(),
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: values.displayName?.trim() || values.modelKey.trim(),
|
||||
icon: values.icon?.trim() || "",
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
selector: skuSelectorFromForm(values.capability, tier),
|
||||
resolution: priceTierResolutionFromForm(values.capability, tier),
|
||||
videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier),
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
billingMode: tier.billingMode,
|
||||
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
|
||||
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: tier.priceConfigured !== false,
|
||||
enabled: tier.enabled !== false,
|
||||
})),
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig,
|
||||
};
|
||||
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
|
||||
else await createAdminChannelModel(channel.id, payload);
|
||||
await reload();
|
||||
await onChanged();
|
||||
setEditorOpen(false);
|
||||
setEditing(null);
|
||||
message.success(editing ? "模型配置已更新" : "模型已添加");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存模型失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testModel = async () => {
|
||||
const values = await form.validateFields(["modelKey", "providerModelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]);
|
||||
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
|
||||
const capabilityConfig =
|
||||
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminChannelModel(channel.id, {
|
||||
modelKey: values.modelKey.trim(),
|
||||
providerModelKey: upstreamModel,
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
capabilityConfig,
|
||||
});
|
||||
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "模型测试失败");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: ChannelModel) => {
|
||||
try {
|
||||
await deleteAdminChannelModel(channel.id, item.id);
|
||||
@@ -249,30 +139,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (changed: Partial<FormValues>) => {
|
||||
if (changed.protocol && (modelCapability === "image" || modelCapability === "video")) {
|
||||
form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol, form.getFieldValue("modelKey")));
|
||||
}
|
||||
if (!changed.capability) return;
|
||||
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if (modelProtocolCapability(current, availableProtocols) !== changed.capability) {
|
||||
const nextProtocol = availableProtocols.find((item) => item.capability === changed.capability)?.value;
|
||||
form.setFieldValue("protocol", nextProtocol);
|
||||
form.setFieldValue("capabilityConfig", changed.capability === "text" || changed.capability === "image" || changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol, form.getFieldValue("modelKey")) : undefined);
|
||||
}
|
||||
const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({
|
||||
...tier,
|
||||
operation: tier.operation || "*",
|
||||
quality: changed.capability === "image" ? tier.quality || "*" : "*",
|
||||
size: changed.capability === "image" ? tier.size || "*" : "*",
|
||||
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
|
||||
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
|
||||
imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0,
|
||||
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
|
||||
}));
|
||||
form.setFieldValue("priceTiers", nextTiers);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ChannelModel> = [
|
||||
{
|
||||
title: "模型",
|
||||
@@ -493,12 +359,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<div className="space-y-3">
|
||||
<p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,请取消本次不需要拉入的模型;已存在的模型不会重复导入。</p>
|
||||
<div className="max-h-[min(60vh,520px)] overflow-y-auto rounded-md border border-border/70 p-3">
|
||||
<Checkbox.Group
|
||||
className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
value={selectedFetchModels}
|
||||
options={fetchModelOptions}
|
||||
onChange={(values) => setSelectedFetchModels(values as string[])}
|
||||
/>
|
||||
<Checkbox.Group className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2" value={selectedFetchModels} options={fetchModelOptions} onChange={(values) => setSelectedFetchModels(values as string[])} />
|
||||
</div>
|
||||
<div className="text-xs text-foreground/50">
|
||||
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"}
|
||||
@@ -506,355 +367,29 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Drawer
|
||||
title={editing ? `编辑模型 / ${editing.displayName || editing.modelKey}` : "新增模型"}
|
||||
open={editorOpen}
|
||||
size="min(1080px, 100vw)"
|
||||
onClose={() => !saving && setEditorOpen(false)}
|
||||
rootClassName="admin-drawer admin-model-editor-drawer"
|
||||
footer={
|
||||
<div className="admin-model-editor-footer-actions flex items-center justify-between gap-3">
|
||||
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>
|
||||
测试模型
|
||||
</Button>
|
||||
<div className="admin-model-editor-footer-primary flex items-center gap-2">
|
||||
<div className="admin-model-editor-footer-status">
|
||||
<span className={modelEnabled ? "is-enabled" : ""} />
|
||||
<div>
|
||||
<strong>{modelEnabled ? "模型启用" : "模型停用"}</strong>
|
||||
<small>保存后生效</small>
|
||||
</div>
|
||||
<Switch aria-label="启用模型" checked={modelEnabled} disabled={saving || testing} onChange={(checked) => form.setFieldValue("enabled", checked)} />
|
||||
</div>
|
||||
<Button disabled={saving || testing} onClick={() => setEditorOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>
|
||||
{editing ? "保存修改" : "添加模型"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
editing ? (
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={startCreate}>
|
||||
新增模型
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Form className="admin-model-editor-form" form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="capabilityConfig" noStyle>
|
||||
<CapabilityConfigField />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" noStyle>
|
||||
<EnabledConfigField />
|
||||
</Form.Item>
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
|
||||
<div className="admin-model-editor-section-content admin-model-identity-grid admin-model-identity-grid-with-icon">
|
||||
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, message: "请输入产品模型标识" }]}>
|
||||
<Input
|
||||
prefix={
|
||||
<span className="grid size-6 place-items-center">
|
||||
<ModelIcon model={modelKey} />
|
||||
</span>
|
||||
}
|
||||
placeholder="例如:seedance-2-5"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="providerModelKey" label="上游模型 ID">
|
||||
<Input placeholder="留空则使用产品模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label="后台显示名称">
|
||||
<Input placeholder="不填则使用模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="模型 Logo">
|
||||
<ModelIconPicker />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="模型能力" description="决定模型在前台可用于哪类生成任务。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.Item className="mb-0" name="capability" rules={[{ required: true }]}>
|
||||
<CapabilityCardPicker density="compact" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{availableProtocols.length ? (
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="请求协议" description="选择发送到上游的接口格式与响应处理方式。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.Item className="mb-0" name="protocol" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} density="compact" protocols={availableProtocols} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-references">
|
||||
<SectionHeading title="引用与限制" description="按媒体类型纵向配置数量、大小、时长及通用约束。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
capability={modelCapability}
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="references"
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => form.setFieldValue("capabilityConfig", next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-parameters">
|
||||
<SectionHeading title="协议参数" description="配置可发送参数、支持值与默认值;仅影响当前模型。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
capability={modelCapability}
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="protocol"
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => form.setFieldValue("capabilityConfig", next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="用户积分价格" description="默认只需填写一个统一价格;需要区分生成方式、质量或尺寸时,再添加规格价格。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.List
|
||||
name="priceTiers"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (!value?.length) throw new Error("请至少配置一个价格档");
|
||||
if (value.filter((tier: PriceTierFormValues) => tier.matchMode === "default").length > 1) throw new Error("只能配置一个所有规格统一价格");
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<PriceTierFields
|
||||
key={field.key}
|
||||
index={field.name}
|
||||
ordinal={index + 1}
|
||||
form={form}
|
||||
capability={modelCapability}
|
||||
protocol={modelProtocol}
|
||||
capabilityConfig={capabilityConfig}
|
||||
onRemove={() => remove(field.name)}
|
||||
/>
|
||||
))}
|
||||
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier(hasDefaultPriceTier ? "advanced" : "default"))}>
|
||||
{hasDefaultPriceTier ? "新增规格价格" : "新增统一默认价格"}
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
</section>
|
||||
</Form>
|
||||
</Drawer>
|
||||
{editorOpen && (
|
||||
<ChannelModelEditor
|
||||
channel={channel}
|
||||
editing={editing}
|
||||
protocols={availableProtocols}
|
||||
protocolLoading={protocolLoading}
|
||||
protocolError={protocolError}
|
||||
onRetryProtocols={() => void loadProtocols()}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSaved={async () => {
|
||||
await reload();
|
||||
await onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<header className="admin-model-editor-section-heading">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityConfigField(_: { value?: ModelCapabilityConfig; onChange?: (value: ModelCapabilityConfig) => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function EnabledConfigField(_: { value?: boolean; onChange?: (value: boolean) => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ChannelModel["capability"]) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
|
||||
}
|
||||
|
||||
function PriceTierFields({
|
||||
index,
|
||||
ordinal,
|
||||
form,
|
||||
capability,
|
||||
protocol,
|
||||
capabilityConfig,
|
||||
onRemove,
|
||||
}: {
|
||||
index: number;
|
||||
ordinal: number;
|
||||
form: FormInstance<FormValues>;
|
||||
capability: EditableCapability | undefined;
|
||||
protocol: ModelProtocol | undefined;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const billingMode = Form.useWatch(["priceTiers", index, "billingMode"], form) || "fixed_request";
|
||||
const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default";
|
||||
const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false;
|
||||
const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false;
|
||||
const video = capabilityConfig?.video;
|
||||
const resolutionOptions = video?.resolutions || [];
|
||||
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
|
||||
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
|
||||
const isVideo = capability === "video";
|
||||
const isImage = capability === "image";
|
||||
return (
|
||||
<div className="admin-price-tier-card">
|
||||
<div className="admin-price-tier-card-header">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
|
||||
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/45">{matchMode === "default" ? "匹配所有生成请求,保存后即可供用户使用。" : "精确条件优先于默认价格。"}</div>
|
||||
</div>
|
||||
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-price-tier-card-body">
|
||||
<div className="admin-price-tier-block admin-price-tier-match-block">
|
||||
<div className="admin-price-tier-block-title">
|
||||
<span>01</span> 价格适用范围
|
||||
</div>
|
||||
<Form.Item className="mb-3" name={[index, "matchMode"]} label="定价方式" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
block
|
||||
options={[
|
||||
{ label: "所有规格统一价格", value: "default" },
|
||||
{ label: "按规格定价", value: "advanced" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{matchMode === "default" ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/25 px-3 py-2.5 text-xs leading-5 text-foreground/60">用户选择任意生成方式、质量和尺寸时,都使用这档价格。新增规格价格后,精确规则优先,默认价格负责兜底。</div>
|
||||
) : (
|
||||
<div className="admin-price-tier-match-grid">
|
||||
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
|
||||
<Select options={operationOptions(capability)} />
|
||||
</Form.Item>
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
|
||||
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
|
||||
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "任意质量", value: "*" },
|
||||
{ label: "1K", value: "1k" },
|
||||
{ label: "2K", value: "2k" },
|
||||
{ label: "4K", value: "4k" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
|
||||
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item className="admin-price-tier-upstream mb-0" name={[index, "providerModelKey"]} label="命中后使用的上游模型 ID">
|
||||
<Input placeholder="留空则使用模型默认上游 ID" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-price-tier-block admin-price-tier-billing-block">
|
||||
<div className="admin-price-tier-block-title">
|
||||
<span>02</span> 计费与状态
|
||||
</div>
|
||||
<div className="admin-price-tier-billing-grid">
|
||||
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "按次", value: "fixed_request" },
|
||||
{ label: "按秒", value: "per_second", disabled: !isVideo },
|
||||
{ label: "Token", value: "token", disabled: !tokenEnabled },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
isVideo ? (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="admin-price-tier-token-grid">
|
||||
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<div className="admin-price-tier-controls">
|
||||
<Form.Item name={[index, "priceConfigured"]} hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={[index, "enabled"]} hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<div className="admin-price-tier-toggle">
|
||||
<div>
|
||||
<strong>可供用户使用</strong>
|
||||
<span>开启后参与用户请求匹配;关闭后仅保留配置,不参与计费匹配</span>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="可供用户使用"
|
||||
checked={priceConfigured && tierEnabled}
|
||||
onChange={(checked) => {
|
||||
form.setFieldValue(["priceTiers", index, "priceConfigured"], checked);
|
||||
form.setFieldValue(["priceTiers", index, "enabled"], checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function billingSummary(item: ChannelModel) {
|
||||
const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || [];
|
||||
if (!tiers.length) return <AdminStatusBadge label="未配置价格" tone="warning" />;
|
||||
@@ -883,14 +418,6 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
|
||||
return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
|
||||
}
|
||||
|
||||
function operationOptions(capability: EditableCapability | undefined) {
|
||||
const options = [{ label: "任意生成方式", value: "*" }];
|
||||
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
|
||||
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
|
||||
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
|
||||
return options;
|
||||
}
|
||||
|
||||
function operationLabel(operation: string) {
|
||||
return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record<string, string>)[operation] || operation;
|
||||
}
|
||||
@@ -900,5 +427,8 @@ function formatCredits(value: number) {
|
||||
}
|
||||
|
||||
function normalizeFetchModelKey(value: string) {
|
||||
return value.trim().replace(/^models\//, "").toLowerCase();
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^models\//, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Button, Form, Input, InputNumber, Segmented, Select, Switch, type FormInstance } from "antd";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker";
|
||||
import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form";
|
||||
|
||||
export function PriceTierFields({
|
||||
index,
|
||||
ordinal,
|
||||
form,
|
||||
capability,
|
||||
protocol,
|
||||
capabilityConfig,
|
||||
onDirty,
|
||||
onRemove,
|
||||
}: {
|
||||
index: number;
|
||||
ordinal: number;
|
||||
form: FormInstance<FormValues>;
|
||||
capability: EditableCapability | undefined;
|
||||
protocol: ModelProtocol | undefined;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
onDirty: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const billingMode = Form.useWatch(["priceTiers", index, "billingMode"], form) || "fixed_request";
|
||||
const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default";
|
||||
const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false;
|
||||
const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false;
|
||||
const video = capabilityConfig?.video;
|
||||
const resolutionOptions = video?.resolutions || [];
|
||||
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
|
||||
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
|
||||
const isVideo = capability === "video";
|
||||
const isImage = capability === "image";
|
||||
return (
|
||||
<div className="admin-price-tier-card">
|
||||
<div className="admin-price-tier-card-header">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
|
||||
</div>
|
||||
<div className="admin-price-tier-controls">
|
||||
<Form.Item name={[index, "priceConfigured"]} hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={[index, "enabled"]} hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<div className="admin-price-tier-toggle">
|
||||
<span title="关闭后保留配置,但不参与用户请求及计费匹配">可供用户使用</span>
|
||||
<Switch
|
||||
aria-label="可供用户使用"
|
||||
checked={priceConfigured && tierEnabled}
|
||||
onChange={(checked) => {
|
||||
onDirty();
|
||||
form.setFieldValue(["priceTiers", index, "priceConfigured"], checked);
|
||||
form.setFieldValue(["priceTiers", index, "enabled"], checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-price-tier-card-body">
|
||||
<div className="admin-price-tier-block admin-price-tier-match-block">
|
||||
<Form.Item className="mb-0" name={[index, "matchMode"]} label="定价方式" tooltip="默认价格适用于所有请求;按规格配置时,精确规则优先。" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
block
|
||||
options={[
|
||||
{ label: "统一价格", value: "default" },
|
||||
{ label: "按规格定价", value: "advanced" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="admin-price-tier-block admin-price-tier-billing-block">
|
||||
<div className="admin-price-tier-billing-grid">
|
||||
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "按次", value: "fixed_request" },
|
||||
{ label: "按秒", value: "per_second", disabled: !isVideo },
|
||||
{ label: "Token", value: "token", disabled: !tokenEnabled },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
isVideo ? (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="admin-price-tier-token-grid">
|
||||
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{matchMode !== "default" && (
|
||||
<div className="admin-price-tier-match-grid">
|
||||
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
|
||||
<Select options={operationOptions(capability)} />
|
||||
</Form.Item>
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
|
||||
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
|
||||
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "任意质量", value: "*" },
|
||||
{ label: "1K", value: "1k" },
|
||||
{ label: "2K", value: "2k" },
|
||||
{ label: "4K", value: "4k" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
|
||||
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item className="admin-price-tier-upstream mb-0" name={[index, "providerModelKey"]} label="命中后使用的上游模型 ID">
|
||||
<Input placeholder="留空则使用模型默认上游 ID" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function operationOptions(capability: EditableCapability | undefined) {
|
||||
const options = [{ label: "任意生成方式", value: "*" }];
|
||||
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
|
||||
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
|
||||
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
|
||||
return options;
|
||||
}
|
||||
Reference in New Issue
Block a user