feat(*): 模型编辑与画布 - 统一编辑弹窗并稳定网格和后台切页
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user