fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率
This commit is contained in:
@@ -29,9 +29,28 @@ type channelModelsPayload struct {
|
||||
}
|
||||
|
||||
type channelModelItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ModelType string `json:"model_type"`
|
||||
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
|
||||
DefaultParameters channelModelCatalogParameters `json:"default_parameters"`
|
||||
Options channelModelCatalogOptions `json:"options"`
|
||||
SupportsImages *bool `json:"supports_images"`
|
||||
MinImages *int `json:"min_images"`
|
||||
MaxImages *int `json:"max_images"`
|
||||
}
|
||||
|
||||
type channelModelCatalogParameters struct {
|
||||
AspectRatio string `json:"aspect_ratio"`
|
||||
DurationSeconds string `json:"duration_seconds"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
type channelModelCatalogOptions struct {
|
||||
AspectRatio []ChannelModelCatalogOption `json:"aspect_ratio"`
|
||||
DurationSeconds []ChannelModelCatalogOption `json:"duration_seconds"`
|
||||
Resolution []ChannelModelCatalogOption `json:"resolution"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
|
||||
@@ -54,10 +73,34 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp
|
||||
}
|
||||
|
||||
// ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同;
|
||||
// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。
|
||||
// 协议、能力和可选参数均来自上游公开元数据,不展开供应商内部兼容模型。
|
||||
type ChannelModelCatalogItem struct {
|
||||
ID string `json:"id"`
|
||||
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
ModelType string `json:"modelType,omitempty"`
|
||||
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
|
||||
DefaultParameters ChannelModelCatalogDefaultParameters `json:"defaultParameters,omitempty"`
|
||||
Options ChannelModelCatalogOptions `json:"options,omitempty"`
|
||||
SupportsImages *bool `json:"supportsImages,omitempty"`
|
||||
MinImages *int `json:"minImages,omitempty"`
|
||||
MaxImages *int `json:"maxImages,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelModelCatalogDefaultParameters struct {
|
||||
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||
DurationSeconds string `json:"durationSeconds,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelModelCatalogOptions struct {
|
||||
AspectRatio []ChannelModelCatalogOption `json:"aspectRatio,omitempty"`
|
||||
DurationSeconds []ChannelModelCatalogOption `json:"durationSeconds,omitempty"`
|
||||
Resolution []ChannelModelCatalogOption `json:"resolution,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelModelCatalogOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
|
||||
@@ -134,7 +177,25 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)})
|
||||
catalog = append(catalog, ChannelModelCatalogItem{
|
||||
ID: name,
|
||||
DisplayName: strings.TrimSpace(item.DisplayName),
|
||||
ModelType: normalizeCatalogModelType(item.ModelType),
|
||||
SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes),
|
||||
DefaultParameters: ChannelModelCatalogDefaultParameters{
|
||||
AspectRatio: strings.TrimSpace(item.DefaultParameters.AspectRatio),
|
||||
DurationSeconds: strings.TrimSpace(item.DefaultParameters.DurationSeconds),
|
||||
Resolution: strings.TrimSpace(item.DefaultParameters.Resolution),
|
||||
},
|
||||
Options: ChannelModelCatalogOptions{
|
||||
AspectRatio: normalizeCatalogOptions(item.Options.AspectRatio),
|
||||
DurationSeconds: normalizeCatalogOptions(item.Options.DurationSeconds),
|
||||
Resolution: normalizeCatalogOptions(item.Options.Resolution),
|
||||
},
|
||||
SupportsImages: item.SupportsImages,
|
||||
MinImages: item.MinImages,
|
||||
MaxImages: item.MaxImages,
|
||||
})
|
||||
}
|
||||
sort.Slice(catalog, func(left int, right int) bool {
|
||||
return catalog[left].ID < catalog[right].ID
|
||||
@@ -142,6 +203,30 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func normalizeCatalogModelType(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "text", "image", "video", "audio":
|
||||
return normalized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCatalogOptions(options []ChannelModelCatalogOption) []ChannelModelCatalogOption {
|
||||
seen := make(map[string]bool, len(options))
|
||||
normalized := make([]ChannelModelCatalogOption, 0, len(options))
|
||||
for _, option := range options {
|
||||
value := strings.TrimSpace(option.Value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
normalized = append(normalized, ChannelModelCatalogOption{Value: value, Label: strings.TrimSpace(option.Label)})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeCatalogEndpointTypes(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -101,6 +102,66 @@ func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomModelCatalogPreservesPublicCapabilityMetadataWithoutCompatibilityIDs(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[
|
||||
{"id":"image-fast","display_name":"Image Fast","model_type":"image"},
|
||||
{"id":"image-quality","display_name":"Image Quality","model_type":"image"},
|
||||
{"id":"omni","capability_id":"omni-flash","display_name":"Omni Flash","model_type":"video","supports_images":false,"min_images":0,"max_images":0,"default_parameters":{"aspect_ratio":"16:9","duration_seconds":"10"},"options":{"aspect_ratio":[{"value":"16:9"},{"value":"9:16"}],"duration_seconds":[{"value":"8"},{"value":"10"}]},"compatibility_map":[{"parameters":{"aspect_ratio":"9:16","duration_seconds":"10"},"model_id":"omni_portrait_10s"}]},
|
||||
{"id":"veo-lite","display_name":"Veo Lite","model_type":"video"},
|
||||
{"id":"veo-fast","display_name":"Veo Fast","model_type":"video"},
|
||||
{"id":"veo-quality","display_name":"Veo Quality","model_type":"video"}
|
||||
]}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
catalog, err := svc.FetchChannelModelCatalog(context.Background(), &model.User{ID: "user-1"}, ChannelModelsRequest{
|
||||
BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
|
||||
}
|
||||
if len(catalog) != 6 {
|
||||
t.Fatalf("catalog length = %d; want 6", len(catalog))
|
||||
}
|
||||
var omni *ChannelModelCatalogItem
|
||||
for index := range catalog {
|
||||
if catalog[index].ID == "omni" {
|
||||
omni = &catalog[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if omni == nil {
|
||||
t.Fatal("catalog is missing public omni capability")
|
||||
}
|
||||
if omni.DisplayName != "Omni Flash" || omni.ModelType != "video" {
|
||||
t.Fatalf("omni metadata = %#v", omni)
|
||||
}
|
||||
if omni.DefaultParameters.AspectRatio != "16:9" || omni.DefaultParameters.DurationSeconds != "10" {
|
||||
t.Fatalf("omni defaults = %#v", omni.DefaultParameters)
|
||||
}
|
||||
if got := optionValues(omni.Options.DurationSeconds); len(got) != 2 || got[0] != "8" || got[1] != "10" {
|
||||
t.Fatalf("omni duration options = %#v", got)
|
||||
}
|
||||
encoded, err := json.Marshal(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "omni_portrait_10s") || strings.Contains(string(encoded), "compatibility") {
|
||||
t.Fatalf("ordinary catalog exposed compatibility IDs: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func optionValues(options []ChannelModelCatalogOption) []string {
|
||||
values := make([]string, 0, len(options))
|
||||
for _, option := range options {
|
||||
values = append(values, option.Value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -405,7 +405,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp
|
||||
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
|
||||
return BadAuthRequest("画面比例不在当前模型支持范围内")
|
||||
}
|
||||
if input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(input.Config.VQuality)) {
|
||||
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
|
||||
return BadAuthRequest("输出分辨率不在当前模型支持范围内")
|
||||
}
|
||||
operation := metadataString(input.Metadata, "videoEditOperation")
|
||||
|
||||
@@ -97,3 +97,22 @@ func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *testing.T) {
|
||||
profile := DefaultModelCapabilityConfigForModel("newapi", "omni").Video
|
||||
profile.Duration = VideoDurationConfig{Selection: "enum", Values: []int{8, 10}, Default: 10}
|
||||
profile.Ratios = []string{"16:9", "9:16"}
|
||||
profile.DefaultRatio = "16:9"
|
||||
profile.Resolutions = nil
|
||||
profile.DefaultResolution = ""
|
||||
profile.References.MaxImages = 0
|
||||
profile.Operations = []string{"text_to_video"}
|
||||
profile.DefaultOperation = "text_to_video"
|
||||
|
||||
err := validateVideoTask(profile, canvasGenerationInput{
|
||||
Config: providerConfig{Model: "omni", VideoSeconds: "10", Size: "16:9", VQuality: "720"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validateVideoTask() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,7 +1420,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
if size := normalizeVideoSize(input.Config.Size); size != "" {
|
||||
writeField(writer, "size", size)
|
||||
}
|
||||
writeField(writer, "resolution_name", normalizeVideoResolution(input.Config.VQuality))
|
||||
if resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality); resolution != "" {
|
||||
writeField(writer, "resolution_name", resolution)
|
||||
}
|
||||
writeField(writer, "preset", "normal")
|
||||
if shouldSendNewAPIVideoImages(input) {
|
||||
for _, image := range input.ReferenceImages {
|
||||
|
||||
@@ -640,6 +640,64 @@ func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVideoTaskSendsOnlyDeclaredResolutionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resolutions []string
|
||||
quality string
|
||||
withoutProfile bool
|
||||
want string
|
||||
}{
|
||||
{name: "catalog omits resolution capability", quality: "720"},
|
||||
{name: "auto never invents 720p", resolutions: []string{"720p", "1080p"}, quality: "auto"},
|
||||
{name: "legacy auto never invents 720p", quality: "auto", withoutProfile: true},
|
||||
{name: "missing profile explicit 720 never invents resolution", quality: "720", withoutProfile: true},
|
||||
{name: "declared HD resolution", resolutions: []string{"1080p"}, quality: "1080", want: "1080p"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
var got string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /v1/videos":
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Fatalf("ParseMultipartForm() error = %v", err)
|
||||
}
|
||||
got = r.FormValue("resolution_name")
|
||||
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"queued"}`))
|
||||
case "GET /v1/videos/video-resolution":
|
||||
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"completed"}`))
|
||||
case "GET /v1/videos/video-resolution/content":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile := DefaultModelCapabilityConfigForModel("newapi", "public-video").Video
|
||||
profile.Resolutions = test.resolutions
|
||||
profile.DefaultResolution = ""
|
||||
if test.withoutProfile {
|
||||
profile = nil
|
||||
}
|
||||
_, err := runVideoTask(context.Background(), canvasGenerationInput{
|
||||
Prompt: "synthetic prompt",
|
||||
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "public-video", VideoSeconds: "8", Size: "16:9", VQuality: test.quality},
|
||||
VideoCapability: profile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runVideoTask() error = %v", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("resolution_name = %q; want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
paths := make([]string, 0, 3)
|
||||
|
||||
@@ -169,6 +169,43 @@ func normalizeVideoResolution(value string) string {
|
||||
return value + "p"
|
||||
}
|
||||
|
||||
func videoResolutionNameRequest(profile *VideoCapabilityConfig, value string) string {
|
||||
requested := strings.ToLower(strings.TrimSpace(value))
|
||||
if requested == "" || requested == "auto" || requested == "default" || requested == "medium" || requested == "high" {
|
||||
return ""
|
||||
}
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if len(profile.Resolutions) == 0 {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{requested, strings.ToLower(normalizeVideoResolution(requested))}
|
||||
if requested == "4k" {
|
||||
candidates = append(candidates, "2160p")
|
||||
}
|
||||
if requested == "2160" || requested == "2160p" {
|
||||
candidates = append(candidates, "4k")
|
||||
}
|
||||
for _, supported := range profile.Resolutions {
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(strings.TrimSpace(supported), candidate) {
|
||||
return strings.TrimSpace(supported)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAutomaticVideoResolution(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "auto", "default", "medium", "high":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeXAIVideoDuration(value string) int {
|
||||
duration, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || duration <= 0 {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "antd";
|
||||
|
||||
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -21,7 +22,12 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const summary = `${videoResolutionLabel(config.vquality)} · ${videoSizeLabel(config.size)} · ${videoSecondsLabel(config.videoSeconds)}`;
|
||||
const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length);
|
||||
const summary = [
|
||||
...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []),
|
||||
videoSizeLabel(config.size),
|
||||
videoSecondsLabel(config.videoSeconds),
|
||||
].join(" · ");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -50,7 +50,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{configuredResolutions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
@@ -58,7 +58,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</SettingGroup> : null}
|
||||
<SettingGroup title="尺寸" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
|
||||
221
web/src/lib/channel-model-catalog.ts
Normal file
221
web/src/lib/channel-model-catalog.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
export type ChannelModelCatalogOption = { value: string; label?: string };
|
||||
|
||||
export type ChannelModelCatalogItem = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
modelType?: "text" | "image" | "video" | "audio";
|
||||
supportedEndpointTypes?: string[];
|
||||
defaultParameters?: {
|
||||
aspectRatio?: string;
|
||||
durationSeconds?: string;
|
||||
resolution?: string;
|
||||
};
|
||||
options?: {
|
||||
aspectRatio?: ChannelModelCatalogOption[];
|
||||
durationSeconds?: ChannelModelCatalogOption[];
|
||||
resolution?: ChannelModelCatalogOption[];
|
||||
};
|
||||
supportsImages?: boolean;
|
||||
minImages?: number;
|
||||
maxImages?: number;
|
||||
};
|
||||
|
||||
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
export function sanitizeChannelModelCatalogItem(value: unknown): ChannelModelCatalogItem | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = stringValue(record.id);
|
||||
if (!id) return null;
|
||||
const modelType = stringValue(record.modelType).toLowerCase();
|
||||
const defaultParameters = objectValue(record.defaultParameters);
|
||||
const options = objectValue(record.options);
|
||||
const normalizedDefaults = defaultParameters
|
||||
? {
|
||||
...(stringValue(defaultParameters.aspectRatio) ? { aspectRatio: stringValue(defaultParameters.aspectRatio) } : {}),
|
||||
...(stringValue(defaultParameters.durationSeconds) ? { durationSeconds: stringValue(defaultParameters.durationSeconds) } : {}),
|
||||
...(stringValue(defaultParameters.resolution) ? { resolution: stringValue(defaultParameters.resolution) } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const normalizedOptions = options
|
||||
? {
|
||||
...(catalogOptions(options.aspectRatio).length ? { aspectRatio: catalogOptions(options.aspectRatio) } : {}),
|
||||
...(catalogOptions(options.durationSeconds).length ? { durationSeconds: catalogOptions(options.durationSeconds) } : {}),
|
||||
...(catalogOptions(options.resolution).length ? { resolution: catalogOptions(options.resolution) } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return compactCatalogItem({
|
||||
id,
|
||||
displayName: stringValue(record.displayName),
|
||||
modelType: ["text", "image", "video", "audio"].includes(modelType) ? (modelType as ChannelModelCatalogItem["modelType"]) : undefined,
|
||||
supportedEndpointTypes: stringArray(record.supportedEndpointTypes),
|
||||
defaultParameters: normalizedDefaults,
|
||||
options: normalizedOptions,
|
||||
supportsImages: typeof record.supportsImages === "boolean" ? record.supportsImages : undefined,
|
||||
minImages: nonNegativeInteger(record.minImages),
|
||||
maxImages: nonNegativeInteger(record.maxImages),
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
|
||||
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
|
||||
const next: ChannelModelCost[] = [];
|
||||
for (const item of catalog) {
|
||||
const existing = existingByModel.get(item.id);
|
||||
const catalogBackedCapability = hasCatalogCapabilityMetadata(item);
|
||||
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes, item.modelType);
|
||||
if (existing && !catalogBackedCapability && !inferredProtocol) {
|
||||
next.push({
|
||||
...existing,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const protocol = inferredProtocol || existing?.protocol || channel.interfaceType;
|
||||
const capability = modelProtocolCapability(protocol) || item.modelType;
|
||||
if (!protocol || !capability) continue;
|
||||
const protocolChanged = Boolean(existing && inferredProtocol && existing.protocol !== inferredProtocol);
|
||||
const replaceCapabilityConfig = !existing || catalogBackedCapability || protocolChanged;
|
||||
const capabilityConfig = replaceCapabilityConfig && (capability === "image" || capability === "video")
|
||||
? (catalogBackedCapability ? catalogCapabilityConfig(item, protocol, capability) : defaultModelCapabilityConfig(protocol, item.id))
|
||||
: undefined;
|
||||
if (existing) {
|
||||
next.push({
|
||||
...existing,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
capability,
|
||||
protocol,
|
||||
...(replaceCapabilityConfig ? { capabilityConfig } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
model: item.id,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
capability,
|
||||
protocol,
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 0,
|
||||
...(capabilityConfig ? { capabilityConfig } : {}),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) {
|
||||
return Boolean(
|
||||
item.modelType ||
|
||||
item.defaultParameters ||
|
||||
item.options ||
|
||||
item.supportsImages !== undefined ||
|
||||
item.minImages !== undefined ||
|
||||
item.maxImages !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig {
|
||||
const config = defaultModelCapabilityConfig(protocol, item.id);
|
||||
if (capability === "image") {
|
||||
if (item.supportsImages === false) config.image!.references.maxImages = 0;
|
||||
else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages;
|
||||
return config;
|
||||
}
|
||||
|
||||
const video = config.video!;
|
||||
const durations = uniqueNumbers(optionValues(item.options?.durationSeconds));
|
||||
const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds);
|
||||
if (durations.length) {
|
||||
video.duration = { selection: "enum", values: durations, default: durations.includes(defaultDuration) ? defaultDuration : durations[0]! };
|
||||
} else if (defaultDuration > 0) {
|
||||
video.duration = { selection: "enum", values: [defaultDuration], default: defaultDuration };
|
||||
}
|
||||
const ratios = optionValues(item.options?.aspectRatio);
|
||||
const defaultRatio = item.defaultParameters?.aspectRatio?.trim() || "";
|
||||
if (ratios.length) {
|
||||
video.ratios = ratios;
|
||||
video.defaultRatio = ratios.includes(defaultRatio) ? defaultRatio : ratios[0]!;
|
||||
} else if (defaultRatio) {
|
||||
video.ratios = [defaultRatio];
|
||||
video.defaultRatio = defaultRatio;
|
||||
}
|
||||
|
||||
// A catalog-backed video may only emit resolution_name when the provider
|
||||
// explicitly advertises compatible values. An empty list means omit it.
|
||||
const resolutions = optionValues(item.options?.resolution);
|
||||
const defaultResolution = item.defaultParameters?.resolution?.trim() || "";
|
||||
video.resolutions = resolutions;
|
||||
video.defaultResolution = resolutions.includes(defaultResolution) ? defaultResolution : resolutions[0] || "";
|
||||
|
||||
if (item.supportsImages === false) {
|
||||
video.references.maxImages = 0;
|
||||
video.operations = video.operations.filter((operation) => operation !== "image_to_video");
|
||||
video.defaultOperation = "text_to_video";
|
||||
} else if (item.maxImages !== undefined) {
|
||||
video.references.maxImages = item.maxImages;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function compactCatalogItem(item: ChannelModelCatalogItem): ChannelModelCatalogItem {
|
||||
const defaultParameters = item.defaultParameters && Object.values(item.defaultParameters).some(Boolean) ? item.defaultParameters : undefined;
|
||||
const options = item.options && Object.values(item.options).some((values) => values?.length) ? item.options : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
...(item.modelType ? { modelType: item.modelType } : {}),
|
||||
...(item.supportedEndpointTypes?.length ? { supportedEndpointTypes: item.supportedEndpointTypes } : {}),
|
||||
...(defaultParameters ? { defaultParameters } : {}),
|
||||
...(options ? { options } : {}),
|
||||
...(item.supportsImages !== undefined ? { supportsImages: item.supportsImages } : {}),
|
||||
...(item.minImages !== undefined ? { minImages: item.minImages } : {}),
|
||||
...(item.maxImages !== undefined ? { maxImages: item.maxImages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
return Array.isArray(value) ? Array.from(new Set(value.map(stringValue).filter(Boolean))) : [];
|
||||
}
|
||||
|
||||
function catalogOptions(value: unknown): ChannelModelCatalogOption[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const options: ChannelModelCatalogOption[] = [];
|
||||
for (const raw of value) {
|
||||
const record = objectValue(raw);
|
||||
const optionValue = stringValue(record?.value);
|
||||
if (!optionValue || seen.has(optionValue)) continue;
|
||||
seen.add(optionValue);
|
||||
const label = stringValue(record?.label);
|
||||
options.push({ value: optionValue, ...(label ? { label } : {}) });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function optionValues(options: ChannelModelCatalogOption[] | undefined) {
|
||||
return Array.from(new Set((options || []).map((option) => option.value.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function uniqueNumbers(values: string[]) {
|
||||
return Array.from(new Set(values.map(positiveNumber).filter((value) => value > 0)));
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
|
||||
}
|
||||
@@ -228,6 +228,22 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec
|
||||
return { seconds: String(duration), ratio, resolution };
|
||||
}
|
||||
|
||||
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
|
||||
const requested = String(value || "").trim().toLowerCase();
|
||||
if (!requested || requested === "auto" || requested === "default" || requested === "medium" || requested === "high") return undefined;
|
||||
const candidates = [requested];
|
||||
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
|
||||
if (requested === "low") candidates.push("480p");
|
||||
if (requested === "4k") candidates.push("2160p");
|
||||
if (requested === "2160" || requested === "2160p") candidates.push("4k");
|
||||
const supported = new Map(profile.resolutions.map((resolution) => [resolution.trim().toLowerCase(), resolution.trim()]));
|
||||
for (const candidate of candidates) {
|
||||
const match = supported.get(candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) {
|
||||
const min = profile.duration.min || 1;
|
||||
const max = profile.duration.max || min;
|
||||
|
||||
@@ -92,7 +92,7 @@ export function modelProtocolSupportsTokenBilling(capability?: string, protocol?
|
||||
return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video");
|
||||
}
|
||||
|
||||
export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined {
|
||||
export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?: string): ModelProtocol | undefined {
|
||||
const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase()));
|
||||
if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion";
|
||||
if (normalized.has("openai-response") || normalized.has("responses")) return "openai-response";
|
||||
@@ -102,6 +102,10 @@ export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProt
|
||||
if (normalized.has("grok-image")) return "grok-image";
|
||||
if (normalized.has("gemini-veo") || normalized.has("gemini-video") || normalized.has("veo")) return "gemini-veo";
|
||||
if (normalized.has("volcengine-ark-video") || normalized.has("volcengine-ark")) return "volcengine-ark-video";
|
||||
if (modelType?.trim().toLowerCase() === "video") return "newapi";
|
||||
if (modelType?.trim().toLowerCase() === "image") return "openai-image";
|
||||
if (modelType?.trim().toLowerCase() === "audio") return "openai-audio";
|
||||
if (modelType?.trim().toLowerCase() === "text") return "chat-completion";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1153,15 +1153,16 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size;
|
||||
if (nextSize) props.setRatio(nextSize);
|
||||
};
|
||||
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
|
||||
const imageSummary = [
|
||||
...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||||
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
|
||||
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
|
||||
].join(" · ");
|
||||
const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary;
|
||||
const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
|
||||
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
|
||||
@@ -1238,7 +1239,7 @@ function thinkingFor(mode: CreationMode): CreationThinking {
|
||||
}
|
||||
|
||||
function directorNoteFor(mode: CreationMode, settings: CreationSettings): string {
|
||||
if (mode === "video") return `已按 ${settings.seconds}s · ${videoResolutionLabel(settings.videoQuality)} · ${settings.ratio} 渲染这一镜,等待你的下一句指令。`;
|
||||
if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`;
|
||||
if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`;
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import { useState } from "react";
|
||||
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
|
||||
import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog";
|
||||
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
|
||||
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import {
|
||||
createModelChannel,
|
||||
defaultBaseUrlForApiFormat,
|
||||
@@ -21,8 +20,6 @@ import { ChannelModelSettings } from "./channel-video-pricing";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -108,7 +105,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
|
||||
return;
|
||||
}
|
||||
updateChannels(
|
||||
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
|
||||
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedChannelModelCosts(item, result.catalog) } : item)),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
|
||||
@@ -155,7 +152,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
|
||||
updateChannels(
|
||||
latestConfig.channels.map((channel) => {
|
||||
const fetched = resultMap.get(channel.id);
|
||||
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
|
||||
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedChannelModelCosts(channel, fetched.catalog) } : channel;
|
||||
}),
|
||||
latestConfig,
|
||||
);
|
||||
@@ -310,25 +307,6 @@ function normalizeDefaultModel(value: string, options: string[]) {
|
||||
return options.includes(value) ? value : options[0] || "";
|
||||
}
|
||||
|
||||
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
|
||||
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
|
||||
const next: ChannelModelCost[] = [];
|
||||
for (const item of catalog) {
|
||||
const existing = existingByModel.get(item.id);
|
||||
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
|
||||
if (existing) {
|
||||
const inferredCapability = modelProtocolCapability(inferredProtocol);
|
||||
next.push(inferredProtocol && existing.protocol !== inferredProtocol ? { ...existing, protocol: inferredProtocol, capability: inferredCapability || existing.capability, capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined } : existing);
|
||||
continue;
|
||||
}
|
||||
const catalogProtocol = inferredProtocol || channel.interfaceType;
|
||||
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
|
||||
if (!catalogProtocol || !catalogCapability) continue;
|
||||
next.push({ model: item.id, capability: catalogCapability, protocol: catalogProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -64,14 +64,15 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const protocol = cost?.protocol || defaultProtocolForModel(channel, model);
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
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">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/65">
|
||||
<Settings2 className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium" title={model}>
|
||||
{model}
|
||||
<div className="truncate text-xs font-medium" title={displayName === model ? model : `${displayName} (${model})`}>
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Tag className="mr-0 text-[var(--fs-tiny)]" bordered={false}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { sanitizeChannelModelCatalogItem, type ChannelModelCatalogItem } from "@/lib/channel-model-catalog";
|
||||
import { nanoid } from "nanoid";
|
||||
import { dataUrlToFile } from "@/lib/image-utils";
|
||||
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
|
||||
@@ -1139,8 +1140,6 @@ export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKe
|
||||
}
|
||||
}
|
||||
|
||||
export type ChannelModelCatalogItem = { id: string; supportedEndpointTypes?: string[] };
|
||||
|
||||
export type ChannelModelFetchResult = { models: string[]; catalog: ChannelModelCatalogItem[] };
|
||||
|
||||
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false): Promise<ChannelModelFetchResult> {
|
||||
@@ -1167,8 +1166,8 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
|
||||
}
|
||||
const catalog = new Map<string, ChannelModelCatalogItem>();
|
||||
for (const item of response.data.data?.models || []) {
|
||||
const entry = typeof item === "string" ? { id: item.trim() } : { id: String(item.id || "").trim(), supportedEndpointTypes: Array.isArray(item.supportedEndpointTypes) ? item.supportedEndpointTypes : undefined };
|
||||
if (!entry.id) continue;
|
||||
const entry = typeof item === "string" ? sanitizeChannelModelCatalogItem({ id: item }) : sanitizeChannelModelCatalogItem(item);
|
||||
if (!entry) continue;
|
||||
const existing = catalog.get(entry.id);
|
||||
catalog.set(entry.id, existing || entry);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
|
||||
import { getResourceOSSUrl } from "@/services/api/resources";
|
||||
import { channelRequest } from "@/services/api/custom-channel-relay";
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities";
|
||||
import { modelCapabilityConfigFor, videoDurationAllowed, videoResolutionRequest } from "@/lib/model-capabilities";
|
||||
import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
@@ -356,7 +356,8 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
body.append("prompt", prompt);
|
||||
body.append("seconds", normalizeVideoSeconds(config.videoSeconds));
|
||||
if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!);
|
||||
body.append("resolution_name", normalizeVideoResolution(config.vquality));
|
||||
const resolution = videoResolutionRequest(modelCapabilityConfigFor(config, model).video!, config.vquality);
|
||||
if (resolution) body.append("resolution_name", resolution);
|
||||
body.append("preset", "normal");
|
||||
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
files.forEach((file) => body.append("input_reference[]", file));
|
||||
|
||||
252
web/test/channel-model-catalog.test.ts
Normal file
252
web/test/channel-model-catalog.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { App } from "antd";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover";
|
||||
import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
|
||||
import { fetchChannelModels } from "../src/services/api/image";
|
||||
import { createVideoGenerationTask } from "../src/services/api/video";
|
||||
import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
const originalAxiosPost = axios.post;
|
||||
|
||||
afterEach(() => {
|
||||
axios.post = originalAxiosPost;
|
||||
});
|
||||
|
||||
const omniCatalog: ChannelModelCatalogItem = {
|
||||
id: "omni",
|
||||
displayName: "Omni Flash",
|
||||
modelType: "video",
|
||||
supportsImages: false,
|
||||
minImages: 0,
|
||||
maxImages: 0,
|
||||
defaultParameters: { aspectRatio: "16:9", durationSeconds: "10" },
|
||||
options: {
|
||||
aspectRatio: [{ value: "16:9" }, { value: "9:16" }],
|
||||
durationSeconds: [{ value: "8" }, { value: "10" }],
|
||||
},
|
||||
};
|
||||
|
||||
function configForCatalog(catalog: ChannelModelCatalogItem[], input: Partial<AiConfig> = {}) {
|
||||
const channel = createModelChannel({
|
||||
id: "flow",
|
||||
name: "Flow2API",
|
||||
baseUrl: "https://flow.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
models: catalog.map((item) => item.id),
|
||||
});
|
||||
const configured = { ...channel, modelCosts: mergeFetchedChannelModelCosts(channel, catalog) };
|
||||
const normalized = normalizeConfigSnapshot({
|
||||
config: {
|
||||
...defaultConfig,
|
||||
...input,
|
||||
channels: [configured],
|
||||
model: `flow::${catalog[0]!.id}`,
|
||||
videoModel: `flow::${catalog[0]!.id}`,
|
||||
},
|
||||
}).config;
|
||||
return input.vquality ? { ...normalized, vquality: input.vquality } : normalized;
|
||||
}
|
||||
|
||||
function formEntries(body: unknown) {
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
return Object.fromEntries(Array.from((body as FormData).entries()).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
}
|
||||
|
||||
describe("public channel model catalog", () => {
|
||||
test("preserves six public capabilities without expanding compatibility IDs", async () => {
|
||||
axios.post = (async () => ({
|
||||
data: {
|
||||
code: 0,
|
||||
data: {
|
||||
models: [
|
||||
{ id: "image-fast", displayName: "Image Fast", modelType: "image" },
|
||||
{ id: "image-quality", displayName: "Image Quality", modelType: "image" },
|
||||
{ ...omniCatalog, compatibilityMap: [{ modelId: "omni_portrait_10s" }] },
|
||||
{ id: "veo-lite", displayName: "Veo Lite", modelType: "video" },
|
||||
{ id: "veo-fast", displayName: "Veo Fast", modelType: "video" },
|
||||
{ id: "veo-quality", displayName: "Veo Quality", modelType: "video" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as typeof axios.post;
|
||||
const channel = createModelChannel({ baseUrl: "https://flow.example", apiKey: "synthetic-test-key", models: [] });
|
||||
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
|
||||
expect(result.models).toHaveLength(6);
|
||||
expect(result.models).toContain("omni");
|
||||
expect(result.models).not.toContain("omni_portrait_10s");
|
||||
expect(result.catalog.find((item) => item.id === "omni")).toEqual(omniCatalog);
|
||||
expect(result.catalog.find((item) => item.id === "omni")).not.toHaveProperty("compatibilityMap");
|
||||
});
|
||||
|
||||
test("maps Omni metadata to friendly video/NewAPI configuration with 8/10 seconds and both ratios", () => {
|
||||
const config = configForCatalog([omniCatalog]);
|
||||
const model = "flow::omni";
|
||||
const cost = config.channels[0]!.modelCosts![0]!;
|
||||
|
||||
expect(modelDisplayName(config, model)).toBe("Omni Flash");
|
||||
expect(selectableModelsByCapability(config, "video")).toEqual([model]);
|
||||
expect(selectableModelsByCapability(config, "text")).toEqual([]);
|
||||
expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi");
|
||||
expect(cost.capabilityConfig?.video).toMatchObject({
|
||||
duration: { selection: "enum", values: [8, 10], default: 10 },
|
||||
ratios: ["16:9", "9:16"],
|
||||
defaultRatio: "16:9",
|
||||
resolutions: [],
|
||||
defaultResolution: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves a manually configured capability profile when a catalog only returns an ID", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual",
|
||||
name: "Manual",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
models: ["manual-video"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
channel.modelCosts = [{ model: "manual-video", displayName: "Manual Video", capability: "video", protocol: "newapi", billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig }];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video" }]);
|
||||
|
||||
expect(costs[0]?.capabilityConfig).toEqual(capabilityConfig);
|
||||
expect(costs[0]?.displayName).toBe("Manual Video");
|
||||
});
|
||||
|
||||
test("preserves an ID-only manual model cost when the per-model protocol is missing", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual-text-channel",
|
||||
name: "Manual Text Channel",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
interfaceType: "chat-completion",
|
||||
models: ["manual-video"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
const existing = {
|
||||
model: "manual-video",
|
||||
displayName: "Manual Video",
|
||||
capability: "video" as const,
|
||||
billingMode: "per_second" as const,
|
||||
unitPriceMicrocredits: 4321,
|
||||
inputTokenPriceMicrocredits: 11,
|
||||
outputTokenPriceMicrocredits: 22,
|
||||
cachedTokenPriceMicrocredits: 3,
|
||||
capabilityConfig,
|
||||
};
|
||||
channel.modelCosts = [existing];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]);
|
||||
|
||||
expect(costs[0]).toEqual({ ...existing, displayName: "Catalog Video" });
|
||||
});
|
||||
|
||||
test("preserves an ID-only manual video model cost when both protocol sources are missing", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual-no-protocol",
|
||||
name: "Manual No Protocol",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
models: ["manual-video"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
const existing = {
|
||||
model: "manual-video",
|
||||
displayName: "Manual Video",
|
||||
capability: "video" as const,
|
||||
billingMode: "per_second" as const,
|
||||
unitPriceMicrocredits: 8765,
|
||||
inputTokenPriceMicrocredits: 31,
|
||||
outputTokenPriceMicrocredits: 47,
|
||||
cachedTokenPriceMicrocredits: 5,
|
||||
capabilityConfig,
|
||||
};
|
||||
channel.modelCosts = [existing];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]);
|
||||
|
||||
expect(costs).toEqual([{ ...existing, displayName: "Catalog Video" }]);
|
||||
});
|
||||
|
||||
test("does not show a synthetic 720P label in the Canvas summary when the model declares no resolutions", () => {
|
||||
const config = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" });
|
||||
|
||||
const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
|
||||
|
||||
expect(html).toContain("横屏 · 10s");
|
||||
expect(html).not.toContain("720P");
|
||||
});
|
||||
|
||||
test("shows the public display name in channel model settings instead of only the internal ID", () => {
|
||||
const config = configForCatalog([omniCatalog]);
|
||||
|
||||
const html = renderToStaticMarkup(React.createElement(App, null, React.createElement(ChannelModelSettings, { channel: config.channels[0]!, onChange: () => undefined })));
|
||||
|
||||
expect(html).toContain("Omni Flash");
|
||||
});
|
||||
|
||||
test("omits resolution_name for Omni and for auto instead of inventing 720p", async () => {
|
||||
const bodies: Record<string, string>[] = [];
|
||||
axios.post = (async (_url: string, body: unknown) => {
|
||||
bodies.push(formEntries(body));
|
||||
return { data: { id: `synthetic-${bodies.length}` } };
|
||||
}) as typeof axios.post;
|
||||
|
||||
const omniConfig = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" });
|
||||
await createVideoGenerationTask(omniConfig, "synthetic prompt");
|
||||
|
||||
const declaredResolution: ChannelModelCatalogItem = {
|
||||
...omniCatalog,
|
||||
id: "veo-public",
|
||||
displayName: "Veo Public",
|
||||
defaultParameters: { ...omniCatalog.defaultParameters, durationSeconds: "8", resolution: "1080p" },
|
||||
options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "720p" }, { value: "1080p" }] },
|
||||
};
|
||||
const autoConfig = configForCatalog([declaredResolution], { videoSeconds: "8", size: "16:9", vquality: "auto" });
|
||||
await createVideoGenerationTask(autoConfig, "synthetic prompt");
|
||||
|
||||
expect(bodies[0]).not.toHaveProperty("resolution_name");
|
||||
expect(bodies[1]).not.toHaveProperty("resolution_name");
|
||||
});
|
||||
|
||||
test("sends a declared compatible HD resolution for a video capability", async () => {
|
||||
let body: Record<string, string> = {};
|
||||
axios.post = (async (_url: string, requestBody: unknown) => {
|
||||
body = formEntries(requestBody);
|
||||
return { data: { id: "synthetic-hd" } };
|
||||
}) as typeof axios.post;
|
||||
const catalog: ChannelModelCatalogItem = {
|
||||
...omniCatalog,
|
||||
id: "veo-public",
|
||||
displayName: "Veo Public",
|
||||
defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "1080p" },
|
||||
options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "1080p" }] },
|
||||
};
|
||||
const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "1080" });
|
||||
|
||||
await createVideoGenerationTask(config, "synthetic prompt");
|
||||
|
||||
expect(body.resolution_name).toBe("1080p");
|
||||
expect(body.model).toBe("veo-public");
|
||||
expect(body.seconds).toBe("8");
|
||||
expect(body.size).toBe("1280x720");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user