1028 lines
49 KiB
TypeScript
1028 lines
49 KiB
TypeScript
import { useMemo } from "react";
|
||
import type { ModelTag } from "@/lib/model-tags";
|
||
import { create } from "zustand";
|
||
import { createJSONStorage, persist } from "zustand/middleware";
|
||
import { nanoid } from "nanoid";
|
||
|
||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||
import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } from "@/lib/model-protocols";
|
||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||
import { workflowFieldRole, workflowFieldSafeToOverride, workflowVideoFieldsFromJson, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||
import { useUserStore } from "@/stores/use-user-store";
|
||
import type { CapabilitySpec, PublicLogicalModelPriceTier } from "@/services/api/logical-models";
|
||
|
||
export type ApiCallFormat = "openai" | "gemini" | "claude";
|
||
export type ChannelInterfaceType = ModelProtocol;
|
||
export type ChannelHeader = { name: string; value: string };
|
||
export type RunningHubCapability = "image" | "video" | "audio";
|
||
export type RunningHubWorkflowKind = "workflow" | "app";
|
||
|
||
export type WorkflowFieldMapping = {
|
||
id?: string;
|
||
nodeId: string;
|
||
classType?: string;
|
||
fieldName: string;
|
||
fieldValue?: unknown;
|
||
value?: unknown;
|
||
default?: unknown;
|
||
defaultValue?: unknown;
|
||
fieldType?: string;
|
||
label?: string;
|
||
role?: string;
|
||
safeToOverride?: boolean;
|
||
optionsSource?: "workflow" | "manual" | "preset";
|
||
options?: unknown[];
|
||
min?: unknown;
|
||
max?: unknown;
|
||
step?: unknown;
|
||
enabled?: boolean;
|
||
source?: string;
|
||
sourceIndex?: number;
|
||
imageOrder?: number;
|
||
required?: boolean;
|
||
randomEnabled?: boolean;
|
||
bindPrompt?: boolean;
|
||
sourceFromUpstream?: boolean;
|
||
sourceAutomatic?: boolean;
|
||
};
|
||
|
||
export function normalizeRunningHubCapability(value: unknown, fallback: RunningHubCapability = "image"): RunningHubCapability {
|
||
return value === "image" || value === "video" || value === "audio" ? value : fallback;
|
||
}
|
||
|
||
export function normalizeRunningHubWorkflowKind(value: unknown): RunningHubWorkflowKind {
|
||
return value === "app" ? "app" : "workflow";
|
||
}
|
||
|
||
function normalizeWorkflowFieldSourceName(value: unknown, capability?: RunningHubCapability) {
|
||
const source = String(value || "").trim();
|
||
const normalized = source.toLowerCase().replace(/[\s_-]/g, "");
|
||
const aliases: Record<string, string> = {
|
||
text: "prompt",
|
||
positive: "prompt",
|
||
positiveprompt: "prompt",
|
||
image: "referenceImage",
|
||
referenceimage: "referenceImage",
|
||
referenceimages: "referenceImage",
|
||
video: "referenceVideo",
|
||
referencevideo: "referenceVideo",
|
||
referencevideos: "referenceVideo",
|
||
audio: "referenceAudio",
|
||
referenceaudio: "referenceAudio",
|
||
referenceaudios: "referenceAudio",
|
||
sizewidth: "width",
|
||
imagewidth: "width",
|
||
videowidth: "width",
|
||
sizeheight: "height",
|
||
imageheight: "height",
|
||
videoheight: "height",
|
||
ratio: "aspectRatio",
|
||
aspectratio: "aspectRatio",
|
||
imageaspectratio: "aspectRatio",
|
||
imageratio: "aspectRatio",
|
||
videoaspectratio: "aspectRatio",
|
||
videoratio: "aspectRatio",
|
||
videoresolution: "vquality",
|
||
batch: "count",
|
||
batchsize: "count",
|
||
duration: "videoSeconds",
|
||
videoseconds: "videoSeconds",
|
||
videoquality: "vquality",
|
||
generateaudio: "videoGenerateAudio",
|
||
videogenerateaudio: "videoGenerateAudio",
|
||
watermark: "videoWatermark",
|
||
videowatermark: "videoWatermark",
|
||
voice: "audioVoice",
|
||
systemprompt: "systemPrompt",
|
||
transparentbackground: "transparentBackground",
|
||
audiovoice: "audioVoice",
|
||
audioformat: "audioFormat",
|
||
audiospeed: "audioSpeed",
|
||
audioinstructions: "audioInstructions",
|
||
};
|
||
if (normalized === "resolution") return capability === "video" ? "vquality" : "size";
|
||
// 工作流的 quality 可能是连续数值(例如 0.1-3),不能按视频分辨率处理。
|
||
if (normalized === "quality") return "quality";
|
||
return aliases[normalized] || source;
|
||
}
|
||
|
||
const workflowDimensionPrefixes = ["", "image", "video", "size", "output", "target", "latent", "frame", "canvas", "source", "resolution", "final"];
|
||
|
||
function workflowDimensionSource(key: string) {
|
||
if (workflowDimensionPrefixes.some((prefix) => key === `${prefix}width`) || key === "pixelwidth" || key === "widthpixels") return "width";
|
||
if (workflowDimensionPrefixes.some((prefix) => key === `${prefix}height`) || key === "pixelheight" || key === "heightpixels") return "height";
|
||
return "";
|
||
}
|
||
|
||
function inferWorkflowFieldSource(fieldName: string, fieldType: string, capability?: RunningHubCapability) {
|
||
const key = fieldName.toLowerCase().replace(/[\s_-]/g, "");
|
||
const normalizedFieldType = fieldType.trim().toLowerCase();
|
||
if (["text", "prompt", "positive", "positiveprompt"].includes(key)) return "prompt";
|
||
if (key.includes("mask")) return "mask";
|
||
const dimensionSource = workflowDimensionSource(key);
|
||
if (dimensionSource) return dimensionSource;
|
||
if (["ratio", "aspectratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio"].includes(key)) return "aspectRatio";
|
||
if (["videoresolution", "videoquality", "vquality"].includes(key)) return "vquality";
|
||
if (["size", "imagesize", "imageresolution"].includes(key)) return "size";
|
||
if (key === "resolution") return capability === "video" ? "vquality" : capability === "image" ? "size" : "";
|
||
if (["batch", "batchsize", "count", "numimages", "numberofimages", "imagecount", "imagescount"].includes(key)) return "count";
|
||
if (key === "quality") return "quality";
|
||
if (["duration", "seconds", "durationseconds", "videoseconds", "videoduration", "videodurationseconds", "videolength", "clipduration"].includes(key)) return "videoSeconds";
|
||
if (["generateaudio", "videogenerateaudio"].includes(key)) return "videoGenerateAudio";
|
||
if (["watermark", "videowatermark"].includes(key)) return "videoWatermark";
|
||
if (key === "audioformat" || (key === "format" && normalizedFieldType === "audio")) return "audioFormat";
|
||
if (["voice", "audiovoice"].includes(key)) return "audioVoice";
|
||
if ((key === "speed" && normalizedFieldType === "audio") || key === "audiospeed") return "audioSpeed";
|
||
if ((key === "instructions" && normalizedFieldType === "audio") || key === "audioinstructions") return "audioInstructions";
|
||
if (["transparentbackground", "transparent"].includes(key)) return "transparentBackground";
|
||
if (normalizedFieldType === "image") return "referenceImage";
|
||
if (normalizedFieldType === "video") return "referenceVideo";
|
||
if (normalizedFieldType === "audio") return "referenceAudio";
|
||
return "";
|
||
}
|
||
|
||
function shouldRepairLegacyWorkflowSource(fieldName: string, source: string, inferredSource: string, capability?: RunningHubCapability) {
|
||
if (!source || !inferredSource || source === inferredSource) return false;
|
||
const key = fieldName.toLowerCase().replace(/[\s_-]/g, "");
|
||
const wasMistakenForMedia = ["referenceImage", "referenceVideo", "referenceAudio"].includes(source);
|
||
if (wasMistakenForMedia && (workflowDimensionSource(key) || inferredSource === "aspectRatio" || inferredSource === "vquality")) return true;
|
||
// 旧版把通用 resolution 固定识别为图片尺寸;视频条目必须恢复为视频清晰度。
|
||
if (key === "resolution" && capability === "video" && source === "size") return true;
|
||
if (key === "resolution" && capability === "image" && source === "vquality") return true;
|
||
if (key === "quality" && source === "vquality") return true;
|
||
return false;
|
||
}
|
||
|
||
export function normalizeWorkflowFieldMappings(value: unknown, capability?: RunningHubCapability): WorkflowFieldMapping[] {
|
||
if (!Array.isArray(value)) return [];
|
||
const fields = value.flatMap((item) => {
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||
const raw = item as Record<string, unknown>;
|
||
const nodeId = String(raw.nodeId || raw.node || raw.node_id || "").trim();
|
||
const fieldName = String(raw.fieldName || raw.input || raw.inputName || raw.input_name || "").trim();
|
||
if (!nodeId || !fieldName) return [];
|
||
// source 明确存在但为空,表示用户要求保留工作流默认值;只有旧数据完全缺少来源字段时才自动补绑定。
|
||
const sourceKey = ["source", "bind", "from"].find((key) => Object.prototype.hasOwnProperty.call(raw, key));
|
||
const sourceConfigured = Boolean(sourceKey);
|
||
const configuredSource = sourceKey ? raw[sourceKey] : raw.bindPrompt === true || raw.bind_prompt === true ? "prompt" : "";
|
||
let source = normalizeWorkflowFieldSourceName(configuredSource, capability);
|
||
const rawSourceAutomatic = raw.sourceAutomatic ?? raw.source_automatic;
|
||
let sourceAutomatic = typeof rawSourceAutomatic === "boolean" ? rawSourceAutomatic : !sourceConfigured && Boolean(source);
|
||
const upstreamAllowed = raw.sourceFromUpstream !== false && raw.source_from_upstream !== false;
|
||
const upstreamExplicit = raw.sourceFromUpstream === true || raw.source_from_upstream === true;
|
||
const fieldType = String(raw.fieldType || raw.type || "").trim();
|
||
const inferredSource = inferWorkflowFieldSource(fieldName, fieldType, capability);
|
||
if (!source && !sourceConfigured && upstreamAllowed) {
|
||
source = inferredSource;
|
||
sourceAutomatic = Boolean(source);
|
||
if (!source && upstreamExplicit && ["text", "prompt", "positiveprompt", "positive"].includes(fieldName.toLowerCase().replace(/[\s_-]/g, ""))) {
|
||
source = "prompt";
|
||
sourceAutomatic = true;
|
||
}
|
||
} else if (shouldRepairLegacyWorkflowSource(fieldName, source, inferredSource, capability)) {
|
||
source = inferredSource;
|
||
}
|
||
// 比例、清晰度、时长等标量必须按 nodeId + fieldName 独立配置。
|
||
// 旧版自动来源会把两个同类字段折叠到同一个全局参数,重新归一化为直接字段值。
|
||
if (sourceAutomatic && !["prompt", "referenceImage", "referenceVideo", "referenceAudio", "mask"].includes(source)) {
|
||
source = "";
|
||
sourceAutomatic = false;
|
||
}
|
||
const fieldValue = raw.fieldValue ?? raw.defaultValue ?? raw.default_value ?? raw.default ?? raw.value;
|
||
const rawOptions = raw.options ?? raw.values ?? raw.choices ?? raw.enum ?? raw.fieldOptions ?? raw.field_options;
|
||
const options = Array.isArray(rawOptions)
|
||
? rawOptions
|
||
: rawOptions && typeof rawOptions === "object"
|
||
? ((rawOptions as Record<string, unknown>).choices ?? (rawOptions as Record<string, unknown>).options ?? (rawOptions as Record<string, unknown>).values)
|
||
: undefined;
|
||
const range = workflowFieldRange(rawOptions);
|
||
const min = raw.min ?? raw.minValue ?? raw.min_value ?? range.min;
|
||
const max = raw.max ?? raw.maxValue ?? raw.max_value ?? range.max;
|
||
const step = raw.step ?? raw.stepValue ?? raw.step_value ?? range.step;
|
||
const required = raw.required ?? raw.isRequired ?? raw.is_required;
|
||
const randomEnabled = raw.randomEnabled ?? raw.random_enabled;
|
||
const sourceIndex = Number(raw.sourceIndex ?? raw.source_index ?? raw.index);
|
||
const imageOrder = Number(raw.imageOrder ?? raw.image_order);
|
||
const candidate: WorkflowFieldMapping = {
|
||
...raw,
|
||
nodeId,
|
||
classType: String(raw.classType || raw.class_type || "").trim() || undefined,
|
||
fieldName,
|
||
id: String(raw.id || `${nodeId}::${fieldName}`),
|
||
source,
|
||
...(fieldValue !== undefined ? { fieldValue } : {}),
|
||
...(Array.isArray(options) ? { options } : {}),
|
||
...(min !== undefined ? { min } : {}),
|
||
...(max !== undefined ? { max } : {}),
|
||
...(step !== undefined ? { step } : {}),
|
||
...(typeof required === "boolean" ? { required } : {}),
|
||
...(typeof randomEnabled === "boolean" ? { randomEnabled } : {}),
|
||
sourceAutomatic,
|
||
...(Number.isFinite(sourceIndex) ? { sourceIndex } : {}),
|
||
...(Number.isFinite(imageOrder) ? { imageOrder } : {}),
|
||
} as WorkflowFieldMapping;
|
||
const safeToOverride = raw.safeToOverride !== false && raw.safe_to_override !== false && workflowFieldSafeToOverride(candidate);
|
||
const role = workflowFieldRole({ ...candidate, role: String(raw.role || "") });
|
||
const configuredEnabled = typeof raw.enabled === "boolean" ? raw.enabled : role !== "internal";
|
||
const optionsSource = ["workflow", "manual", "preset"].includes(String(raw.optionsSource || raw.options_source || ""))
|
||
? (String(raw.optionsSource || raw.options_source) as WorkflowFieldMapping["optionsSource"])
|
||
: Array.isArray(options)
|
||
? "workflow"
|
||
: undefined;
|
||
return [{ ...candidate, role, safeToOverride, enabled: safeToOverride && configuredEnabled, ...(optionsSource ? { optionsSource } : {}) }];
|
||
});
|
||
let imageOrder = 0;
|
||
let videoOrder = 0;
|
||
let audioOrder = 0;
|
||
return fields.map((field) => {
|
||
if (field.source === "referenceImage") {
|
||
const configuredOrder = Number(field.imageOrder);
|
||
const nextOrder = Number.isInteger(configuredOrder) && configuredOrder > 0 ? configuredOrder : imageOrder + 1;
|
||
imageOrder = Math.max(imageOrder, nextOrder);
|
||
return { ...field, imageOrder: nextOrder, sourceIndex: nextOrder - 1, required: field.required ?? nextOrder === 1 };
|
||
}
|
||
if (field.source === "referenceVideo") {
|
||
const configuredIndex = Number(field.sourceIndex);
|
||
const nextIndex = Number.isInteger(configuredIndex) && configuredIndex >= 0 ? configuredIndex : videoOrder;
|
||
videoOrder = Math.max(videoOrder, nextIndex + 1);
|
||
return { ...field, sourceIndex: nextIndex, required: field.required ?? nextIndex === 0 };
|
||
}
|
||
if (field.source === "referenceAudio") {
|
||
const configuredIndex = Number(field.sourceIndex);
|
||
const nextIndex = Number.isInteger(configuredIndex) && configuredIndex >= 0 ? configuredIndex : audioOrder;
|
||
audioOrder = Math.max(audioOrder, nextIndex + 1);
|
||
return { ...field, sourceIndex: nextIndex, required: field.required ?? nextIndex === 0 };
|
||
}
|
||
if (field.source === "prompt") return { ...field, required: field.required ?? true };
|
||
return field;
|
||
});
|
||
}
|
||
|
||
export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown, capability?: RunningHubCapability) {
|
||
const previousFields = normalizeWorkflowFieldMappings(current, capability);
|
||
const nextFields = normalizeWorkflowFieldMappings(incoming, capability);
|
||
const previousByKey = new Map(previousFields.map((field) => [`${field.nodeId}::${field.fieldName}`, field]));
|
||
const policyKeys: Array<keyof WorkflowFieldMapping> = ["label", "enabled", "randomEnabled", "source", "sourceAutomatic", "sourceFromUpstream", "sourceIndex", "imageOrder", "required", "bindPrompt"];
|
||
return nextFields.map((field) => {
|
||
const previous = previousByKey.get(`${field.nodeId}::${field.fieldName}`);
|
||
if (!previous) return field;
|
||
const merged = { ...field } as WorkflowFieldMapping;
|
||
const target = merged as unknown as Record<string, unknown>;
|
||
const source = previous as unknown as Record<string, unknown>;
|
||
policyKeys.forEach((key) => {
|
||
if (Object.prototype.hasOwnProperty.call(source, key)) target[key] = source[key];
|
||
});
|
||
// 字段类型、枚举和数值范围属于上游协议。只有本次拉取未返回时,才沿用本地补充配置。
|
||
if (!field.fieldType && previous.fieldType) merged.fieldType = previous.fieldType;
|
||
if (!field.options?.length && previous.options?.length) {
|
||
merged.options = previous.options;
|
||
merged.optionsSource = previous.optionsSource;
|
||
}
|
||
if (field.min === undefined && previous.min !== undefined) merged.min = previous.min;
|
||
if (field.max === undefined && previous.max !== undefined) merged.max = previous.max;
|
||
if (field.step === undefined && previous.step !== undefined) merged.step = previous.step;
|
||
// 首次获得角色信息时采用服务端的内部参数默认值;之后才保留用户明确的启用选择。
|
||
if (field.role === "internal" && previous.role !== "internal") merged.enabled = field.enabled;
|
||
if (field.safeToOverride === false) merged.enabled = false;
|
||
return merged;
|
||
});
|
||
}
|
||
|
||
function normalizeSavedWorkflowFields(workflow: { fields?: unknown; workflowJson?: Record<string, unknown> }, capability: RunningHubCapability) {
|
||
const schemaFields = workflowVideoFieldsFromJson(workflow.workflowJson);
|
||
return schemaFields.length ? mergeWorkflowFieldMappings(workflow.fields, schemaFields, capability) : normalizeWorkflowFieldMappings(workflow.fields, capability);
|
||
}
|
||
|
||
function workflowFieldRange(value: unknown) {
|
||
const candidates: Record<string, unknown>[] = [];
|
||
const add = (item: unknown) => {
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) return;
|
||
const record = item as Record<string, unknown>;
|
||
candidates.push(record);
|
||
add(record.range);
|
||
};
|
||
add(value);
|
||
if (Array.isArray(value)) value.forEach(add);
|
||
const read = (keys: string[]) => candidates.map((item) => keys.map((key) => item[key]).find((candidate) => candidate !== undefined)).find((candidate) => candidate !== undefined);
|
||
return { min: read(["min", "minValue", "min_value"]), max: read(["max", "maxValue", "max_value"]), step: read(["step", "stepValue", "step_value"]) };
|
||
}
|
||
|
||
export type RunningHubWorkflow = {
|
||
kind?: RunningHubWorkflowKind;
|
||
capability?: RunningHubCapability;
|
||
workflowId: string;
|
||
webappId?: string;
|
||
title?: string;
|
||
description?: string;
|
||
fields?: WorkflowFieldMapping[];
|
||
workflowJson?: Record<string, unknown>;
|
||
optionalImageMode?: string;
|
||
raw?: Record<string, unknown>;
|
||
};
|
||
|
||
export type RunningHubConfig = {
|
||
enabled: boolean;
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
walletApiKey: string;
|
||
/** 仅用于 RunningHub 参考素材上传,通常填写企业级 API Key。 */
|
||
uploadApiKey?: string;
|
||
useWallet: boolean;
|
||
capability: RunningHubCapability;
|
||
selectedKind: RunningHubWorkflowKind;
|
||
workflowId: string;
|
||
workflows: RunningHubWorkflow[];
|
||
};
|
||
|
||
export type WorkflowGraphPreview = {
|
||
nodes: Array<{ id: string; title?: string; classType?: string }>;
|
||
edges: Array<{ from: string; to: string }>;
|
||
};
|
||
|
||
// 兼容仍在使用旧目录标识的会话恢复和模型选择器。
|
||
export const PUBLIC_MODEL_CATALOG_ID = "managed";
|
||
|
||
export type ModelChannel = {
|
||
id: string;
|
||
name: string;
|
||
sortOrder?: number;
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
secretKey?: string;
|
||
headers?: ChannelHeader[];
|
||
apiFormat: ApiCallFormat;
|
||
interfaceType?: ChannelInterfaceType;
|
||
models: string[];
|
||
// 仅平台目录使用:将已保存的旧 SKU 选择重定向到当前模型家族。
|
||
modelAliases?: Record<string, string>;
|
||
scope?: "system" | "user";
|
||
enabled?: boolean;
|
||
hasApiKey?: boolean;
|
||
hasSecretKey?: boolean;
|
||
concurrencyLimit?: number;
|
||
modelCosts?: Array<{
|
||
model: string;
|
||
displayName?: string;
|
||
channelLabel?: string;
|
||
tags?: ModelTag[];
|
||
description?: string;
|
||
icon?: string;
|
||
capability: ModelCapability;
|
||
protocol?: ModelProtocol;
|
||
pricePolicy?: "channel" | "unified";
|
||
billingMode: "fixed_request" | "per_second" | "token";
|
||
unitPriceMicrocredits: number;
|
||
inputTokenPriceMicrocredits?: number;
|
||
outputTokenPriceMicrocredits?: number;
|
||
cachedTokenPriceMicrocredits?: number;
|
||
capabilityConfig?: ModelCapabilityConfig;
|
||
logicalModelId?: string;
|
||
logicalCapabilitySpec?: CapabilitySpec;
|
||
logicalCapabilityProfiles?: CapabilitySpec[];
|
||
logicalPriceTiers?: PublicLogicalModelPriceTier[];
|
||
defaultOptions?: Record<string, unknown>;
|
||
}>;
|
||
};
|
||
|
||
export type AiConfig = {
|
||
channelMode: "remote";
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
apiFormat: ApiCallFormat;
|
||
channels: ModelChannel[];
|
||
runningHub: RunningHubConfig;
|
||
/** 仅用于单次生成任务路由,不属于全局渠道启用状态。 */
|
||
taskWorkflowProvider?: "model" | "runninghub";
|
||
model: string;
|
||
imageModel: string;
|
||
videoModel: string;
|
||
textModel: string;
|
||
audioModel: string;
|
||
audioVoice: string;
|
||
audioFormat: string;
|
||
audioSpeed: string;
|
||
audioInstructions: string;
|
||
videoSeconds: string;
|
||
vquality: string;
|
||
videoGenerateAudio: string;
|
||
videoWatermark: string;
|
||
videoArkPrivateAssetUpload: string;
|
||
systemPrompt: string;
|
||
models: string[];
|
||
imageModels: string[];
|
||
videoModels: string[];
|
||
textModels: string[];
|
||
audioModels: string[];
|
||
quality: string;
|
||
size: string;
|
||
transparentBackground: string;
|
||
count: string;
|
||
canvasImageCount: string;
|
||
};
|
||
|
||
export const CONFIG_STORE_KEY = "open_ai_canvas:ai_config_store";
|
||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||
const CHANNEL_MODEL_SEPARATOR = "::";
|
||
const OPENAI_BASE_URL = "https://api.openai.com";
|
||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
|
||
const LEGACY_DEFAULT_MODEL_NAMES = new Set(["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"]);
|
||
|
||
export const defaultConfig: AiConfig = {
|
||
channelMode: "remote",
|
||
baseUrl: OPENAI_BASE_URL,
|
||
apiKey: "",
|
||
apiFormat: "openai",
|
||
// 创作端模型目录只能来自后台公开逻辑模型和用户自定义渠道,不能内置供应商模型。
|
||
channels: [],
|
||
runningHub: { enabled: false, baseUrl: "https://www.runninghub.cn", apiKey: "", walletApiKey: "", uploadApiKey: "", useWallet: false, capability: "image", selectedKind: "workflow", workflowId: "", workflows: [] },
|
||
taskWorkflowProvider: "model",
|
||
model: "",
|
||
imageModel: "",
|
||
videoModel: "",
|
||
textModel: "",
|
||
audioModel: "",
|
||
audioVoice: "alloy",
|
||
audioFormat: "mp3",
|
||
audioSpeed: "1",
|
||
audioInstructions: "",
|
||
videoSeconds: "6",
|
||
vquality: "720",
|
||
videoGenerateAudio: "true",
|
||
videoWatermark: "false",
|
||
videoArkPrivateAssetUpload: "true",
|
||
systemPrompt: "",
|
||
models: [],
|
||
imageModels: [],
|
||
videoModels: [],
|
||
textModels: [],
|
||
audioModels: [],
|
||
quality: "auto",
|
||
size: "1:1",
|
||
transparentBackground: "false",
|
||
count: "1",
|
||
canvasImageCount: "1",
|
||
};
|
||
|
||
type ConfigStore = {
|
||
config: AiConfig;
|
||
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||
replaceConfig: (config: AiConfig) => void;
|
||
mergeSystemChannels: (channels: ModelChannel[]) => void;
|
||
isAiConfigReady: (config: AiConfig, model: string) => boolean;
|
||
};
|
||
|
||
export type ConfigStoreSnapshot = {
|
||
config?: Partial<AiConfig>;
|
||
};
|
||
|
||
function isVideoModelName(model: string) {
|
||
const value = modelOptionName(model).toLowerCase();
|
||
return (
|
||
value.includes("seedance") ||
|
||
value.includes("video") ||
|
||
value.includes("sora") ||
|
||
value.includes("veo") ||
|
||
value.includes("kling") ||
|
||
value.includes("wan") ||
|
||
value.includes("hailuo") ||
|
||
value.includes("pika") ||
|
||
value.includes("runway") ||
|
||
value.includes("gen-3") ||
|
||
value.includes("gen3") ||
|
||
value.includes("hunyuan-video") ||
|
||
value.includes("hunyuanvideo") ||
|
||
value.includes("cogvideo") ||
|
||
value.includes("mochi") ||
|
||
value.includes("latte") ||
|
||
value.includes("stable-video") ||
|
||
value.includes("svd") ||
|
||
value.includes("animatediff") ||
|
||
value.includes("ltx-video") ||
|
||
value.includes("ltxvideo") ||
|
||
value.includes("minimax-video") ||
|
||
value.includes("abab-video")
|
||
);
|
||
}
|
||
|
||
function isImageModelName(model: string) {
|
||
const value = modelOptionName(model).toLowerCase();
|
||
return (
|
||
!isVideoModelName(model) &&
|
||
!isAudioModelName(model) &&
|
||
(value.includes("seedream") ||
|
||
value.includes("gpt-image") ||
|
||
value.includes("image") ||
|
||
value.includes("dall-e") ||
|
||
value.includes("dalle") ||
|
||
value.includes("imagen") ||
|
||
value.includes("flux") ||
|
||
value.includes("sdxl") ||
|
||
value.includes("stable-diffusion") ||
|
||
value.includes("midjourney") ||
|
||
value.includes("nano-banana") ||
|
||
value.includes("nanobanana") ||
|
||
value.includes("ideogram") ||
|
||
value.includes("recraft") ||
|
||
value.includes("playground") ||
|
||
value.includes("leonardo"))
|
||
);
|
||
}
|
||
|
||
function isAudioModelName(model: string) {
|
||
const value = modelOptionName(model).toLowerCase();
|
||
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
|
||
}
|
||
|
||
function isTextModelName(model: string) {
|
||
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
|
||
}
|
||
|
||
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
|
||
if (!capability) return true;
|
||
if (capability === "image") return isImageModelName(model);
|
||
if (capability === "video") return isVideoModelName(model);
|
||
if (capability === "audio") return isAudioModelName(model);
|
||
return isTextModelName(model);
|
||
}
|
||
|
||
export function filterModelsByCapability(models: string[], capability?: ModelCapability, channels?: ModelChannel[]) {
|
||
if (!capability) return models;
|
||
return models.filter((model) => {
|
||
const decoded = decodeChannelModel(model);
|
||
const channel = decoded ? channels?.find((item) => item.id === decoded.channelId) : undefined;
|
||
const modelName = decoded?.model || modelOptionName(model);
|
||
const costEntry = channel?.modelCosts?.find((item) => item.model === modelName);
|
||
// 协议层优先级最高:协议决定 API 端点,明确属于其他能力时直接排除,
|
||
// 防止用户将 video/image/audio 协议的模型误标为 text 后混入文本下拉。
|
||
const protocolCapability = modelProtocolCapability(costEntry?.protocol);
|
||
if (protocolCapability) return protocolCapability === capability;
|
||
// 渠道接口层:渠道级协议推断能力
|
||
const channelCapability = capabilityForChannelInterface(channel?.interfaceType);
|
||
if (channelCapability) return channelCapability === capability;
|
||
// 配置能力层:用户显式标记的 capability
|
||
const configuredCapability = costEntry?.capability;
|
||
if (configuredCapability) return configuredCapability === capability;
|
||
// 模型名启发式:最后回退
|
||
return modelMatchesCapability(model, capability);
|
||
});
|
||
}
|
||
|
||
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
|
||
// 选项目录只从当前有效渠道重建,不能信任旧快照里残留的 config.models。
|
||
// 这样旧版本内置模型、未绑定渠道的裸模型不会再次进入创作端。
|
||
const models = modelOptionsFromChannels(config.channels);
|
||
if (!capability) return models;
|
||
return filterModelsByCapability(models, capability, config.channels);
|
||
}
|
||
|
||
export function configuredModelMatchesCapability(config: AiConfig, model: string, capability?: ModelCapability) {
|
||
const normalized = normalizeModelOptionValue(model, config.channels);
|
||
if (!normalized) return false;
|
||
return selectableModelsByCapability(config, capability).includes(normalized);
|
||
}
|
||
|
||
function isAiConfigReady(config: AiConfig, model: string) {
|
||
if (config.taskWorkflowProvider === "runninghub") {
|
||
const key = config.runningHub.apiKey;
|
||
return Boolean(config.runningHub.enabled && config.runningHub.baseUrl.trim() && key.trim() && config.runningHub.workflowId.trim());
|
||
}
|
||
const channel = resolveModelChannel(config, model);
|
||
return Boolean(model.trim() && channel.baseUrl.trim() && channel.apiKey.trim());
|
||
}
|
||
|
||
export const useConfigStore = create<ConfigStore>()(
|
||
persist(
|
||
(set) => ({
|
||
config: defaultConfig,
|
||
updateConfig: (key, value) =>
|
||
set((state) => ({
|
||
config: {
|
||
...state.config,
|
||
[key]: value,
|
||
},
|
||
})),
|
||
replaceConfig: (config) => set({ config }),
|
||
mergeSystemChannels: (channels) =>
|
||
set((state) => {
|
||
const systemChannels = channels.map((channel, index) =>
|
||
createModelChannel({
|
||
...channel,
|
||
id: channel.id || `system-${index + 1}`,
|
||
name: channel.name || `系统渠道 ${index + 1}`,
|
||
scope: "system",
|
||
apiKey: channel.apiKey || "system",
|
||
}),
|
||
);
|
||
const userChannels = state.config.channels.filter((channel) => channel.scope !== "system");
|
||
return normalizeConfigSnapshot({ config: { ...state.config, channels: [...systemChannels, ...userChannels] } });
|
||
}),
|
||
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
|
||
}),
|
||
{
|
||
name: CONFIG_STORE_KEY,
|
||
storage: createJSONStorage(() => scopedLocalStorage),
|
||
partialize: (state) => ({ config: state.config }),
|
||
merge: (persisted, current) => {
|
||
const persistedState = (persisted || {}) as Partial<ConfigStore>;
|
||
return {
|
||
...current,
|
||
...normalizeConfigSnapshot({ config: persistedState.config }),
|
||
};
|
||
},
|
||
},
|
||
),
|
||
);
|
||
|
||
export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefined = {}) {
|
||
// 坏存储/旧版本快照可能是 undefined 或缺 config,兜底为 defaultConfig,保证渲染不崩溃
|
||
const persistedConfig = (snapshot?.config || {}) as Partial<AiConfig>;
|
||
const persistedRunningHub = persistedConfig.runningHub;
|
||
const runningHubCapability = normalizeRunningHubCapability(persistedRunningHub?.capability, defaultConfig.runningHub.capability);
|
||
const runningHubWorkflows = Array.isArray(persistedRunningHub?.workflows)
|
||
? persistedRunningHub.workflows
|
||
.filter((item): item is RunningHubWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
|
||
.map((item) => {
|
||
const capability = normalizeRunningHubCapability(item.capability, runningHubCapability);
|
||
return {
|
||
...item,
|
||
kind: normalizeRunningHubWorkflowKind(item.kind),
|
||
workflowId: String(item.workflowId || "").trim(),
|
||
capability,
|
||
fields: normalizeSavedWorkflowFields(item, capability),
|
||
};
|
||
})
|
||
: [];
|
||
const runningHubWorkflowID = String(persistedRunningHub?.workflowId || "").trim();
|
||
const runningHubSelectedKind = persistedRunningHub?.selectedKind ? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind) : normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind);
|
||
const config = {
|
||
...defaultConfig,
|
||
...persistedConfig,
|
||
taskWorkflowProvider: "model" as const,
|
||
runningHub: {
|
||
...defaultConfig.runningHub,
|
||
...(persistedRunningHub || {}),
|
||
capability: runningHubCapability,
|
||
selectedKind: runningHubSelectedKind,
|
||
workflowId: runningHubWorkflowID,
|
||
workflows: runningHubWorkflows,
|
||
},
|
||
};
|
||
const hasPersistedChannels = Array.isArray(persistedConfig.channels);
|
||
if (!hasPersistedChannels) config.channels = [];
|
||
const channels = normalizeChannels(config, !hasPersistedChannels);
|
||
const models = modelOptionsFromChannels(channels);
|
||
const imageModels = filterModelsByCapability(models, "image", channels);
|
||
const videoModels = filterModelsByCapability(models, "video", channels);
|
||
const textModels = filterModelsByCapability(models, "text", channels);
|
||
const audioModels = filterModelsByCapability(models, "audio", channels);
|
||
const model = normalizeSelectedModel(config.model || config.imageModel || config.textModel, channels, models);
|
||
return {
|
||
config: {
|
||
...config,
|
||
channelMode: "remote" as const,
|
||
apiFormat: normalizeApiFormat(config.apiFormat),
|
||
channels,
|
||
models,
|
||
model,
|
||
imageModel: normalizeSelectedModel(config.imageModel || model, channels, imageModels),
|
||
videoModel: normalizeSelectedModel(config.videoModel, channels, videoModels),
|
||
textModel: normalizeSelectedModel(config.textModel || model, channels, textModels),
|
||
audioModel: normalizeSelectedModel(config.audioModel || defaultConfig.audioModel, channels, audioModels),
|
||
audioVoice: config.audioVoice || defaultConfig.audioVoice,
|
||
audioFormat: config.audioFormat || defaultConfig.audioFormat,
|
||
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
|
||
audioInstructions: config.audioInstructions || "",
|
||
// 旧版全局 systemPrompt 会跨任务污染请求;提示词定制现已按 operation 由服务端编译。
|
||
systemPrompt: "",
|
||
videoSeconds: normalizeVideoDuration(config.videoSeconds),
|
||
vquality: normalizeVideoResolution(config.vquality),
|
||
videoGenerateAudio: config.videoGenerateAudio || "true",
|
||
videoWatermark: config.videoWatermark || "false",
|
||
videoArkPrivateAssetUpload: config.videoArkPrivateAssetUpload || "true",
|
||
transparentBackground: config.transparentBackground === "true" ? "true" : "false",
|
||
canvasImageCount: config.canvasImageCount || defaultConfig.canvasImageCount,
|
||
imageModels,
|
||
videoModels,
|
||
textModels,
|
||
audioModels,
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeSelectedModel(value: string, channels: ModelChannel[], options: string[]) {
|
||
const model = normalizeModelOptionValue(value, channels);
|
||
return model && options.includes(model) ? model : options[0] || "";
|
||
}
|
||
|
||
export function useEffectiveConfig() {
|
||
const config = useConfigStore((state) => state.config);
|
||
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
|
||
return useMemo(() => effectiveConfigForCustomChannels(config, customChannelsEnabled), [config, customChannelsEnabled]);
|
||
}
|
||
|
||
export function effectiveConfigForCustomChannels(config: AiConfig, customChannelsEnabled: boolean): AiConfig {
|
||
if (customChannelsEnabled) return config;
|
||
const channels = config.channels.filter((channel) => channel.scope === "system");
|
||
return normalizeConfigSnapshot({ config: { ...config, channels } }).config;
|
||
}
|
||
|
||
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
|
||
const apiFormat = normalizeApiFormat(channel?.apiFormat);
|
||
const interfaceType = normalizeChannelInterfaceType(channel?.interfaceType);
|
||
const providedBaseUrl = channel?.baseUrl?.trim();
|
||
return {
|
||
id: channel?.id?.trim() || nanoid(),
|
||
name: channel?.name?.trim() || "新渠道",
|
||
sortOrder: channel?.sortOrder ?? 0,
|
||
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
|
||
apiKey: channel?.apiKey || "",
|
||
secretKey: channel?.secretKey || "",
|
||
headers: Array.isArray(channel?.headers) ? channel.headers.map((header) => ({ name: String(header.name || ""), value: String(header.value || "") })) : [],
|
||
apiFormat,
|
||
interfaceType,
|
||
models: uniqueRawModels(channel?.models || []),
|
||
scope: channel?.scope === "system" ? "system" : "user",
|
||
enabled: channel?.enabled !== false,
|
||
hasApiKey: channel?.hasApiKey,
|
||
hasSecretKey: channel?.hasSecretKey,
|
||
modelCosts: channel?.modelCosts?.map((item) => ({ ...item, protocol: normalizeModelProtocol(item.protocol) })),
|
||
};
|
||
}
|
||
|
||
export function encodeChannelModel(channelId: string, model: string) {
|
||
return `${channelId}${CHANNEL_MODEL_SEPARATOR}${model.trim()}`;
|
||
}
|
||
|
||
export function isChannelModelValue(value: string) {
|
||
return value.includes(CHANNEL_MODEL_SEPARATOR);
|
||
}
|
||
|
||
export function decodeChannelModel(value: string) {
|
||
const index = value.indexOf(CHANNEL_MODEL_SEPARATOR);
|
||
if (index < 0) return null;
|
||
return { channelId: value.slice(0, index), model: value.slice(index + CHANNEL_MODEL_SEPARATOR.length) };
|
||
}
|
||
|
||
export function modelOptionName(value: string) {
|
||
return decodeChannelModel(value)?.model || value;
|
||
}
|
||
|
||
export function modelDisplayName(config: AiConfig, value: string) {
|
||
const model = modelOptionName(value);
|
||
const channel = resolveModelChannel(config, value);
|
||
const displayName = channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim();
|
||
if (displayName) return displayName;
|
||
return channel.scope === "system" ? "系统模型" : model;
|
||
}
|
||
|
||
export function modelIcon(config: AiConfig, value: string) {
|
||
const model = modelOptionName(value);
|
||
return resolveModelChannel(config, value).modelCosts?.find((item) => item.model === model)?.icon || "";
|
||
}
|
||
|
||
export function modelOptionLabel(config: AiConfig, value: string) {
|
||
const decoded = decodeChannelModel(value);
|
||
if (!decoded) return modelDisplayName(config, value);
|
||
const channel = config.channels.find((item) => item.id === decoded.channelId);
|
||
const displayName = modelDisplayName(config, value);
|
||
// 平台前台模型只展示公开名称;供应来源和内部目录适配器不属于创作端信息。
|
||
if (!channel || channel.scope === "system") return displayName;
|
||
return `${displayName}(${channel.name})`;
|
||
}
|
||
|
||
export function modelOptionsFromChannels(channels: ModelChannel[]) {
|
||
return uniqueModelOptions(
|
||
channels.flatMap((channel) =>
|
||
channel.models
|
||
.map(normalizeRawModelName)
|
||
.filter(Boolean)
|
||
.filter((model) => channel.scope !== "system" || hasSystemModelPrice(channel, model))
|
||
.map((model) => encodeChannelModel(channel.id, model)),
|
||
),
|
||
);
|
||
}
|
||
|
||
export function hasSystemModelPrice(channel: ModelChannel, model: string) {
|
||
if (channel.scope !== "system") return true;
|
||
// 价格字段已由后端按“非负数”校验;0 表示免费模型,不能在目录重建时被过滤。
|
||
const configured = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||
return (
|
||
channel.modelCosts?.some((item) => {
|
||
if (item.model !== model) return false;
|
||
const tiers = item.logicalPriceTiers || [];
|
||
if (tiers.length) {
|
||
return tiers.some((tier) => (tier.billingMode === "token" ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].every(configured) : configured(tier.unitPriceMicrocredits)));
|
||
}
|
||
if (item.billingMode === "token") {
|
||
return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].every(configured);
|
||
}
|
||
return configured(item.unitPriceMicrocredits);
|
||
}) === true
|
||
);
|
||
}
|
||
|
||
export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) {
|
||
const model = typeof value === "string" ? value.trim() : "";
|
||
if (!normalizeRawModelName(model)) return "";
|
||
const decoded = decodeChannelModel(model);
|
||
if (decoded) {
|
||
const channel = channels.find((item) => item.id === decoded.channelId);
|
||
const resolved = channel?.modelAliases?.[decoded.model] || decoded.model;
|
||
return channel && channel.models.includes(resolved) ? encodeChannelModel(channel.id, resolved) : "";
|
||
}
|
||
const channel = channels.find((item) => item.models.includes(model) || Boolean(item.modelAliases?.[model])) || channels[0];
|
||
const resolved = channel?.modelAliases?.[model] || model;
|
||
return channel && channel.models.includes(resolved) ? encodeChannelModel(channel.id, resolved) : "";
|
||
}
|
||
|
||
export function resolveModelChannel(config: AiConfig, value: string) {
|
||
const decoded = decodeChannelModel(value);
|
||
const model = decoded?.model || value;
|
||
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.includes(model));
|
||
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) });
|
||
}
|
||
|
||
export function logicalModelIDForConfig(config: AiConfig) {
|
||
const channel = resolveModelChannel(config, config.model);
|
||
return channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalModelId || "";
|
||
}
|
||
|
||
export function channelConnectionSignature(channel: ModelChannel) {
|
||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||
}
|
||
|
||
export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||
const channel = resolveModelChannel(config, value);
|
||
const model = modelOptionName(value || config.model);
|
||
const modelProtocol = channel.modelCosts?.find((item) => item.model === model)?.protocol;
|
||
const interfaceType = modelProtocol || channel.interfaceType;
|
||
return {
|
||
...config,
|
||
model,
|
||
baseUrl: channel.baseUrl,
|
||
apiKey: channel.apiKey,
|
||
secretKey: channel.secretKey,
|
||
headers: channel.headers,
|
||
apiFormat: interfaceType ? (interfaceType === "gemini-veo" || interfaceType === "gemini-image" ? ("gemini" as const) : interfaceType === "claude-api" ? ("claude" as const) : ("openai" as const)) : channel.apiFormat,
|
||
interfaceType,
|
||
channelId: channel.scope === "system" ? channel.id : "",
|
||
};
|
||
}
|
||
|
||
function normalizeChannels(config: AiConfig, ensureDefault = true) {
|
||
const persistedChannels = Array.isArray(config.channels) ? config.channels : [];
|
||
const channels = persistedChannels
|
||
.map((channel, index) =>
|
||
createModelChannel({
|
||
...channel,
|
||
id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`),
|
||
name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`),
|
||
models: uniqueRawModels(channel.models || []),
|
||
}),
|
||
)
|
||
.filter((channel) => !isEmptyDefaultChannel(channel));
|
||
if (!channels.length && ensureDefault && config.apiKey.trim()) {
|
||
channels.push(
|
||
createModelChannel({
|
||
id: "default",
|
||
name: "默认渠道",
|
||
baseUrl: config.baseUrl || defaultConfig.baseUrl,
|
||
apiKey: config.apiKey || "",
|
||
apiFormat: config.apiFormat || defaultConfig.apiFormat,
|
||
models: uniqueRawModels([...(config.models || []), config.model, config.imageModel, config.videoModel, config.textModel, config.audioModel]),
|
||
}),
|
||
);
|
||
}
|
||
return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) }));
|
||
}
|
||
|
||
function isEmptyDefaultChannel(channel: ModelChannel) {
|
||
if (channel.scope === "system") return false;
|
||
if (channel.id !== "default" || channel.name.trim() !== "默认渠道" || channel.apiKey.trim()) return false;
|
||
const baseUrl = channel.baseUrl.trim().replace(/\/+$/, "");
|
||
const defaultBaseUrl = defaultConfig.baseUrl.trim().replace(/\/+$/, "");
|
||
if (baseUrl && baseUrl !== defaultBaseUrl) return false;
|
||
// 只清理旧版本写入浏览器的无密钥“默认渠道”和内置模型;没有 API Key 但已填写自定义模型时仍保留,
|
||
// 让用户可以先保存模型目录再补充密钥,而不是把真实自定义配置误判为空。
|
||
return !channel.models.length || channel.models.every((model) => LEGACY_DEFAULT_MODEL_NAMES.has(modelOptionName(model)));
|
||
}
|
||
|
||
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
|
||
return apiFormat === "gemini" ? GEMINI_BASE_URL : OPENAI_BASE_URL;
|
||
}
|
||
|
||
export function defaultBaseUrlForChannelInterface(interfaceType?: ChannelInterfaceType) {
|
||
if (interfaceType === "gemini-veo" || interfaceType === "gemini-image") return GEMINI_BASE_URL;
|
||
if (interfaceType === "novita-video") return "https://api.novita.ai/v3";
|
||
if (interfaceType === "volcengine-ark-agent-plan-image" || interfaceType === "volcengine-ark-agent-plan-video") return "https://ark.cn-beijing.volces.com/api/plan/v3";
|
||
if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-video") return "https://ark.cn-beijing.volces.com/api/v3";
|
||
if (interfaceType === "volcengine-jimeng-image" || interfaceType === "volcengine-jimeng-video") return "https://visual.volcengineapi.com";
|
||
if (interfaceType === "minimax-video") return "https://api.minimaxi.com";
|
||
if (interfaceType === "grok-image" || interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video") return "";
|
||
return OPENAI_BASE_URL;
|
||
}
|
||
|
||
function capabilityForChannelInterface(interfaceType?: ChannelInterfaceType): ModelCapability | undefined {
|
||
return modelProtocolCapability(interfaceType);
|
||
}
|
||
|
||
function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
|
||
return apiFormat === "gemini" || apiFormat === "claude" ? apiFormat : "openai";
|
||
}
|
||
|
||
function normalizeChannelInterfaceType(value: unknown): ChannelInterfaceType | undefined {
|
||
return normalizeModelProtocol(value);
|
||
}
|
||
|
||
function uniqueRawModels(models: string[]) {
|
||
return Array.from(new Set((models || []).map(normalizeRawModelName).filter(Boolean)));
|
||
}
|
||
|
||
function uniqueModelOptions(models: string[]) {
|
||
return Array.from(
|
||
new Set(
|
||
(models || [])
|
||
.filter((model): model is string => typeof model === "string")
|
||
.map((model) => model.trim())
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
}
|
||
|
||
function normalizeRawModelName(value: unknown) {
|
||
if (typeof value !== "string") return "";
|
||
const model = modelOptionName(value).trim();
|
||
return model && model !== "undefined" && model !== "null" ? model : "";
|
||
}
|
||
|
||
export function buildApiUrl(baseUrl: string, path: string) {
|
||
let normalizedBaseUrl = resolveBackendApiUrl(baseUrl).replace(/\/+$/, "");
|
||
normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl);
|
||
const requestPath = path.startsWith("/") ? path : `/${path}`;
|
||
if (isSystemProxyBaseUrl(normalizedBaseUrl)) return `${normalizedBaseUrl}${requestPath}`;
|
||
|
||
const knownPrefixes = ["/api/plan/v3", "/api/v3", "/v1beta", "/v1", "/v2", "/v3"];
|
||
const requestPrefixFor = (value: string) =>
|
||
knownPrefixes.find((prefix) => {
|
||
const lower = value.toLowerCase();
|
||
return lower === prefix || lower.startsWith(`${prefix}/`) || lower.startsWith(`${prefix}?`) || lower.startsWith(`${prefix}#`);
|
||
}) || "";
|
||
const basePrefixFor = (value: string) =>
|
||
knownPrefixes.find((prefix) => {
|
||
const lower = value.toLowerCase();
|
||
return lower.endsWith(prefix) || lower.includes(`${prefix}/`) || lower.includes(`${prefix}?`) || lower.includes(`${prefix}#`);
|
||
}) || "";
|
||
const basePrefix = basePrefixFor(normalizedBaseUrl);
|
||
const requestPrefix = requestPrefixFor(requestPath);
|
||
if (requestPrefix) {
|
||
const root = basePrefix ? normalizedBaseUrl.slice(0, -basePrefix.length) : normalizedBaseUrl;
|
||
return `${root}${requestPath}`;
|
||
}
|
||
return `${normalizedBaseUrl}${basePrefix ? "" : "/v1"}${requestPath}`;
|
||
}
|
||
|
||
export function resolveBackendApiUrl(value: string) {
|
||
const url = value.trim();
|
||
if (!url.startsWith("/api/")) return url;
|
||
const backendBaseUrl = String(import.meta.env.VITE_CANVAS_BACKEND_URL || "/api")
|
||
.trim()
|
||
.replace(/\/+$/, "");
|
||
return backendBaseUrl === "/api" ? url : `${backendBaseUrl}${url.slice("/api".length)}`;
|
||
}
|
||
|
||
export function isSystemProxyBaseUrl(baseUrl: string) {
|
||
return Boolean(systemProxyChannelId(baseUrl));
|
||
}
|
||
|
||
export function systemProxyChannelId(baseUrl: string) {
|
||
const value = baseUrl.trim();
|
||
const lowerValue = value.toLowerCase();
|
||
for (const marker of ["/api/ai/system/", "/api/"]) {
|
||
const index = lowerValue.lastIndexOf(marker);
|
||
if (index < 0) continue;
|
||
const remainder = value.slice(index + marker.length);
|
||
if (/[/?#]/.test(remainder)) continue;
|
||
const channelId = remainder.trim();
|
||
if (channelId && !channelId.includes("\\") && !["v1", "v1beta", "v2", "v3", "plan", "ai"].includes(channelId.toLowerCase())) return channelId;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function normalizeArkPlanBaseUrl(baseUrl: string) {
|
||
try {
|
||
const url = new URL(baseUrl);
|
||
const path = url.pathname.replace(/\/+$/, "");
|
||
const lowerPath = path.toLowerCase();
|
||
const arkPlanIndex = lowerPath.indexOf("/api/plan/v3");
|
||
if (arkPlanIndex < 0) return baseUrl;
|
||
const end = arkPlanIndex + "/api/plan/v3".length;
|
||
if (lowerPath.length !== end && lowerPath[end] !== "/") return baseUrl;
|
||
url.pathname = path.slice(0, end);
|
||
url.search = "";
|
||
url.hash = "";
|
||
return url.toString().replace(/\/+$/, "");
|
||
} catch {
|
||
return baseUrl;
|
||
}
|
||
}
|