Files
ttstd-infinite-canvas/web/src/stores/use-config-store.ts

691 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { scopedLocalStorage } from "@/lib/user-scope";
import { useMemo } from "react";
import type { ModelTag } from "@/lib/model-tags";
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { nanoid } from "nanoid";
import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } from "@/lib/model-protocols";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import type { CapabilitySpec, PublicChannelModelAvailability, PublicLogicalModelPriceTier } from "@/services/api/logical-models";
import { type WorkflowFieldMapping, normalizeRunningHubCapability, normalizeRunningHubWorkflowKind, normalizeSavedWorkflowFields, mergeWorkflowFieldMappings, normalizeWorkflowFieldMappings } from "./config-workflow-fields";
import {
decodeChannelModel,
modelOptionName,
modelOptionsFromChannels,
normalizeModelOptionValue,
CHANNEL_MODEL_SEPARATOR,
uniqueModelOptions,
encodeChannelModel,
hasSystemModelPrice,
isChannelModelValue,
modelDisplayName,
modelIcon,
modelOptionLabel,
} from "./config-model-options";
import { normalizeRawModelName } from "./config-model-options";
export { CHANNEL_MODEL_SEPARATOR, normalizeRawModelName, uniqueModelOptions } from "./config-model-options";
export { mergeWorkflowFieldMappings, normalizeRunningHubCapability, normalizeRunningHubWorkflowKind, normalizeWorkflowFieldMappings, type WorkflowFieldMapping } from "./config-workflow-fields";
export { decodeChannelModel, encodeChannelModel, hasSystemModelPrice, isChannelModelValue, modelDisplayName, modelIcon, modelOptionLabel, modelOptionName, modelOptionsFromChannels, normalizeModelOptionValue } from "./config-model-options";
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 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;
proxyUrl?: string;
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>;
availability?: PublicChannelModelAvailability;
available?: boolean;
}>;
};
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;
audioLanguage: string;
audioDialect: string;
audioInstructions: string;
audioEmotionControlMethod: string;
audioEmotionRandom: string;
audioEmotionHappy: string;
audioEmotionAngry: string;
audioEmotionSad: string;
audioEmotionAfraid: string;
audioEmotionDisgusted: string;
audioEmotionMelancholic: string;
audioEmotionSurprised: string;
audioEmotionCalm: 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 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",
audioLanguage: "",
audioDialect: "",
audioInstructions: "",
audioEmotionControlMethod: "与音色参考音频相同",
audioEmotionRandom: "false",
audioEmotionHappy: "0",
audioEmotionAngry: "0",
audioEmotionSad: "0",
audioEmotionAfraid: "0",
audioEmotionDisgusted: "0",
audioEmotionMelancholic: "0",
audioEmotionSurprised: "0",
audioEmotionCalm: "0",
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: "系统模型",
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,
audioLanguage: config.audioLanguage || "",
audioDialect: config.audioDialect || "",
audioInstructions: config.audioInstructions || "",
audioEmotionControlMethod: config.audioEmotionControlMethod || defaultConfig.audioEmotionControlMethod,
audioEmotionRandom: config.audioEmotionRandom || defaultConfig.audioEmotionRandom,
audioEmotionHappy: config.audioEmotionHappy || defaultConfig.audioEmotionHappy,
audioEmotionAngry: config.audioEmotionAngry || defaultConfig.audioEmotionAngry,
audioEmotionSad: config.audioEmotionSad || defaultConfig.audioEmotionSad,
audioEmotionAfraid: config.audioEmotionAfraid || defaultConfig.audioEmotionAfraid,
audioEmotionDisgusted: config.audioEmotionDisgusted || defaultConfig.audioEmotionDisgusted,
audioEmotionMelancholic: config.audioEmotionMelancholic || defaultConfig.audioEmotionMelancholic,
audioEmotionSurprised: config.audioEmotionSurprised || defaultConfig.audioEmotionSurprised,
audioEmotionCalm: config.audioEmotionCalm || defaultConfig.audioEmotionCalm,
// 旧版全局 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 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)));
}
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;
}
}