feat(*): 模型编辑与画布 - 统一编辑弹窗并稳定网格和后台切页

This commit is contained in:
ddCat
2026-09-06 07:19:28 +08:00
parent bf0c4454fa
commit 83ce57e65f
23 changed files with 1749 additions and 1678 deletions

View File

@@ -1,6 +1,6 @@
import { Button } from "antd";
import { Plus, Settings2, UserRoundCog } from "lucide-react";
import { lazy, Suspense, useState } from "react";
import { lazy, useState } from "react";
import { useAdminContext } from "./admin-context";
import { AdminPageFrame } from "./components/admin-shell";
@@ -14,17 +14,11 @@ const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel"
const FeatureAvailabilityPanel = lazy(() => import("./components/feature-availability-panel"));
const StorageResourcesPanel = lazy(() => import("./components/storage-resources-panel"));
function PageFallback({ label }: { label: string }) {
return <div className="py-16 text-center text-sm text-foreground/50">正在读取{label}...</div>;
}
export function AnalyticsPage() {
const { references } = useAdminContext();
return (
<AdminPageFrame title="数据概览" description="用户、任务、质量与成本健康度" scroll>
<Suspense fallback={<PageFallback label="统计数据" />}>
<AnalyticsPanel users={references.users} channels={references.channels} />
</Suspense>
<AnalyticsPanel users={references.users} channels={references.channels} />
</AdminPageFrame>
);
}
@@ -53,9 +47,7 @@ export function AnnouncementsPage() {
</Button>
}
>
<Suspense fallback={<PageFallback label="系统公告" />}>
<AdminAnnouncementsPanel publishOpen={publishOpen} publishBlocked={publishBlocked} publishReturnFocus={publishReturnFocus} onPublishOpenChange={setPublishOpen} onPublishBlockedChange={setPublishBlocked} />
</Suspense>
<AdminAnnouncementsPanel publishOpen={publishOpen} publishBlocked={publishBlocked} publishReturnFocus={publishReturnFocus} onPublishOpenChange={setPublishOpen} onPublishBlockedChange={setPublishBlocked} />
</AdminPageFrame>
);
}
@@ -78,9 +70,7 @@ export function CreditOperationsPage() {
</>
}
>
<Suspense fallback={<PageFallback label="积分运营数据" />}>
<CreditOperationsPanel users={references.users} activeOperation={activeOperation} onOperationChange={setActiveOperation} />
</Suspense>
<CreditOperationsPanel users={references.users} activeOperation={activeOperation} onOperationChange={setActiveOperation} />
</AdminPageFrame>
);
}
@@ -88,9 +78,7 @@ export function CreditOperationsPage() {
export function AccessSettingsPage() {
return (
<AdminPageFrame title="登录与注册" description="先控制账号创建,再配置第三方登录入口" scroll>
<Suspense fallback={<PageFallback label="登录与注册配置" />}>
<AccessSettingsPanel />
</Suspense>
<AccessSettingsPanel />
</AdminPageFrame>
);
}
@@ -98,9 +86,7 @@ export function AccessSettingsPage() {
export function EmailSettingsPage() {
return (
<AdminPageFrame title="邮件服务" description="先决定是否发送注册验证码,再配置 SMTP" scroll>
<Suspense fallback={<PageFallback label="邮件配置" />}>
<EmailSettingsPanel />
</Suspense>
<EmailSettingsPanel />
</AdminPageFrame>
);
}
@@ -108,9 +94,7 @@ export function EmailSettingsPage() {
export function FeatureAvailabilityPage() {
return (
<AdminPageFrame title="功能开放" description="按用户使用路径控制工作台、插件与模型能力" scroll>
<Suspense fallback={<PageFallback label="功能开放配置" />}>
<FeatureAvailabilityPanel />
</Suspense>
<FeatureAvailabilityPanel />
</AdminPageFrame>
);
}
@@ -118,9 +102,7 @@ export function FeatureAvailabilityPage() {
export function StorageResourcesPage() {
return (
<AdminPageFrame title="存储资源" description="只读查看资源记录、容量分布与文件预览" scroll>
<Suspense fallback={<PageFallback label="存储资源" />}>
<StorageResourcesPanel />
</Suspense>
<StorageResourcesPanel />
</AdminPageFrame>
);
}

View File

@@ -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>

View 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");
});
}

View 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;
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -1,12 +1,13 @@
import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Select, Switch, Table, Tag } from "antd";
import { Alert, App, Button, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd";
import type { FormInstance } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Archive, FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react";
import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react";
import { useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
import { ChoiceBrowser } from "@/components/choice-browser";
import { ModelEditorModal } from "@/components/model-editor-modal";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
import { listAdminChannels } from "@/services/api/auth";
@@ -59,7 +60,14 @@ type LogicalModelFormValues = {
routes: RouteRuleRow[];
};
export default function LogicalModelsPage() {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const [editorTab, setEditorTab] = useState("identity");
const dirtyRef = useRef(false);
const closeEditor = () => {
if (saving) return;
if (dirtyRef.current) modal.confirm({ title: "放弃未保存的修改?", okText: "放弃修改", cancelText: "继续编辑", onOk: () => setEditingModel(undefined) });
else setEditingModel(undefined);
};
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -77,6 +85,7 @@ export default function LogicalModelsPage() {
const [simulationResult, setSimulationResult] = useState<RouteSimulationResult>();
const [simulating, setSimulating] = useState(false);
const [modelForm] = Form.useForm<LogicalModelFormValues>();
const modelEnabled = Form.useWatch("enabled", modelForm) !== false;
const modelCapability = Form.useWatch("capability", modelForm) || "image";
const modelRoutes = Form.useWatch("routes", modelForm) || [];
const modelCapabilitySpec = Form.useWatch("capabilitySpec", modelForm);
@@ -118,6 +127,8 @@ export default function LogicalModelsPage() {
const openModel = (item?: AdminLogicalModel) => {
const capability = item?.capability || "image";
dirtyRef.current = false;
setEditorTab("identity");
modelForm.resetFields();
modelForm.setFieldsValue(
item
@@ -145,13 +156,26 @@ export default function LogicalModelsPage() {
};
const saveModel = async () => {
const values = await modelForm.validateFields();
let values: LogicalModelFormValues;
try {
values = await modelForm.validateFields();
} catch (error) {
const fields = (error as { errorFields?: { name: (string | number)[] }[] }).errorFields;
if (fields?.length) {
const name = fields[0].name;
setEditorTab(name[0] === "routes" ? "routes" : ["capabilitySpec", "defaultOptions"].includes(String(name[0])) ? "capabilities" : "identity");
requestAnimationFrame(() => modelForm.scrollToField(name, { block: "center", focus: true }));
}
return;
}
if (values.enabled && !values.routes.length) {
setEditorTab("routes");
message.error("请至少添加一条供应线路");
return;
}
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec);
if (values.enabled && sourceError) {
setEditorTab("capabilities");
message.error(sourceError);
return;
}
@@ -330,96 +354,116 @@ export default function LogicalModelsPage() {
}
/>
<Drawer
title={editingModel ? "编辑前台模型" : "新增前台模型"}
<ModelEditorModal
admin
open={editingModel !== undefined}
size="min(1120px, 100vw)"
destroyOnHidden
mask={{ closable: !saving }}
onClose={() => !saving && setEditingModel(undefined)}
rootClassName="admin-drawer"
busy={saving}
title={editingModel ? "编辑前台模型" : "新增前台模型"}
subtitle={editingModel?.name || "配置前台展示、供应线路与能力范围"}
onClose={closeEditor}
activeKey={editorTab}
onTabChange={setEditorTab}
footer={
<div className="flex justify-end gap-2">
<Button disabled={saving} onClick={() => setEditingModel(undefined)}>
取消
</Button>
<Button type="primary" loading={saving} onClick={() => void saveModel()}>
保存
</Button>
<div className="model-editor-footer">
<label className="flex items-center gap-2">
<Switch
aria-label="启用前台模型"
checked={modelEnabled}
disabled={saving}
onChange={(enabled) => {
dirtyRef.current = true;
modelForm.setFieldValue("enabled", enabled);
}}
/>
模型启用 <span className="text-xs text-foreground/50">保存后生效</span>
</label>
<div className="model-editor-footer-actions">
<Button disabled={saving} onClick={closeEditor}>取消</Button>
<Button type="primary" loading={saving} onClick={() => void saveModel()}>{editingModel ? "保存修改" : "添加模型"}</Button>
</div>
</div>
}
items={[
{
key: "identity",
label: "基本信息",
children: <>
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
<EditorSection icon={<Layers3 className="size-4" />} title="前台展示">
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="name" label="显示名称" rules={[{ required: true, message: "请填写显示名称" }]}>
<Input placeholder="例如:Seedance 视频" />
</Form.Item>
<Form.Item name="code" label="模型代码" rules={[{ required: true, message: "请填写模型代码" }]}>
<Input placeholder="例如:seedance-video" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo"><ModelIconPicker /></Form.Item>
</div>
<Form.Item name="description" label="简短说明">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
</Form.Item>
<Form.Item name="capability" label="类型">
<Segmented block options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
</Form.Item>
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
</EditorSection>
</>,
},
{
key: "routes",
label: "供应线路",
children: <>
<EditorSection icon={<GitBranch className="size-4" />} title="供应线路">
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</EditorSection>
<EditorSection title="系统规格价格"><PricingFields /></EditorSection>
</>,
},
{
key: "capabilities",
label: "能力与参数",
children: <>
<EditorSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
<CapabilityScopeEditor capability={modelCapability} sourceSpecs={modelSourceSpecs} mode="front" />
</Form.Item>
</EditorSection>
<EditorSection title="默认参数">
<Form.Item name="defaultOptions" noStyle>
<DefaultOptionsEditor spec={modelCapabilitySpec} />
</Form.Item>
</EditorSection>
</>,
},
]}
>
<Form
form={modelForm}
layout="vertical"
requiredMark={false}
className="space-y-3"
onValuesChange={(changedValues: Partial<LogicalModelFormValues>) => {
const capability = changedValues.capability;
if (!capability) return;
modelForm.setFieldsValue({
routes: [],
capabilitySpec: emptyCapabilitySpec(capability),
defaultOptions: {},
pricePolicy: "channel",
billingMode: "fixed_request",
});
}}
>
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
<DrawerSection icon={<Layers3 className="size-4" />} title="前台展示">
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="name" label="显示名称" rules={[{ required: true, message: "请填写显示名称" }]}>
<Input placeholder="例如:Seedance 视频" />
</Form.Item>
<Form.Item name="code" label="模型代码" rules={[{ required: true, message: "请填写模型代码" }]}>
<Input placeholder="例如:seedance-video" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
</div>
<Form.Item name="description" label="简短说明">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
</Form.Item>
<Form.Item name="capability" label="类型">
<CapabilityCardPicker density="compact" />
</Form.Item>
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</DrawerSection>
<DrawerSection icon={<GitBranch className="size-4" />} title="供应线路">
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</DrawerSection>
<DrawerSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
<CapabilityScopeEditor capability={modelCapability} sourceSpecs={modelSourceSpecs} mode="front" />
</Form.Item>
</DrawerSection>
<DrawerSection title="默认参数">
<Form.Item name="defaultOptions" noStyle>
<DefaultOptionsEditor spec={modelCapabilitySpec} />
</Form.Item>
</DrawerSection>
<DrawerSection title="系统规格价格">
<PricingFields />
</DrawerSection>
</Form>
</Drawer>
{(tabs) => (
<Form
form={modelForm}
layout="vertical"
disabled={saving}
inert={saving}
onValuesChange={(changedValues: Partial<LogicalModelFormValues>) => {
dirtyRef.current = true;
if (!changedValues.capability) return;
modelForm.setFieldsValue({ routes: [], capabilitySpec: emptyCapabilitySpec(changedValues.capability), defaultOptions: {}, pricePolicy: "channel", billingMode: "fixed_request" });
}}
>
<Form.Item name="enabled" hidden><Switch /></Form.Item>
{tabs}
</Form>
)}
</ModelEditorModal>
<Modal
title={simulatingModel ? `供应线路匹配模拟 - ${simulatingModel.name}` : "供应线路匹配模拟"}
@@ -478,9 +522,9 @@ export default function LogicalModelsPage() {
);
}
function DrawerSection({ icon, title, description, children }: { icon?: ReactNode; title: string; description?: string; children: ReactNode }) {
function EditorSection({ icon, title, description, children }: { icon?: ReactNode; title: string; description?: string; children: ReactNode }) {
return (
<section className="rounded-lg bg-muted/20 p-4">
<section className="model-editor-section">
<div className="mb-4 flex items-center gap-2.5">
{icon ? <span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/50 text-foreground/55">{icon}</span> : null}
<div>
@@ -512,6 +556,14 @@ function RouteFields({
value: item.id,
label: `${channelNames[item.channelId]} / ${item.displayName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
disabled: Boolean(unavailableReason),
subtitle: item.providerModelKey || item.modelKey,
details: [
{ label: "渠道", value: channelNames[item.channelId] },
{ label: "模型", value: item.displayName || item.modelKey },
{ label: "上游 ID", value: item.providerModelKey || item.modelKey },
{ label: "协议", value: item.protocol || "未配置" },
{ label: "状态", value: unavailableReason || "可用" },
],
};
});
const availableChannelModelCount = selectOptions.filter((item) => !item.disabled).length;
@@ -530,7 +582,7 @@ function RouteFields({
</Button>
</div>
{fields.length ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3">
{fields.map((field) => {
const routes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.channelModelId)).filter(Boolean));
@@ -548,11 +600,10 @@ function RouteFields({
</Button>
</div>
<Form.Item name={[field.name, "channelModelId"]} rules={[{ required: true, message: "请选择渠道模型" }]} className="mb-3">
<Select
aria-label={`供应线路 ${fields.indexOf(field) + 1}`}
showSearch
optionFilterProp="label"
placeholder="选择渠道模型"
<ChoiceBrowser
label={`供应线路 ${fields.indexOf(field) + 1}`}
placeholder="搜索渠道或模型"
key={capability}
options={options}
onChange={(channelModelId) => {
const nextRoutes = [...(form.getFieldValue("routes") || [])];

View File

@@ -1,15 +1,13 @@
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Switch, Tooltip } from "antd";
import { ChevronDown, ChevronUp, MonitorUp, Plus, RefreshCw, Trash2, Workflow } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Pencil, MonitorUp, Plus, RefreshCw, Trash2, Workflow } from "lucide-react";
import { useState, type ReactNode } from "react";
import { ModelEditorModal } from "@/components/model-editor-modal";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog";
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
import { fetchChannelModels } from "@/services/api/image";
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
import { pluginWorkflowCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
import {
createModelChannel,
defaultBaseUrlForApiFormat,
@@ -34,18 +32,14 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
const config = useConfigStore((state) => state.config);
const replaceConfig = useConfigStore((state) => state.replaceConfig);
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
const [providerCatalog, setProviderCatalog] = useState<ModelProtocolDefinition[]>([]);
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
const [newChannelId, setNewChannelId] = useState<string | null>(null);
const desktopLocalChannelsEnabled = useUserStore((state) => state.features.desktopLocalChannelsEnabled);
const desktopLocalChannelHostname = typeof window === "undefined" ? "" : window.location.hostname;
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
const runningHubReady = Boolean(config.runningHub.enabled && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim() && config.runningHub.workflowId.trim());
const comfyBridgeReady = Boolean(config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim());
useEffect(() => {
void fetchPluginProviderCatalog("user.custom-channel").then(setProviderCatalog).catch(() => setProviderCatalog([]));
}, []);
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
replaceConfig(withChannels(baseConfig, channels));
};
@@ -71,37 +65,16 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
};
const updateChannelProvider = (channel: ModelChannel, providerId: string) => {
const provider = providerCatalog.find((item) => item.value === providerId);
if (!provider) return;
const workflows = provider.workflows || [];
const models = workflows.length ? workflows.map((workflow) => workflow.id) : channel.models;
const modelCosts = workflows.length
? workflows.map((workflow) => ({
model: workflow.id,
displayName: workflow.label,
capability: workflow.capability,
protocol: provider.value,
billingMode: "fixed_request" as const,
unitPriceMicrocredits: 0,
capabilityConfig: pluginWorkflowCapabilityConfig(provider.value, workflow),
defaultOptions: workflow.defaults,
}))
: channel.modelCosts;
updateChannel(channel.id, {
interfaceType: provider.value,
apiFormat: provider.value.startsWith("gemini") ? "gemini" : "openai",
baseUrl: provider.baseUrl || channel.baseUrl,
models,
modelCosts,
});
message.success(workflows.length ? `已载入 ${workflows.length} 个工作流` : `${provider.label} 已应用到当前渠道`);
};
const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
updateChannels([...config.channels, channel]);
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
setNewChannelId(channel.id);
setEditingChannelId(channel.id);
};
const closeChannelEditor = () => {
setEditingChannelId(null);
setNewChannelId(null);
};
const deleteChannel = (id: string) => {
@@ -117,15 +90,6 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
};
const toggleChannelCollapsed = (id: string) => {
setCollapsedChannelIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const refreshChannelModels = async (channel: ModelChannel) => {
const connectionError = channelConnectionError(channel);
if (connectionError) {
@@ -257,7 +221,7 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
{userChannels.length ? (
<div className="settings-channel-list space-y-2">
{userChannels.map((channel) => {
const collapsed = collapsedChannelIds.has(channel.id);
const editing = editingChannelId === channel.id;
return (
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="settings-channel p-2.5 sm:p-3">
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
@@ -270,38 +234,59 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
</div>
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
<Button className="h-10 sm:h-8" size="small" icon={<RefreshCw className="size-3.5" />} loading={loadingChannelIds.includes(channel.id)} disabled={loadingChannelIds.includes("all")} onClick={() => void refreshChannelModels(channel)}>拉取模型</Button>
<Tooltip title={collapsed ? "展开渠道配置" : "收起渠道配置"}>
<Button className="size-10 p-0 sm:size-8" size="small" type="text" aria-label={`${collapsed ? "展开" : "收起"}渠道配置 ${channel.name || "未命名渠道"}`} aria-expanded={!collapsed} aria-controls={`channel-${channel.id}-details`} icon={collapsed ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />} onClick={() => toggleChannelCollapsed(channel.id)} />
</Tooltip>
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => { setNewChannelId(null); setEditingChannelId(channel.id); }}>编辑</Button>
<Popconfirm title="删除个人模型渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Tooltip title="删除渠道"><Button className="size-10 p-0 sm:size-8" aria-label={`删除渠道 ${channel.name || "未命名渠道"}`} size="small" type="text" danger disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")} icon={<Trash2 className="size-3.5" />} /></Tooltip>
</Popconfirm>
</div>
</div>
<div id={`channel-${channel.id}-details`} hidden={collapsed}>
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
<Form.Item label="插件 Provider" className="mb-0 lg:col-span-4" extra="Provider 会带入 Base URL、鉴权和工作流;无需手工选择请求协议。">
<Select showSearch optionFilterProp="label" value={channel.interfaceType || undefined} placeholder="选择已安装插件 Provider" options={providerCatalog.map((provider) => ({ value: provider.value, label: `${provider.label} · ${provider.capability}` }))} onChange={(value) => updateChannelProvider(channel, value)} />
</Form.Item>
<Form.Item label="目录连接类型" className="mb-0 lg:col-span-2" extra="仅影响模型目录拉取。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI", value: "openai" }, { label: "Gemini", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
<UserLocalChannelFields
channel={channel}
visible={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).visible}
checked={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked}
desktopLocalChannelsEnabled={desktopLocalChannelsEnabled}
hostname={desktopLocalChannelHostname}
updateChannel={updateChannel}
/>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5"><Input.Password id={`channel-${channel.id}-api-key`} autoComplete="new-password" value={channel.apiKey} placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /></Form.Item>
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。"><Input.Password id={`channel-${channel.id}-secret-key`} autoComplete="new-password" value={channel.secretKey || ""} placeholder="填写 Secret Key" onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /></Form.Item>
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7"><Select id={`channel-${channel.id}-models`} mode="tags" showSearch allowClear maxTagCount="responsive" tokenSeparators={[",", "\n"]} placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })} /></Form.Item>
<div className="lg:col-span-12"><ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} /></div>
</div>
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</div>
{editing && (
<ModelEditorModal
open
title={channel.id === newChannelId ? "新增自定义渠道" : "编辑自定义渠道"}
subtitle={channel.name}
onClose={closeChannelEditor}
footer={<div className="model-editor-footer">
<span className="text-xs text-foreground/50">更改实时保存到本地渠道配置</span>
<div className="model-editor-footer-actions">
<Button loading={loadingChannelIds.includes(channel.id)} onClick={() => void refreshChannelModels(channel)}>拉取模型</Button>
<Button type="primary" onClick={closeChannelEditor}>完成</Button>
</div>
</div>}
>
<div className="model-editor-panel">
<section className="model-editor-section">
<div>
<h2>连接信息</h2>
<p className="mt-1 text-xs text-foreground/50">用于拉取模型目录并向当前渠道发起请求。</p>
</div>
<div className="model-editor-connection-fields grid gap-3 sm:grid-cols-2">
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 sm:col-span-1"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
<Form.Item label="目录连接类型" className="mb-0 sm:col-span-1" extra="仅影响模型目录拉取。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI", value: "openai" }, { label: "Gemini", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
<UserLocalChannelFields
channel={channel}
visible={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).visible}
checked={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked}
desktopLocalChannelsEnabled={desktopLocalChannelsEnabled}
hostname={desktopLocalChannelHostname}
updateChannel={updateChannel}
/>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 sm:col-span-1"><Input.Password id={`channel-${channel.id}-api-key`} autoComplete="new-password" value={channel.apiKey} placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /></Form.Item>
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 sm:col-span-1" extra="即梦等 AK/SK 协议需要;其他协议留空。"><Input.Password id={`channel-${channel.id}-secret-key`} autoComplete="new-password" value={channel.secretKey || ""} placeholder="填写 Secret Key" onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /></Form.Item>
<div className="sm:col-span-2"><ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} /></div>
</div>
</section>
<section className="model-editor-section">
<div>
<h2>模型与能力</h2>
<p className="mt-1 text-xs text-foreground/50">维护渠道模型,并在单个模型中配置调用协议、能力和定价。</p>
</div>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0"><Select id={`channel-${channel.id}-models`} mode="tags" showSearch allowClear maxTagCount="responsive" tokenSeparators={[",", "\n"]} placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })} /></Form.Item>
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</section>
</div>
</ModelEditorModal>
)}
</section>
);
})}
@@ -426,7 +411,7 @@ function channelConnectionError(channel: ModelChannel) {
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", channel.allowLocalChannel === true ? "local" : "remote", JSON.stringify(channel.headers || [])].join("\n");
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.allowLocalChannel === true ? "local" : "remote", JSON.stringify(channel.headers || [])].join("\n");
}
function channelProtocolLabel(channel: ModelChannel) {
@@ -440,5 +425,5 @@ function isKnownDefaultBaseUrl(value: string) {
}
function requiresSecretKey(channel: ModelChannel) {
return channel.interfaceType?.startsWith("volcengine-jimeng-") === true || channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
return channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from "react";
import { App, Button, Drawer, InputNumber, Segmented, Tag } from "antd";
import { App, Button, Segmented, Tag } from "antd";
import { ChevronRight, FlaskConical, Settings2 } from "lucide-react";
import { ModelEditorModal } from "@/components/model-editor-modal";
import { ModelProtocolBrowser } from "@/components/model-protocol-browser";
import { testChannelModelConnection } from "@/lib/model-connection-test";
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { type ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
import { modelProtocolCapability, modelProtocolDefinition, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
import { modelOptionName, type ModelChannel } from "@/stores/use-config-store";
@@ -15,17 +17,24 @@ type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function ChannelModelSettings({ channel, onChange }: { channel: ModelChannel; onChange: (costs: ModelCost[]) => void }) {
const { message } = App.useApp();
const [testingModel, setTestingModel] = useState("");
const [editorTab, setEditorTab] = useState("protocol");
const [protocolLoading, setProtocolLoading] = useState(true);
const [protocolError, setProtocolError] = useState("");
const [activeModel, setActiveModel] = useState<string | null>(null);
const [availableProtocols, setAvailableProtocols] = useState<ModelProtocolDefinition[]>([]);
useEffect(() => {
void fetchPluginProviderCatalog("user.custom-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
let active = true;
void fetchPluginProviderCatalog("user.custom-channel").then((items) => { if (active) setAvailableProtocols(items); })
.catch((error) => { if (active) setProtocolError(error instanceof Error ? error.message : "协议目录读取失败"); })
.finally(() => { if (active) setProtocolLoading(false); });
return () => { active = false; };
}, []);
if (!channel.models.length) return null;
const updateCost = (model: string, patch: Partial<ModelCost>) => {
const defaultProtocol = defaultProtocolForModel(channel, model, availableProtocols);
const defaultProtocol = defaultProtocolForModel(model, availableProtocols);
const defaultCap = modelProtocolCapability(defaultProtocol, availableProtocols) || inferCapabilityFromModel(model);
const current = channel.modelCosts?.find((item) => item.model === model) || {
model,
@@ -52,11 +61,9 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
};
const activeModelCost = activeModel ? channel.modelCosts?.find((item) => item.model === activeModel) : undefined;
const inferredProtocol = activeModel ? defaultProtocolForModel(channel, activeModel, availableProtocols) : "";
const inferredProtocol = activeModel ? defaultProtocolForModel(activeModel, availableProtocols) : "";
const activeProtocol = activeModelCost?.protocol || inferredProtocol;
const activeCapability = activeModelCost?.capability || modelProtocolCapability(activeProtocol, availableProtocols) || (activeModel ? inferCapabilityFromModel(activeModel) : "text");
const activeBillingMode = activeModelCost?.billingMode || "fixed_request";
const activeTokenBillingSupported = modelProtocolSupportsTokenBilling(activeCapability, activeProtocol);
return (
<div className="mt-4">
@@ -71,7 +78,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
{channel.models.map((rawModel) => {
const model = modelOptionName(rawModel);
const cost = channel.modelCosts?.find((item) => item.model === model);
const protocol = cost?.protocol || defaultProtocolForModel(channel, model, availableProtocols);
const protocol = cost?.protocol || defaultProtocolForModel(model, availableProtocols);
const capability = cost?.capability || modelProtocolCapability(protocol, availableProtocols) || inferCapabilityFromModel(model);
const displayName = cost?.displayName?.trim() || model;
return (
@@ -92,153 +99,95 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
</span>
</div>
</div>
<Button type="text" size="small" icon={<ChevronRight className="size-4" />} iconPosition="end" onClick={() => setActiveModel(model)}>
<Button type="text" size="small" icon={<ChevronRight className="size-4" />} iconPosition="end" onClick={() => { setEditorTab("protocol"); setActiveModel(model); }}>
配置使用
</Button>
</div>
);
})}
</div>
<Drawer
title={activeModel ? `${activeModel} · 使用配置` : "模型使用配置"}
<ModelEditorModal
open={Boolean(activeModel)}
busy={Boolean(testingModel)}
title="编辑模型使用配置"
subtitle={activeModel || ""}
activeKey={editorTab}
onTabChange={setEditorTab}
onClose={() => setActiveModel(null)}
size="min(720px, 100vw)"
destroyOnHidden
extra={
activeModel && activeCapability && activeProtocol ? (
<div className="flex items-center gap-2">
footer={
<div className="model-editor-footer">
<span className="text-xs text-foreground/50">更改实时保存到本地渠道配置</span>
<div className="model-editor-footer-actions">
<Button
size="small"
icon={<FlaskConical className="size-3.5" />}
loading={testingModel === activeModel}
disabled={Boolean(testingModel && testingModel !== activeModel)}
onClick={() => void testModel(activeModel, activeCapability, activeProtocol)}
icon={<FlaskConical className="size-4" />}
loading={Boolean(testingModel)}
disabled={!activeProtocol || protocolLoading || Boolean(protocolError)}
onClick={() => { if (activeModel && activeProtocol) void testModel(activeModel, activeCapability, activeProtocol); }}
>
测试模型
</Button>
<Button
type="primary"
size="small"
onClick={() => {
message.success(`${activeModel} 配置已自动保存`);
setActiveModel(null);
}}
>
完成
</Button>
<Button type="primary" disabled={Boolean(testingModel)} onClick={() => setActiveModel(null)}>完成</Button>
</div>
) : (
<Button
type="primary"
size="small"
onClick={() => {
if (activeModel) message.success(`${activeModel} 配置已自动保存`);
setActiveModel(null);
}}
>
完成
</Button>
)
}
footer={
<div className="flex items-center justify-between py-1">
<span className="flex items-center gap-1.5 text-xs text-foreground/50">
<span className="size-1.5 rounded-full bg-emerald-500" />
更改已实时自动保存到渠道配置
</span>
<Button
type="primary"
size="small"
onClick={() => {
if (activeModel) message.success(`${activeModel} 配置已生效`);
setActiveModel(null);
}}
>
完成配置并关闭
</Button>
</div>
}
>
{activeModel ? (
<div className="space-y-4">
<div className="rounded-md bg-surface-active px-3 py-2.5">
<div className="text-xs font-medium">模型能力与请求协议</div>
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/45">这些设置只影响当前渠道的这个模型,保存后会同步到生成校验。</div>
</div>
<section className="space-y-2">
<div className="text-xs font-medium">模型能力</div>
<CapabilityCardPicker
value={activeCapability}
onChange={(nextCapability) => {
const nextProtocol = availableProtocols.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || availableProtocols.find((item) => item.capability === nextCapability && item.enabled !== false)?.value || defaultProtocolForCapability(nextCapability, availableProtocols);
updateCost(activeModel, {
protocol: nextProtocol,
capability: nextCapability,
billingMode: activeBillingMode === "per_second" && nextCapability === "video" ? "per_second" : activeBillingMode === "token" && modelProtocolSupportsTokenBilling(nextCapability, nextProtocol) ? "token" : "fixed_request",
capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
});
}}
/>
</section>
{availableProtocols.length ? (
items={activeModel ? [
{
key: "protocol",
label: "基本信息",
children: <div className="space-y-4" inert={Boolean(testingModel)}>
<section className="space-y-2">
<div className="text-xs font-medium">模型能力</div>
<Segmented<ModelCapabilityChoice>
block
options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]}
value={activeCapability}
onChange={(nextCapability) => {
const nextProtocol = availableProtocols.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || availableProtocols.find((item) => item.capability === nextCapability && item.enabled !== false)?.value || defaultProtocolForCapability(nextCapability, availableProtocols);
updateCost(activeModel, {
protocol: nextProtocol,
capability: nextCapability,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
});
}}
/>
</section>
<section className="space-y-2">
<div className="text-xs font-medium">请求协议</div>
<ProtocolCardPicker
<ModelProtocolBrowser
loading={protocolLoading}
error={protocolError}
capability={activeCapability}
value={activeProtocol}
protocols={availableProtocols}
onChange={(nextProtocol) => updateCost(activeModel, {
protocol: nextProtocol,
billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
})}
/>
</section>
) : null}
{activeCapability === "video" ? (
<div className="space-y-2">
<div className="text-xs font-medium">计费方式</div>
<div className="grid gap-2 lg:grid-cols-[176px_1fr]">
<Segmented
size="small"
block
value={activeBillingMode}
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second" },
{ label: "Token", value: "token", disabled: !activeTokenBillingSupported },
]}
onChange={(value) => updateCost(activeModel, { billingMode: value as ModelCost["billingMode"] })}
/>
<InputNumber
size="small"
min={0}
max={1_000_000}
precision={6}
step={0.1}
className="w-full"
placeholder={activeBillingMode === "token" ? "每百万视频 Token 价格" : activeBillingMode === "per_second" ? "每秒价格" : "每次价格"}
addonAfter={`积分/${activeBillingMode === "token" ? "百万 Token" : activeBillingMode === "per_second" ? "秒" : "次"}`}
value={activeModelCost ? (activeBillingMode === "token" ? (activeModelCost.outputTokenPriceMicrocredits || 0) : activeModelCost.unitPriceMicrocredits) / 1_000_000 : null}
onChange={(value) => updateCost(activeModel, activeBillingMode === "token" ? { outputTokenPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) } : { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })}
/>
</div>
{activeBillingMode === "token" ? <div className="text-[var(--fs-tiny)] text-foreground/45">按火山方舟任务查询响应的 usage.completion_tokens 结算。</div> : null}
</div>
) : null}
{activeCapability === "image" || activeCapability === "video" ? (
<ModelCapabilityEditor
capability={activeCapability}
model={activeModel}
value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)}
protocol={activeProtocol}
onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })}
/>
) : null}
</div>
) : null}
</Drawer>
</div>,
},
{
key: "capabilities",
label: "能力与参数",
children: <div inert={Boolean(testingModel)}>
{activeCapability === "image" || activeCapability === "video" ? (
<ModelCapabilityEditor
capability={activeCapability}
model={activeModel}
value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)}
protocol={activeProtocol}
onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })}
/>
) : <p className="text-xs text-foreground/50">当前模型类型无需额外配置引用与参数。</p>}
</div>,
},
] : []}
/>
</div>
);
}
@@ -312,10 +261,7 @@ function defaultProtocolForCapability(capability: ModelCapabilityChoice, availab
return fallbackMap[capability] || "chat-completion";
}
function defaultProtocolForModel(channel: ModelChannel, model: string, availableProtocols: ModelProtocolDefinition[] = []): ModelProtocol {
if (channel.interfaceType) {
return channel.interfaceType;
}
function defaultProtocolForModel(model: string, availableProtocols: ModelProtocolDefinition[] = []): ModelProtocol {
const capability = inferCapabilityFromModel(model);
return defaultProtocolForCapability(capability, availableProtocols);
}