feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)

This commit is contained in:
Ray authored and GitHub committed 2026-09-08 20:56:42 +08:00
1 parent 381edf37e3
commit 8360b2c933
41 files changed
+1485 -148

No files matched your search

+99 -69
View File
@@ -60,16 +60,47 @@ function normalizeWorkflowFieldSourceName(value: unknown, capability?: RunningHu
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",
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),不能按视频分辨率处理。
@@ -135,7 +166,7 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn
// 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" : "");
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);
@@ -164,8 +195,8 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn
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;
? ((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;
@@ -196,8 +227,10 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn
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;
? (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;
@@ -231,11 +264,7 @@ export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown,
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",
];
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;
@@ -263,9 +292,7 @@ export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown,
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);
return schemaFields.length ? mergeWorkflowFieldMappings(workflow.fields, schemaFields, capability) : normalizeWorkflowFieldMappings(workflow.fields, capability);
}
function workflowFieldRange(value: unknown) {
@@ -339,6 +366,8 @@ export const PUBLIC_MODEL_CATALOG_ID = "managed";
export type ModelChannel = {
id: string;
name: string;
publicAlias?: string;
sortOrder?: number;
baseUrl: string;
allowLocalChannel?: boolean;
apiKey: string;
@@ -371,7 +400,7 @@ export type ModelChannel = {
logicalModelId?: string;
logicalCapabilitySpec?: CapabilitySpec;
logicalCapabilityProfiles?: CapabilitySpec[];
logicalPriceTiers?: PublicLogicalModelPriceTier[];
logicalPriceTiers?: PublicLogicalModelPriceTier[];
defaultOptions?: Record<string, unknown>;
}>;
transport?: "backend-channel" | "local-runtime";
@@ -642,36 +671,34 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
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),
};
})
.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 runningHubSelectedKind = persistedRunningHub?.selectedKind ? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind) : normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind);
const persistedComfyBridge = persistedConfig.comfyBridge;
const comfyBridgeCapability = normalizeRunningHubCapability(persistedComfyBridge?.capability, defaultConfig.comfyBridge.capability);
const comfyBridgeWorkflows = Array.isArray(persistedComfyBridge?.workflows)
? persistedComfyBridge.workflows
.filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
.map((item) => {
const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability);
return {
...item,
workflowId: String(item.workflowId || "").trim(),
capability,
fields: normalizeSavedWorkflowFields(item, capability),
};
})
.filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
.map((item) => {
const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability);
return {
...item,
workflowId: String(item.workflowId || "").trim(),
capability,
fields: normalizeSavedWorkflowFields(item, capability),
};
})
: [];
const config = {
...defaultConfig,
@@ -789,6 +816,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
return {
id: channel?.id?.trim() || nanoid(),
name: channel?.name?.trim() || "新渠道",
sortOrder: channel?.sortOrder ?? 0,
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
allowLocalChannel: channel?.allowLocalChannel === true,
apiKey: channel?.apiKey || "",
@@ -864,19 +892,19 @@ 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;
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[]) {
@@ -1020,14 +1048,16 @@ export function buildApiUrl(baseUrl: string, path: string) {
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 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) {