fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率

This commit is contained in:
Codex
2026-08-16 22:27:29 +08:00
parent 85aa4b3ee1
commit 6d6ad67dae
19 changed files with 792 additions and 51 deletions

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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