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

This commit is contained in:
ddCat committed 2026-09-06 07:19:28 +08:00
1 parent 7d99303b04
commit 596b7e0b51
23 files changed
+1749 -1678

No files matched your search

@@ -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;
}
+80 -134
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);
}