diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 983059be8..7d82b48ed 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -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 + } } } } diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index fcb8bf2c1..e96dcdc87 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -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}, diff --git a/web/src/components/model-capability-editor.tsx b/web/src/components/model-capability-editor.tsx index 0108f1dd0..d1f2a37c0 100644 --- a/web/src/components/model-capability-editor.tsx +++ b/web/src/components/model-capability-editor.tsx @@ -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" })} /> @@ -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" })} /> diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 1e715b78d..4c40c3184 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -64,6 +64,7 @@ export type VideoCapabilityConfig = { values?: number[]; default: number; }; + durationSupported?: boolean; ratios: string[]; defaultRatio: string; resolutions: string[]; diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 5db902e19..5732d5914 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -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(null); - const [availableProtocols, setAvailableProtocols] = useState([]); + const [availableProtocols, setAvailableProtocols] = useState([]); + useEffect(() => { void fetchPluginProviderCatalog("user.custom-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([])); }, []); + if (!channel.models.length) return null; const updateCost = (model: string, patch: Partial) => { + 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 (
@@ -103,19 +107,60 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan destroyOnHidden extra={ activeModel && activeCapability && activeProtocol ? ( +
+ + +
+ ) : ( - ) : null + ) + } + footer={ +
+ + + 更改已实时自动保存到渠道配置 + + +
} > - {activeModel && activeCapability && activeProtocol ? ( + {activeModel ? (
模型能力与请求协议
@@ -126,8 +171,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan { - 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 }} /> - {availableProtocols.length ?
-
请求协议
- updateCost(activeModel, { protocol: nextProtocol, billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })} - /> -
: null} + {availableProtocols.length ? ( +
+
请求协议
+ updateCost(activeModel, { + protocol: nextProtocol, + billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode, + capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, + })} + /> +
+ ) : null} {activeCapability === "video" ? (
计费方式
@@ -178,7 +228,13 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
) : null} {activeCapability === "image" || activeCapability === "video" ? ( - updateCost(activeModel, { capabilityConfig })} /> + updateCost(activeModel, { capabilityConfig })} + /> ) : null}
) : 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 = { + 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 = { + 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"]) {