fix(channel): 修复前台自定义渠道模型配置抽屉空白死锁,并在启动时保护管理员能力配置 (#360)

Co-authored-by: Danborad <danborad@github.com>
This commit is contained in:
Danborad
2026-08-31 12:28:40 +08:00
committed by GitHub
parent 8d9ab03a66
commit ce6e20f749
5 changed files with 198 additions and 44 deletions

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ export type VideoCapabilityConfig = {
values?: number[];
default: number;
};
durationSupported?: boolean;
ratios: string[];
defaultRatio: string;
resolutions: string[];

View File

@@ -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"]) {