feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截
This commit is contained in:
1 parent
0002cdd2cb
commit
8c5f56a551
89 files changed
+21451
-1170
No files matched your search
+85
-14
@@ -8,6 +8,13 @@ const controlTheme = {
|
||||
solidHoverBg: "#303030",
|
||||
solidActiveBg: "#404040",
|
||||
solidFg: "#ffffff",
|
||||
checkBg: "#171717",
|
||||
checkHoverBg: "#303030",
|
||||
checkActiveBg: "#404040",
|
||||
checkFg: "#ffffff",
|
||||
switchCheckedBg: "#171717",
|
||||
switchCheckedHoverBg: "#303030",
|
||||
switchCheckedHandle: "#ffffff",
|
||||
selectedBg: "rgba(17, 17, 17, 0.09)",
|
||||
selectedHoverBg: "rgba(17, 17, 17, 0.13)",
|
||||
selectedActiveBg: "rgba(17, 17, 17, 0.16)",
|
||||
@@ -33,6 +40,13 @@ const controlTheme = {
|
||||
solidHoverBg: "#ffffff",
|
||||
solidActiveBg: "#e5e5e5",
|
||||
solidFg: "#171717",
|
||||
checkBg: "#f5f5f5",
|
||||
checkHoverBg: "#ffffff",
|
||||
checkActiveBg: "#e5e5e5",
|
||||
checkFg: "#131313",
|
||||
switchCheckedBg: "#f5f5f5",
|
||||
switchCheckedHoverBg: "#ffffff",
|
||||
switchCheckedHandle: "#131313",
|
||||
selectedBg: "rgba(255, 255, 255, 0.12)",
|
||||
selectedHoverBg: "rgba(255, 255, 255, 0.16)",
|
||||
selectedActiveBg: "rgba(255, 255, 255, 0.2)",
|
||||
@@ -103,7 +117,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
primaryColor: color.solidFg,
|
||||
defaultBg: color.controlSurface,
|
||||
defaultBg: "transparent",
|
||||
defaultColor: color.selectedFg,
|
||||
defaultBorderColor: color.controlBorder,
|
||||
defaultHoverBg: color.selectedBg,
|
||||
@@ -131,9 +145,9 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
Switch: {
|
||||
handleBg: color.switchOffHandle,
|
||||
handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)",
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorPrimary: color.switchCheckedBg,
|
||||
colorPrimaryActive: color.switchCheckedBg,
|
||||
colorPrimaryHover: color.switchCheckedHoverBg,
|
||||
colorTextQuaternary: color.switchOffBg,
|
||||
colorTextTertiary: color.switchOffHoverBg,
|
||||
controlOutline: color.controlFocus,
|
||||
@@ -142,9 +156,9 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
colorBgContainer: color.controlSurface,
|
||||
colorBgContainerDisabled: color.controlDisabledBg,
|
||||
colorBorder: color.controlBorder,
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorPrimary: color.checkBg,
|
||||
colorPrimaryActive: color.checkActiveBg,
|
||||
colorPrimaryHover: color.checkHoverBg,
|
||||
controlOutline: color.controlFocus,
|
||||
},
|
||||
Radio: {
|
||||
@@ -154,16 +168,16 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
buttonCheckedBg: color.selectedBg,
|
||||
buttonCheckedBgDisabled: color.controlDisabledBg,
|
||||
buttonCheckedColorDisabled: color.controlDisabledFg,
|
||||
buttonSolidCheckedColor: color.solidFg,
|
||||
buttonSolidCheckedActiveBg: color.solidActiveBg,
|
||||
buttonSolidCheckedBg: color.solidBg,
|
||||
buttonSolidCheckedHoverBg: color.solidHoverBg,
|
||||
buttonSolidCheckedColor: color.checkFg,
|
||||
buttonSolidCheckedActiveBg: color.checkActiveBg,
|
||||
buttonSolidCheckedBg: color.checkBg,
|
||||
buttonSolidCheckedHoverBg: color.checkHoverBg,
|
||||
colorBgContainer: color.controlSurface,
|
||||
colorBgContainerDisabled: color.controlDisabledBg,
|
||||
colorBorder: color.controlBorder,
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorPrimary: color.checkBg,
|
||||
colorPrimaryActive: color.checkActiveBg,
|
||||
colorPrimaryHover: color.checkHoverBg,
|
||||
controlOutline: color.controlFocus,
|
||||
},
|
||||
Menu: {
|
||||
@@ -241,3 +255,60 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台使用更高密度的控件节奏;只在 AdminShell 内挂载,避免改变创作端的视觉契约。
|
||||
*/
|
||||
export function getAdminAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
const base = getAntThemeConfig(dark);
|
||||
const mutedForeground = dark ? "rgba(250, 250, 250, 0.58)" : "rgba(23, 23, 23, 0.58)";
|
||||
|
||||
return {
|
||||
...base,
|
||||
token: {
|
||||
...base.token,
|
||||
borderRadius: 6,
|
||||
borderRadiusLG: 8,
|
||||
colorBgContainer: "var(--color-surface)",
|
||||
colorBorder: "var(--color-border)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
padding: 12,
|
||||
paddingSM: 8,
|
||||
fontSize: 13,
|
||||
},
|
||||
components: {
|
||||
...base.components,
|
||||
Card: {
|
||||
...base.components?.Card,
|
||||
boxShadow: "none",
|
||||
boxShadowTertiary: "none",
|
||||
},
|
||||
Drawer: {
|
||||
...base.components?.Drawer,
|
||||
colorBgElevated: "var(--color-surface)",
|
||||
},
|
||||
Form: {
|
||||
...base.components?.Form,
|
||||
itemMarginBottom: 16,
|
||||
},
|
||||
Menu: {
|
||||
...base.components?.Menu,
|
||||
itemHeight: 34,
|
||||
itemSelectedBg: "transparent",
|
||||
},
|
||||
Table: {
|
||||
...base.components?.Table,
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingBlockMD: 8,
|
||||
cellPaddingBlockSM: 6,
|
||||
cellPaddingInline: 12,
|
||||
cellPaddingInlineMD: 12,
|
||||
cellPaddingInlineSM: 8,
|
||||
headerBg: "transparent",
|
||||
headerColor: mutedForeground,
|
||||
headerSplitColor: "transparent",
|
||||
borderColor: "var(--color-border)",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type GenerationTask } from "@/services/api/task-center";
|
||||
import { backendProviderConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
|
||||
import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
|
||||
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
@@ -8,7 +8,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { modelRequestOptions, resolveCompatibleModel, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
@@ -85,7 +85,7 @@ export function assertCanvasImageReferenceLimit(config: AiConfig, referenceImage
|
||||
if (error) throw new Error(error);
|
||||
}
|
||||
|
||||
export { backendProviderConfig };
|
||||
export { backendProviderConfig, logicalModelIDForConfig };
|
||||
|
||||
const generationOperationLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -375,35 +375,46 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
|
||||
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
|
||||
const imageSize = mode === "image" ? node?.metadata?.size || config.size || defaultConfig.size : undefined;
|
||||
// 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。
|
||||
const requestedConfig: AiConfig = {
|
||||
...config,
|
||||
quality: node?.metadata?.quality ?? config.quality ?? defaultConfig.quality,
|
||||
size: node?.metadata?.size ?? config.size ?? defaultConfig.size,
|
||||
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
|
||||
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds ?? config.videoSeconds ?? defaultConfig.videoSeconds),
|
||||
vquality: normalizeVideoResolution(node?.metadata?.vquality ?? config.vquality ?? defaultConfig.vquality),
|
||||
videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark,
|
||||
audioVoice: node?.metadata?.audioVoice ?? config.audioVoice ?? defaultConfig.audioVoice,
|
||||
audioFormat: node?.metadata?.audioFormat ?? config.audioFormat ?? defaultConfig.audioFormat,
|
||||
audioSpeed: node?.metadata?.audioSpeed ?? config.audioSpeed ?? defaultConfig.audioSpeed,
|
||||
audioInstructions: node?.metadata?.audioInstructions ?? config.audioInstructions ?? defaultConfig.audioInstructions,
|
||||
count: String(node?.metadata?.count ?? (mode === "image" ? config.canvasImageCount || config.count || defaultConfig.count : config.count || defaultConfig.count)),
|
||||
};
|
||||
const imageSize = mode === "image" ? requestedConfig.size : undefined;
|
||||
// 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
|
||||
// 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
|
||||
const baseRequirements = requirements?.capability ? requirements : { capability: mode };
|
||||
const liveOptions = modelRequestOptions(requestedConfig, mode);
|
||||
const baseRequirements = requirements?.capability
|
||||
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
|
||||
: { capability: mode, options: liveOptions };
|
||||
const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel;
|
||||
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
|
||||
const normalizedImage = imageProfile
|
||||
? normalizeImageValue(imageProfile, {
|
||||
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||
transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground,
|
||||
count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count),
|
||||
quality: requestedConfig.quality,
|
||||
size: requestedConfig.size,
|
||||
transparentBackground: requestedConfig.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
...config,
|
||||
...requestedConfig,
|
||||
model,
|
||||
quality: normalizedImage?.quality || node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||
size: normalizedImage?.size || node?.metadata?.size || config.size || defaultConfig.size,
|
||||
transparentBackground: normalizedImage?.transparentBackground || ((node?.metadata?.transparentBackground || config.transparentBackground) === "true" ? "true" : "false"),
|
||||
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds),
|
||||
vquality: normalizeVideoResolution(node?.metadata?.vquality || config.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
|
||||
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
|
||||
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
|
||||
count: normalizedImage?.count || String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
|
||||
quality: normalizedImage?.quality || requestedConfig.quality,
|
||||
size: normalizedImage?.size || requestedConfig.size,
|
||||
transparentBackground: normalizedImage?.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"),
|
||||
count: normalizedImage?.count || requestedConfig.count,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,21 @@ import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
|
||||
export type ModelCapabilityConfig = {
|
||||
version: number;
|
||||
text?: TextCapabilityConfig;
|
||||
image?: ImageCapabilityConfig;
|
||||
video?: VideoCapabilityConfig;
|
||||
};
|
||||
|
||||
export type TextCapabilityConfig = {
|
||||
references: {
|
||||
promptMaxChars: number;
|
||||
maxImages: number;
|
||||
maxImageBytes: number;
|
||||
maxVideos: number;
|
||||
maxVideoBytes: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageSizeParameter = "none" | "size" | "aspect_ratio";
|
||||
|
||||
export type ImageCapabilityConfig = {
|
||||
@@ -131,6 +142,10 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
}
|
||||
|
||||
export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = ""): ModelCapabilityConfig {
|
||||
const text: TextCapabilityConfig = {
|
||||
// 文本模型的视觉能力必须由管理员明确开启,不能根据模型名猜测。
|
||||
references: { promptMaxChars: 32000, maxImages: 0, maxImageBytes: 0, maxVideos: 0, maxVideoBytes: 0 },
|
||||
};
|
||||
const video: VideoCapabilityConfig = {
|
||||
references: {
|
||||
promptMaxChars: 1000,
|
||||
@@ -181,7 +196,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
video.resolutions = ["1080p"];
|
||||
video.defaultResolution = "1080p";
|
||||
}
|
||||
return { version: 1, image: defaultImageCapabilityConfig(protocol, model), video };
|
||||
return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video };
|
||||
}
|
||||
|
||||
export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) {
|
||||
@@ -192,10 +207,13 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
|
||||
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
|
||||
if (!cost?.capabilityConfig) return fallback;
|
||||
const text = cost.capabilityConfig.text
|
||||
? { ...fallback.text!, ...cost.capabilityConfig.text, references: { ...fallback.text!.references, ...cost.capabilityConfig.text.references } }
|
||||
: fallback.text;
|
||||
const video = cost.capabilityConfig.video
|
||||
? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } }
|
||||
: fallback.video;
|
||||
return { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video };
|
||||
return { ...fallback, ...cost.capabilityConfig, text, image: cost.capabilityConfig.image || fallback.image, video };
|
||||
}
|
||||
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ModelRequirements = {
|
||||
videoOperation?: string;
|
||||
videoSeconds?: string;
|
||||
imageSize?: string;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DisplayModelGroup = {
|
||||
@@ -53,6 +54,18 @@ export function modelCompatibilityError(config: AiConfig, model: string, require
|
||||
if (!capability) return "";
|
||||
const input = requirements?.input;
|
||||
const visualInputCount = input ? input.imageCount + input.characterCount : 0;
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||||
const logicalSpecs = logicalCost?.logicalCapabilityProfiles?.length ? logicalCost.logicalCapabilityProfiles : logicalCost?.logicalCapabilitySpec ? [logicalCost.logicalCapabilitySpec] : [];
|
||||
if (logicalSpecs.length) {
|
||||
const publicOptionNames = logicalCost?.logicalCapabilitySpec?.options || {};
|
||||
const logicalRequirements = {
|
||||
...requirements,
|
||||
options: Object.fromEntries(Object.entries(requirements.options || {}).filter(([name]) => Boolean(publicOptionNames[name]))),
|
||||
};
|
||||
const errors = logicalSpecs.map((spec) => logicalModelCompatibilityError(spec, logicalRequirements, visualInputCount));
|
||||
return errors.some((error) => !error) ? "" : errors[0] || "当前输入不受支持";
|
||||
}
|
||||
|
||||
if (capability === "image") {
|
||||
const image = modelCapabilityConfigFor(config, model).image!;
|
||||
@@ -87,6 +100,71 @@ export function modelCompatibilityError(config: AiConfig, model: string, require
|
||||
return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : "";
|
||||
}
|
||||
|
||||
export function modelRequestOptions(config: AiConfig, capability: ModelCapability) {
|
||||
switch (capability) {
|
||||
case "image":
|
||||
return { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) };
|
||||
case "video":
|
||||
return { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" };
|
||||
case "audio":
|
||||
return { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalCapabilitySpec"]>, requirements: ModelRequirements, visualInputCount: number) {
|
||||
if (requirements.capability && spec.capability !== requirements.capability) return "不支持当前生成类型";
|
||||
const input = requirements.input;
|
||||
const counts: Record<string, number> = {
|
||||
text: 0,
|
||||
image: visualInputCount,
|
||||
video: input?.videoCount || 0,
|
||||
audio: input?.audioCount || 0,
|
||||
};
|
||||
for (const [kind, count] of Object.entries(counts)) {
|
||||
const constraint = spec.inputs?.[kind];
|
||||
if (!constraint && count > 0) return `不支持${kind}输入`;
|
||||
if (constraint && (count < constraint.min || count > constraint.max)) return `${kind}输入需为 ${constraint.min}-${constraint.max} 个`;
|
||||
}
|
||||
const operation = requirements.capability === "video" && input ? resolveVideoOperation(input, requirements.videoOperation) : requirements.videoOperation;
|
||||
if (operation && spec.operations?.length && !spec.operations.includes(operation)) return "不支持当前生成模式";
|
||||
const options = { ...requirements.options, ...(requirements.videoSeconds ? { videoSeconds: requirements.videoSeconds } : {}), ...(requirements.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
for (const [name, value] of Object.entries(options)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
const constraint = spec.options?.[name];
|
||||
if (!constraint || !logicalOptionMatches(constraint, value)) return logicalOptionError(name);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function logicalOptionMatches(constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
|
||||
if (constraint.values?.length) return constraint.values.some((candidate) => String(candidate).toLowerCase() === String(value).toLowerCase());
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return false;
|
||||
if (constraint.min !== undefined && numeric < constraint.min) return false;
|
||||
if (constraint.max !== undefined && numeric > constraint.max) return false;
|
||||
if (constraint.step !== undefined && constraint.min !== undefined) return Math.abs((numeric - constraint.min) / constraint.step - Math.round((numeric - constraint.min) / constraint.step)) < 1e-9;
|
||||
return true;
|
||||
}
|
||||
|
||||
function logicalOptionError(name: string) {
|
||||
const label: Record<string, string> = {
|
||||
size: "尺寸",
|
||||
quality: "质量",
|
||||
transparentBackground: "透明背景",
|
||||
count: "输出数量",
|
||||
videoSeconds: "时长",
|
||||
vquality: "分辨率",
|
||||
videoGenerateAudio: "同步音频",
|
||||
videoWatermark: "水印设置",
|
||||
audioVoice: "音色",
|
||||
audioFormat: "音频格式",
|
||||
audioSpeed: "语速",
|
||||
};
|
||||
return `不支持当前${label[name] || name}`;
|
||||
}
|
||||
|
||||
export function compatibleModelInGroup(config: AiConfig, models: string[], requirements?: ModelRequirements, preferred?: string) {
|
||||
const compatible = models.filter((model) => !modelCompatibilityError(config, model, requirements));
|
||||
if (!compatible.length) return "";
|
||||
|
||||
+100
-7
@@ -1,10 +1,12 @@
|
||||
import { getFeatureAvailability, getSystemChannels, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { listLogicalModels, type CapabilitySpec, type OptionConstraint, type PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore } from "@/stores/use-config-store";
|
||||
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
|
||||
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
|
||||
@@ -41,7 +43,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
|
||||
const initialSystemConfig = {
|
||||
...defaultConfig,
|
||||
channels: payload.systemChannels || [],
|
||||
channels: managedModelChannels(payload.logicalModels || []),
|
||||
imageModels: undefined,
|
||||
videoModels: undefined,
|
||||
textModels: undefined,
|
||||
@@ -49,7 +51,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
};
|
||||
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
|
||||
} else {
|
||||
useConfigStore.getState().mergeSystemChannels(payload.systemChannels || []);
|
||||
useConfigStore.getState().mergeSystemChannels(managedModelChannels(payload.logicalModels || []));
|
||||
}
|
||||
installRemoteUserDataAutoSync();
|
||||
if (payload.user?.id) await syncRemoteUserData(payload.user.id);
|
||||
@@ -60,9 +62,100 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
}
|
||||
|
||||
export async function refreshSystemChannels() {
|
||||
// 系统模型由后端统一维护,后台变更后只刷新这一层,避免重跑整套用户数据同步。
|
||||
const payload = await getSystemChannels();
|
||||
useConfigStore.getState().mergeSystemChannels(payload.channels || []);
|
||||
// 创作端只刷新公开前台模型;供应渠道目录仅管理员页面可见。
|
||||
const logicalPayload = await listLogicalModels();
|
||||
useConfigStore.getState().mergeSystemChannels(managedModelChannels(logicalPayload.models || []));
|
||||
}
|
||||
|
||||
function managedModelChannels(models: PublicLogicalModel[]) {
|
||||
const availableModels = models.filter((item) => item.available);
|
||||
if (!availableModels.length) return [];
|
||||
const managed: ModelChannel = {
|
||||
id: PUBLIC_MODEL_CATALOG_ID,
|
||||
name: "平台模型",
|
||||
baseUrl: "/api",
|
||||
apiKey: "system",
|
||||
apiFormat: "openai",
|
||||
scope: "system",
|
||||
enabled: true,
|
||||
models: availableModels.map((item) => item.id),
|
||||
modelCosts: availableModels.map((item) => ({
|
||||
model: item.id,
|
||||
displayName: item.name,
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
capability: item.capability,
|
||||
pricePolicy: item.pricePolicy,
|
||||
billingMode: item.billingMode,
|
||||
unitPriceMicrocredits: item.unitPriceMicrocredits,
|
||||
inputTokenPriceMicrocredits: item.inputPriceMicrocredits,
|
||||
outputTokenPriceMicrocredits: item.outputPriceMicrocredits,
|
||||
cachedTokenPriceMicrocredits: item.cachedPriceMicrocredits,
|
||||
capabilityConfig: projectLogicalCapability(item.capabilitySpec, item.defaultOptions),
|
||||
logicalModelId: item.id,
|
||||
logicalCapabilitySpec: item.capabilitySpec,
|
||||
logicalCapabilityProfiles: item.capabilityProfiles,
|
||||
defaultOptions: item.defaultOptions,
|
||||
})),
|
||||
};
|
||||
return [managed];
|
||||
}
|
||||
|
||||
function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string, unknown>): ModelCapabilityConfig {
|
||||
const projected = defaultModelCapabilityConfig();
|
||||
if (spec.capability === "image" && projected.image) {
|
||||
projected.image.references.maxImages = spec.inputs?.image?.max ?? 0;
|
||||
projected.image.references.maskSupported = (spec.inputs?.mask?.max ?? 0) > 0;
|
||||
projected.image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
|
||||
projected.image.quality = { supported: false, values: [], default: "auto" };
|
||||
projected.image.transparentBackground = { supported: false, default: false };
|
||||
applyStringOption(spec.options?.size || spec.options?.aspectRatio, defaults.size, (values, initial) => {
|
||||
projected.image!.size = { parameter: "size", values, default: initial, allowCustom: false };
|
||||
});
|
||||
applyStringOption(spec.options?.quality, defaults.quality, (values, initial) => {
|
||||
projected.image!.quality = { supported: true, values, default: initial };
|
||||
});
|
||||
projected.image.maxOutputs = maxNumericOption(spec.options?.count, 1);
|
||||
projected.image.transparentBackground = booleanOption(spec.options?.transparentBackground, defaults.transparentBackground);
|
||||
}
|
||||
if (spec.capability === "video" && projected.video) {
|
||||
projected.video.references.minImages = spec.inputs?.image?.min ?? 0;
|
||||
projected.video.references.maxImages = spec.inputs?.image?.max ?? 0;
|
||||
projected.video.references.maxVideos = spec.inputs?.video?.max ?? 0;
|
||||
projected.video.references.maxAudios = spec.inputs?.audio?.max ?? 0;
|
||||
projected.video.operations = spec.operations || [];
|
||||
projected.video.defaultOperation = spec.operations?.[0] || "";
|
||||
const duration = spec.options?.videoSeconds || spec.options?.duration;
|
||||
if (duration?.values?.length) projected.video.duration = { selection: "enum", values: duration.values.map(Number).filter(Number.isFinite), default: Number(defaults.videoSeconds ?? duration.values[0]) };
|
||||
else if (duration?.min !== undefined && duration.max !== undefined) projected.video.duration = { selection: "range", min: duration.min, max: duration.max, step: duration.step || 1, default: Number(defaults.videoSeconds ?? duration.min) };
|
||||
projected.video.ratios = stringValues(spec.options?.size || spec.options?.aspectRatio);
|
||||
projected.video.defaultRatio = String(defaults.size ?? projected.video.ratios[0] ?? "");
|
||||
projected.video.resolutions = stringValues(spec.options?.vquality || spec.options?.resolution);
|
||||
projected.video.defaultResolution = String(defaults.vquality ?? projected.video.resolutions[0] ?? "");
|
||||
projected.video.generateAudio = booleanOption(spec.options?.videoGenerateAudio, defaults.videoGenerateAudio);
|
||||
projected.video.watermark = booleanOption(spec.options?.videoWatermark, defaults.videoWatermark);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function applyStringOption(option: OptionConstraint | undefined, fallback: unknown, apply: (values: string[], initial: string) => void) {
|
||||
const values = stringValues(option);
|
||||
if (values.length) apply(values, String(fallback ?? values[0]));
|
||||
}
|
||||
|
||||
function stringValues(option?: OptionConstraint) {
|
||||
return (option?.values || []).map(String);
|
||||
}
|
||||
|
||||
function maxNumericOption(option: OptionConstraint | undefined, fallback: number) {
|
||||
if (option?.max !== undefined) return option.max;
|
||||
const values = (option?.values || []).map(Number).filter(Number.isFinite);
|
||||
return values.length ? Math.max(...values) : fallback;
|
||||
}
|
||||
|
||||
function booleanOption(option: OptionConstraint | undefined, fallback: unknown) {
|
||||
const supported = (option?.values || []).some((value) => value === true || value === "true");
|
||||
return { supported, default: supported && String(fallback) === "true" };
|
||||
}
|
||||
|
||||
export async function refreshFeatureAvailability() {
|
||||
|
||||
Reference in new issue
Block a user