fix(channel): 修复前台自定义渠道模型配置抽屉空白死锁,并在启动时保护管理员能力配置 (#360)
Co-authored-by: Danborad <danborad@github.com>
This commit is contained in:
@@ -296,6 +296,9 @@ func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, r
|
||||
if tier.VideoSeconds == 0 {
|
||||
continue
|
||||
}
|
||||
if !videoDurationSupported(config.Video) {
|
||||
continue
|
||||
}
|
||||
if config.Video.Duration.Selection == "enum" && !durationSupported[tier.VideoSeconds] {
|
||||
return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds))
|
||||
}
|
||||
@@ -742,30 +745,26 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []
|
||||
}
|
||||
desired[name] = true
|
||||
if item := byKey[name]; item != nil {
|
||||
if !item.Enabled {
|
||||
item.Enabled = true
|
||||
item.PriceVersion++
|
||||
if err := s.repo.SaveChannelModel(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
modelID, idErr := s.repo.NextPrefixedID("MODEL")
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}
|
||||
item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, UnitPriceMicrocredits: 0, PriceVersion: 1}
|
||||
if err := s.repo.SaveChannelModel(&item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for index := range existing {
|
||||
if existing[index].Enabled && !desired[existing[index].ModelKey] {
|
||||
if !desired[existing[index].ModelKey] {
|
||||
changed := existing[index].Enabled
|
||||
existing[index].Enabled = false
|
||||
existing[index].PriceVersion++
|
||||
if err := s.repo.SaveChannelModel(&existing[index]); err != nil {
|
||||
return err
|
||||
if changed {
|
||||
existing[index].PriceVersion++
|
||||
if err := s.repo.SaveChannelModel(&existing[index]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ type ParameterSupport struct {
|
||||
type VideoCapabilityConfig struct {
|
||||
References VideoReferenceConfig `json:"references"`
|
||||
Duration VideoDurationConfig `json:"duration"`
|
||||
DurationSupported *bool `json:"durationSupported,omitempty"`
|
||||
Ratios []string `json:"ratios"`
|
||||
DefaultRatio string `json:"defaultRatio"`
|
||||
Resolutions []string `json:"resolutions"`
|
||||
@@ -109,6 +110,10 @@ func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig {
|
||||
return DefaultModelCapabilityConfigForModel(protocol, "")
|
||||
}
|
||||
|
||||
func videoDurationSupported(value *VideoCapabilityConfig) bool {
|
||||
return value == nil || value.DurationSupported == nil || *value.DurationSupported
|
||||
}
|
||||
|
||||
func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapabilityConfig {
|
||||
image := &ImageCapabilityConfig{
|
||||
References: ImageReferenceConfig{PromptMaxChars: 32000, MaxImages: 16, MaxImageBytes: 30 * 1024 * 1024, MaskSupported: true},
|
||||
|
||||
@@ -18,6 +18,22 @@ const operationOptions = [
|
||||
{ label: "音频生视频", value: "audio_to_video" },
|
||||
];
|
||||
|
||||
function formatImageSizeTagLabel(size: string): string {
|
||||
const raw = size.trim();
|
||||
if (raw === "1:1") return "1:1 (方图)";
|
||||
if (raw === "16:9") return "16:9 (横屏)";
|
||||
if (raw === "9:16") return "9:16 (竖屏)";
|
||||
if (raw === "1024x1024") return "1024x1024 (1:1)";
|
||||
if (raw === "2048x2048") return "2048x2048 (2K 方)";
|
||||
if (raw === "3840x2160") return "3840x2160 (4K 横屏)";
|
||||
if (raw === "2160x3840") return "2160x3840 (4K 竖屏)";
|
||||
return raw;
|
||||
}
|
||||
|
||||
function imageSizeOptions(values: string[]) {
|
||||
return values.map((v) => ({ label: formatImageSizeTagLabel(v), value: v }));
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value?: ModelCapabilityConfig;
|
||||
onChange?: (value: ModelCapabilityConfig) => void;
|
||||
@@ -450,6 +466,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, sec
|
||||
className="admin-capability-tags w-full"
|
||||
disabled={disabled}
|
||||
value={profile.size.values}
|
||||
options={imageSizeOptions(profile.size.values)}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="例如 1:1、1024x1024"
|
||||
onChange={(values) => updateSize({ values, default: values.includes(profile.size.default) || profile.size.allowCustom ? profile.size.default : values[0] || "auto" })}
|
||||
@@ -479,6 +496,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, sec
|
||||
disabled={disabled}
|
||||
value={profile.quality.values}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="例如 auto, low, medium, high, 1k, 2k, 4k"
|
||||
onChange={(values) => updateQuality({ values, default: values.includes(profile.quality.default) ? profile.quality.default : values[0] || "auto" })}
|
||||
/>
|
||||
</Field>
|
||||
@@ -551,6 +569,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, sec
|
||||
className="admin-capability-tags w-full"
|
||||
disabled={disabled}
|
||||
value={profile.size.values}
|
||||
options={imageSizeOptions(profile.size.values)}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="例如 1:1、1024x1024"
|
||||
onChange={(values) => updateSize({ values, default: values.includes(profile.size.default) || profile.size.allowCustom ? profile.size.default : values[0] || "auto" })}
|
||||
@@ -584,6 +603,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, sec
|
||||
disabled={disabled}
|
||||
value={profile.quality.values}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="例如 auto, low, medium, high, 1k, 2k, 4k"
|
||||
onChange={(values) => updateQuality({ values, default: values.includes(profile.quality.default) ? profile.quality.default : values[0] || "auto" })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -64,6 +64,7 @@ export type VideoCapabilityConfig = {
|
||||
values?: number[];
|
||||
default: number;
|
||||
};
|
||||
durationSupported?: boolean;
|
||||
ratios: string[];
|
||||
defaultRatio: string;
|
||||
resolutions: string[];
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ChevronRight, FlaskConical, Settings2 } from "lucide-react";
|
||||
|
||||
import { testChannelModelConnection } from "@/lib/model-connection-test";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker } from "@/components/model-protocol-picker";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
|
||||
import { modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
@@ -16,20 +16,24 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const { message } = App.useApp();
|
||||
const [testingModel, setTestingModel] = useState("");
|
||||
const [activeModel, setActiveModel] = useState<string | null>(null);
|
||||
const [availableProtocols, setAvailableProtocols] = useState<import("@/lib/model-protocols").ModelProtocolDefinition[]>([]);
|
||||
const [availableProtocols, setAvailableProtocols] = useState<ModelProtocolDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPluginProviderCatalog("user.custom-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
|
||||
}, []);
|
||||
|
||||
if (!channel.models.length) return null;
|
||||
|
||||
const updateCost = (model: string, patch: Partial<ModelCost>) => {
|
||||
const defaultProtocol = defaultProtocolForModel(channel, model, availableProtocols);
|
||||
const defaultCap = modelProtocolCapability(defaultProtocol, availableProtocols) || inferCapabilityFromModel(model);
|
||||
const current = channel.modelCosts?.find((item) => item.model === model) || {
|
||||
model,
|
||||
capability: modelProtocolCapability(defaultProtocolForModel(channel, model), availableProtocols) || "text",
|
||||
protocol: defaultProtocolForModel(channel, model),
|
||||
capability: defaultCap,
|
||||
protocol: defaultProtocol,
|
||||
billingMode: "fixed_request" as const,
|
||||
unitPriceMicrocredits: 0,
|
||||
capabilityConfig: defaultModelCapabilityConfig(defaultProtocolForModel(channel, model), model),
|
||||
capabilityConfig: defaultModelCapabilityConfig(defaultProtocol, model),
|
||||
};
|
||||
const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model }];
|
||||
onChange(next.filter((item) => channel.models.includes(item.model)));
|
||||
@@ -48,8 +52,9 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
};
|
||||
|
||||
const activeModelCost = activeModel ? channel.modelCosts?.find((item) => item.model === activeModel) : undefined;
|
||||
const activeProtocol = activeModel ? activeModelCost?.protocol || defaultProtocolForModel(channel, activeModel) : undefined;
|
||||
const activeCapability = activeModel ? activeModelCost?.capability || modelProtocolCapability(activeProtocol, availableProtocols) || "text" : undefined;
|
||||
const inferredProtocol = activeModel ? defaultProtocolForModel(channel, 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);
|
||||
|
||||
@@ -66,9 +71,8 @@ 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);
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol, availableProtocols) || "text";
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
const protocol = cost?.protocol || defaultProtocolForModel(channel, model, availableProtocols);
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol, availableProtocols) || inferCapabilityFromModel(model);
|
||||
const displayName = cost?.displayName?.trim() || model;
|
||||
return (
|
||||
<div key={model} className="flex min-w-0 items-center gap-3 rounded-md bg-surface-active px-3 py-2.5 transition-colors hover:bg-surface-hover">
|
||||
@@ -103,19 +107,60 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
destroyOnHidden
|
||||
extra={
|
||||
activeModel && activeCapability && activeProtocol ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FlaskConical className="size-3.5" />}
|
||||
loading={testingModel === activeModel}
|
||||
disabled={Boolean(testingModel && testingModel !== activeModel)}
|
||||
onClick={() => void testModel(activeModel, activeCapability, activeProtocol)}
|
||||
>
|
||||
测试模型
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
message.success(`${activeModel} 配置已自动保存`);
|
||||
setActiveModel(null);
|
||||
}}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<FlaskConical className="size-3.5" />}
|
||||
loading={testingModel === activeModel}
|
||||
disabled={Boolean(testingModel && testingModel !== activeModel)}
|
||||
onClick={() => void testModel(activeModel, activeCapability, activeProtocol)}
|
||||
onClick={() => {
|
||||
if (activeModel) message.success(`${activeModel} 配置已自动保存`);
|
||||
setActiveModel(null);
|
||||
}}
|
||||
>
|
||||
测试模型
|
||||
完成
|
||||
</Button>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
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 && activeCapability && activeProtocol ? (
|
||||
{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>
|
||||
@@ -126,8 +171,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
<CapabilityCardPicker
|
||||
value={activeCapability}
|
||||
onChange={(nextCapability) => {
|
||||
const nextProtocol = availableProtocols.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || availableProtocols.find((item) => item.capability === nextCapability)?.value;
|
||||
if (!nextProtocol) return;
|
||||
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,
|
||||
@@ -137,15 +181,21 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
{availableProtocols.length ? <section className="space-y-2">
|
||||
<div className="text-xs font-medium">请求协议</div>
|
||||
<ProtocolCardPicker
|
||||
capability={activeCapability}
|
||||
value={activeProtocol}
|
||||
protocols={availableProtocols}
|
||||
onChange={(nextProtocol) => updateCost(activeModel, { protocol: nextProtocol, billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })}
|
||||
/>
|
||||
</section> : null}
|
||||
{availableProtocols.length ? (
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs font-medium">请求协议</div>
|
||||
<ProtocolCardPicker
|
||||
capability={activeCapability}
|
||||
value={activeProtocol}
|
||||
protocols={availableProtocols}
|
||||
onChange={(nextProtocol) => updateCost(activeModel, {
|
||||
protocol: nextProtocol,
|
||||
billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode,
|
||||
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>
|
||||
@@ -178,7 +228,13 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
</div>
|
||||
) : null}
|
||||
{activeCapability === "image" || activeCapability === "video" ? (
|
||||
<ModelCapabilityEditor capability={activeCapability} model={activeModel} value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)} protocol={activeProtocol} onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })} />
|
||||
<ModelCapabilityEditor
|
||||
capability={activeCapability}
|
||||
model={activeModel}
|
||||
value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)}
|
||||
protocol={activeProtocol}
|
||||
onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -187,8 +243,81 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
);
|
||||
}
|
||||
|
||||
function defaultProtocolForModel(channel: ModelChannel, model: string): ModelProtocol {
|
||||
return channel.interfaceType || "";
|
||||
function inferCapabilityFromModel(model: string): ModelCapabilityChoice {
|
||||
const lower = model.toLowerCase();
|
||||
if (
|
||||
lower.includes("seedream") ||
|
||||
lower.includes("image") ||
|
||||
lower.includes("dall-e") ||
|
||||
lower.includes("dalle") ||
|
||||
lower.includes("flux") ||
|
||||
lower.includes("imagen") ||
|
||||
lower.includes("banana") ||
|
||||
lower.includes("midjourney") ||
|
||||
lower.includes("sdxl") ||
|
||||
lower.includes("stable-diffusion")
|
||||
) {
|
||||
return "image";
|
||||
}
|
||||
if (
|
||||
lower.includes("video") ||
|
||||
lower.includes("sora") ||
|
||||
lower.includes("veo") ||
|
||||
lower.includes("kling") ||
|
||||
lower.includes("seedance") ||
|
||||
lower.includes("minimax") ||
|
||||
lower.includes("hailuo") ||
|
||||
lower.includes("pika") ||
|
||||
lower.includes("runway") ||
|
||||
lower.includes("omni") ||
|
||||
lower.includes("cogvideo") ||
|
||||
lower.includes("wan")
|
||||
) {
|
||||
return "video";
|
||||
}
|
||||
if (
|
||||
lower.includes("audio") ||
|
||||
lower.includes("tts") ||
|
||||
lower.includes("voice") ||
|
||||
lower.includes("speech") ||
|
||||
lower.includes("sound") ||
|
||||
lower.includes("music")
|
||||
) {
|
||||
return "audio";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
function defaultProtocolForCapability(capability: ModelCapabilityChoice, availableProtocols: ModelProtocolDefinition[]): ModelProtocol {
|
||||
const standardProtocols: Record<string, string[]> = {
|
||||
text: ["chat-completion", "openai-response"],
|
||||
image: ["openai-image"],
|
||||
video: ["newapi-channel-2", "newapi"],
|
||||
audio: ["openai-audio"],
|
||||
};
|
||||
const preferred = standardProtocols[capability] || [];
|
||||
for (const id of preferred) {
|
||||
if (availableProtocols.some((p) => p.value === id && p.enabled !== false)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
const matched = availableProtocols.find((p) => p.capability === capability && p.enabled !== false);
|
||||
if (matched) return matched.value;
|
||||
const fallbackMap: Record<string, string> = {
|
||||
text: "chat-completion",
|
||||
image: "openai-image",
|
||||
video: "newapi-channel-2",
|
||||
audio: "openai-audio",
|
||||
};
|
||||
return fallbackMap[capability] || "chat-completion";
|
||||
}
|
||||
|
||||
function defaultProtocolForModel(channel: ModelChannel, model: string, availableProtocols: ModelProtocolDefinition[] = []): ModelProtocol {
|
||||
if (channel.interfaceType) {
|
||||
return channel.interfaceType;
|
||||
}
|
||||
const capability = inferCapabilityFromModel(model);
|
||||
return defaultProtocolForCapability(capability, availableProtocols);
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ModelCost["capability"]) {
|
||||
|
||||
Reference in New Issue
Block a user