Files
ttstd-infinite-canvas/web/src/pages/admin/components/channel-model-editor.tsx

425 lines
27 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 { useRef, useState } from "react";
import { Alert, App, Button, Form, Input, Segmented, Switch, Tabs } from "antd";
import { AdminModal } from "@/pages/admin/ui/overlays";
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, normalizeUpstreamModelKey, priceTierPayloadFromForm } 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 tiersWithOwnUpstream = priceTiers.filter((tier) => tier.providerModelKey?.trim());
const modelUpstream = normalizeUpstreamModelKey(providerModelKey || modelKey);
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(),
channelLabel: values.channelLabel?.trim() || "",
description: values.description?.trim() || "",
icon: values.icon?.trim() || "",
capability: values.capability,
protocol: values.protocol,
priceTiers: values.priceTiers.map((tier) => priceTierPayloadFromForm(values.capability, tier, upstreamModel)),
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 (
<AdminModal
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}
flush styles={{ body: { 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="产品模型标识" tooltip="不同渠道使用相同标识时,创作端归为同一个产品模型。不同版本(例如 Fast)应使用不同标识;请勿为分组随意修改已有标识。" 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="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
</div>
{tiersWithOwnUpstream.length ? (
<Alert
type="info"
showIcon
title={`${tiersWithOwnUpstream.length} 个价格档配置了独立上游模型 ID`}
description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。"
/>
) : null}
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项悬浮或聚焦时显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写此渠道模型的使用说明" />
</Form.Item>
</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 === "text" || 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}
modelUpstream={modelUpstream}
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>
</AdminModal>
);
}
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;
}