feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
/** 异步阶段只准备结果;提交时按节点身份合并,绝不能用旧文档覆盖当前文档。 */
|
||||
export function commitCanvasGenerationResult(current: CanvasNodeData[], before: CanvasNodeData, result: CanvasNodeData, taskId: string): CanvasNodeData[] {
|
||||
export function commitCanvasGenerationResult(current: CanvasNodeData[], before: CanvasNodeData, result: CanvasNodeData, taskId: string, additionalNodes: CanvasNodeData[] = []): CanvasNodeData[] {
|
||||
const live = current.find((node) => node.id === before.id);
|
||||
if (!live) throw new Error("生成结果对应节点已删除,结果仍保留在任务中心");
|
||||
if (result.id !== before.id || (live.metadata?.taskId && live.metadata.taskId !== taskId) || (before.metadata?.taskId && !live.metadata?.taskId)) {
|
||||
@@ -24,9 +24,12 @@ export function commitCanvasGenerationResult(current: CanvasNodeData[], before:
|
||||
metadata,
|
||||
};
|
||||
const versionRootId = merged.metadata?.versionOfNodeId;
|
||||
return current.map((node) => {
|
||||
const committed = current.map((node) => {
|
||||
if (node.id === merged.id) return versionRootId ? { ...merged, metadata: { ...merged.metadata, versionPrimary: true } } : merged;
|
||||
if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node;
|
||||
return { ...node, metadata: { ...node.metadata, versionPrimary: false } };
|
||||
});
|
||||
// 同一任务的图片子节点使用稳定 ID;重放只补缺失项,不覆盖用户已移动、改名的图片。
|
||||
const existingIds = new Set(current.map((node) => node.id));
|
||||
return [...committed, ...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } }))];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { imageGenerationChildPosition } from "@/lib/canvas/canvas-generation-layout";
|
||||
import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
|
||||
import { storeGeneratedAudio } from "@/services/api/audio";
|
||||
@@ -122,13 +123,13 @@ export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: C
|
||||
}, fallbackModel);
|
||||
}
|
||||
|
||||
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
|
||||
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node], outputIndex = 0): Promise<CanvasNodeData> {
|
||||
const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image");
|
||||
const prompt = node.metadata?.prompt || task.prompt;
|
||||
const result = parseBackendGenerationResult(task);
|
||||
|
||||
if (mode === "image") {
|
||||
const image = result.images?.[0];
|
||||
const image = result.images?.[outputIndex];
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
let resultDataUrl = image.dataUrl;
|
||||
const emotionEdit = node.metadata?.emotionEdit;
|
||||
@@ -159,7 +160,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
|
||||
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }, task.model),
|
||||
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task), generationOutputCount: 1 }, task.model),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,34 +234,40 @@ async function cacheGeneratedRemoteVideo(result: GeneratedVideoResult & { storag
|
||||
export async function applyGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) {
|
||||
const node = findGenerationTaskNode(nodes, task, targetNodeId);
|
||||
if (!node) return { nodes, updated: false, nodeId: "", node: null };
|
||||
const updatedNode = await buildGenerationTaskNodeResult(node, task, nodes);
|
||||
const { node: updatedNode, additionalNodes } = await buildGenerationTaskNodeResults(node, task, nodes);
|
||||
return {
|
||||
nodes: applySuccessfulVersionSelection(nodes, updatedNode),
|
||||
nodes: commitCanvasGenerationResult(nodes, node, updatedNode, task.id, additionalNodes),
|
||||
updated: true,
|
||||
nodeId: node.id,
|
||||
node: updatedNode,
|
||||
additionalNodes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyMaterializedGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, output: GenerationTaskOutput, effectKey: string, targetNodeId?: string) {
|
||||
const node = findGenerationTaskNode(nodes, task, targetNodeId);
|
||||
if (!node) return { nodes, updated: false, nodeId: "", node: null };
|
||||
if (generationEffectApplied(node.metadata || {}, effectKey)) {
|
||||
return { nodes, updated: true, nodeId: node.id, node };
|
||||
if (generationEffectApplied(node.metadata || {}, effectKey) && generationTaskOutputsApplied(node, task)) {
|
||||
return { nodes, updated: true, nodeId: node.id, node, additionalNodes: [] };
|
||||
}
|
||||
const asset = useAssetStore.getState().assets.find((candidate) => candidate.id === output.materializedAssetId);
|
||||
if (!asset) throw new Error("生成任务输出素材不存在");
|
||||
const result = parseBackendGenerationResult(task);
|
||||
if (asset.kind === "image") {
|
||||
const images = [...(result.images || [])];
|
||||
images[output.outputIndex] = {
|
||||
dataUrl: asset.data.dataUrl || asset.coverUrl,
|
||||
storageKey: asset.data.storageKey,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
};
|
||||
// materialize 已保存整个任务;画布按真实输出序号接收每张图及其素材绑定。
|
||||
for (const item of task.outputs?.length ? task.outputs : [output]) {
|
||||
const imageAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === item.materializedAssetId);
|
||||
if (!imageAsset || imageAsset.kind !== "image") throw new Error("生成任务图片输出素材不存在");
|
||||
images[item.outputIndex] = {
|
||||
dataUrl: imageAsset.data.dataUrl || imageAsset.coverUrl,
|
||||
storageKey: imageAsset.data.storageKey,
|
||||
width: imageAsset.data.width,
|
||||
height: imageAsset.data.height,
|
||||
bytes: imageAsset.data.bytes,
|
||||
mimeType: imageAsset.data.mimeType,
|
||||
};
|
||||
}
|
||||
result.images = images;
|
||||
} else if (asset.kind === "video") {
|
||||
result.video = { dataUrl: asset.data.url, ...asset.data };
|
||||
@@ -269,28 +276,75 @@ export async function applyMaterializedGenerationTaskResultToNodes(nodes: Canvas
|
||||
} else {
|
||||
throw new Error("生成任务输出素材类型不支持画布节点");
|
||||
}
|
||||
const updatedNode = await buildGenerationTaskNodeResult(node, { ...task, resultJson: JSON.stringify(result) }, nodes);
|
||||
const durableNode = {
|
||||
...updatedNode,
|
||||
metadata: applyGenerationConsumerEffect({ ...updatedNode.metadata, assetId: asset.id }, effectKey, (metadata) => metadata).value,
|
||||
};
|
||||
const { node: updatedNode, additionalNodes } = await buildGenerationTaskNodeResults(node, { ...task, resultJson: JSON.stringify(result) }, nodes);
|
||||
const stamp = (item: CanvasNodeData) => ({ ...item, metadata: applyGenerationConsumerEffect(item.metadata || {}, effectKey, (metadata) => metadata).value });
|
||||
const durableNode = stamp({ ...updatedNode, metadata: { ...updatedNode.metadata, assetId: updatedNode.metadata?.assetId || asset.id } });
|
||||
const durableChildren = additionalNodes.map(stamp);
|
||||
return {
|
||||
nodes: applySuccessfulVersionSelection(nodes, durableNode),
|
||||
nodes: commitCanvasGenerationResult(nodes, node, durableNode, task.id, durableChildren),
|
||||
updated: true,
|
||||
nodeId: node.id,
|
||||
node: durableNode,
|
||||
additionalNodes: durableChildren,
|
||||
};
|
||||
}
|
||||
|
||||
function applySuccessfulVersionSelection(nodes: CanvasNodeData[], updatedNode: CanvasNodeData) {
|
||||
const versionRootId = updatedNode.metadata?.versionOfNodeId;
|
||||
return nodes.map((item) => {
|
||||
if (item.id === updatedNode.id) {
|
||||
return versionRootId ? { ...updatedNode, metadata: { ...updatedNode.metadata, versionPrimary: true } } : updatedNode;
|
||||
}
|
||||
if (!versionRootId || (item.metadata?.versionOfNodeId || item.id) !== versionRootId) return item;
|
||||
return { ...item, metadata: { ...item.metadata, versionPrimary: false } };
|
||||
});
|
||||
export function generationTaskOutputsApplied(node: CanvasNodeData, task: GenerationTask) {
|
||||
if (node.metadata?.taskId !== task.id || node.metadata.status !== "success" || !node.metadata.content) return false;
|
||||
if (generationTaskMode(task) !== "image") return true;
|
||||
const count = parseBackendGenerationResult(task).images?.length || 1;
|
||||
return node.metadata.generationOutputCount === count || (count > 1 && (node.metadata.batchChildIds?.length || 0) >= count);
|
||||
}
|
||||
|
||||
export function shouldRecoverCanvasImageOutputs(node: CanvasNodeData) {
|
||||
return node.type === CanvasNodeType.Image && node.metadata?.status === "success" && Boolean(node.metadata.taskId)
|
||||
&& node.metadata.generationOutputCount === undefined && !node.metadata.isBatchRoot && !node.metadata.batchRootId
|
||||
&& /(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || "");
|
||||
}
|
||||
|
||||
async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[]) {
|
||||
const resultNode = await buildGenerationTaskNodeResult(node, task, nodes);
|
||||
const images = generationTaskMode(task) === "image" ? parseBackendGenerationResult(task).images || [] : [];
|
||||
if (images.length <= 1) return { node: resultNode, additionalNodes: [] as CanvasNodeData[] };
|
||||
const children: CanvasNodeData[] = [];
|
||||
for (let index = 0; index < images.length; index += 1) {
|
||||
const id = `${node.id}:task:${task.id}:image:${index}`;
|
||||
const existing = nodes.find((item) => item.id === id);
|
||||
if (existing) { children.push(existing); continue; }
|
||||
const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
|
||||
children.push({
|
||||
...child,
|
||||
position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index),
|
||||
metadata: {
|
||||
...child.metadata,
|
||||
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
|
||||
isBatchRoot: undefined, batchChildIds: undefined, primaryImageId: undefined, imageBatchExpanded: undefined,
|
||||
batchRootId: node.id, versionOfNodeId: undefined, versionLabel: undefined, versionPrimary: undefined,
|
||||
agentGenerationContinuation: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
const primary = children.find((child) => child.id === node.metadata?.primaryImageId) || children[0];
|
||||
return {
|
||||
node: {
|
||||
...resultNode,
|
||||
metadata: {
|
||||
...resultNode.metadata,
|
||||
...imageMetadata({
|
||||
url: primary.metadata!.content!, storageKey: primary.metadata!.storageKey!,
|
||||
width: primary.metadata!.naturalWidth!, height: primary.metadata!.naturalHeight!,
|
||||
bytes: primary.metadata!.bytes || 0, mimeType: primary.metadata!.mimeType || "image/png",
|
||||
}),
|
||||
assetId: primary.metadata?.assetId,
|
||||
isBatchRoot: true,
|
||||
batchChildIds: children.map((child) => child.id),
|
||||
primaryImageId: primary.id,
|
||||
imageBatchExpanded: node.metadata?.imageBatchExpanded ?? false,
|
||||
generationOutputCount: images.length,
|
||||
},
|
||||
},
|
||||
additionalNodes: children,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
|
||||
@@ -303,11 +357,11 @@ export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
|
||||
if (!project) return false;
|
||||
const node = findGenerationTaskNode(project.nodes, task);
|
||||
if (!node) return false;
|
||||
if (node.metadata?.taskId === task.id && node.metadata.status === "success" && node.metadata.content) return false;
|
||||
const updatedNode = await buildGenerationTaskNodeResult(node, task, project.nodes);
|
||||
if (generationTaskOutputsApplied(node, task)) return false;
|
||||
const { node: updatedNode, additionalNodes } = await buildGenerationTaskNodeResults(node, task, project.nodes);
|
||||
const latest = useCanvasStore.getState().projects.find((item) => item.id === project.id);
|
||||
if (!latest?.nodes.some((item) => item.id === node.id)) return false;
|
||||
useCanvasStore.getState().updateProject(project.id, { nodes: commitCanvasGenerationResult(latest.nodes, node, updatedNode, task.id) });
|
||||
useCanvasStore.getState().updateProject(project.id, { nodes: commitCanvasGenerationResult(latest.nodes, node, updatedNode, task.id, additionalNodes) });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
|
||||
const loading = children.some((node) => node.metadata?.status === "loading");
|
||||
const failed = children.find((node) => node.metadata?.status === "error");
|
||||
// 批次父节点只是子任务的投影,不能保留上一轮单图任务的身份/终态。
|
||||
const metadata: CanvasNodeMetadata = resetGenerationTaskMetadata(root.metadata, root.metadata?.status);
|
||||
const metadata: CanvasNodeMetadata = root.metadata.generationOutputCount ? { ...root.metadata } : resetGenerationTaskMetadata(root.metadata, root.metadata?.status);
|
||||
metadata.batchFailedCount = children.filter((node) => node.metadata?.status === "error").length;
|
||||
|
||||
if (primary) {
|
||||
|
||||
@@ -11,6 +11,7 @@ function snapshotMetadata(metadata: CanvasNodeMetadata | undefined, rootId: stri
|
||||
delete snapshot.taskStage;
|
||||
delete snapshot.taskCreatedAt;
|
||||
delete snapshot.taskUpdatedAt;
|
||||
delete snapshot.generationOutputCount;
|
||||
delete snapshot.errorDetails;
|
||||
delete snapshot.generationErrorCode;
|
||||
delete snapshot.failedPromptFingerprint;
|
||||
|
||||
@@ -478,6 +478,7 @@ export function applyBatchPrimaryImage(root: CanvasNodeData, primary: CanvasNode
|
||||
primaryImageId: primary.id,
|
||||
content: primary.metadata?.content,
|
||||
storageKey: primary.metadata?.storageKey,
|
||||
assetId: primary.metadata?.assetId,
|
||||
status: primary.metadata?.status,
|
||||
naturalWidth: primary.metadata?.naturalWidth,
|
||||
naturalHeight: primary.metadata?.naturalHeight,
|
||||
|
||||
@@ -34,5 +34,6 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
|
||||
delete next.taskReceiptRecorded;
|
||||
delete next.taskCreatedAt;
|
||||
delete next.taskUpdatedAt;
|
||||
delete next.generationOutputCount;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
|
||||
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
import { imagePresetForRatio } from "./image-size-presets";
|
||||
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
|
||||
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
|
||||
|
||||
export { workflowFieldChoiceValues, workflowFieldConfigurationError, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldPresetOptions, workflowFieldRandomKey, workflowFieldRole, workflowFieldSafeToOverride, workflowFieldSource, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoDefaultSize, workflowVideoFieldsFromJson, type WorkflowFieldNumberBounds, type WorkflowVideoFieldLike } from "./model-capabilities-workflow";
|
||||
|
||||
@@ -91,12 +93,15 @@ export type VideoCapabilityConfig = {
|
||||
defaultRatio: string;
|
||||
resolutions: string[];
|
||||
defaultResolution: string;
|
||||
fixedScreenSpec?: VideoScreenSpecConfig;
|
||||
generateAudio: { supported: boolean; default: boolean };
|
||||
watermark: { supported: boolean; default: boolean };
|
||||
operations: string[];
|
||||
defaultOperation: string;
|
||||
};
|
||||
|
||||
export type VideoScreenSpecConfig = Pick<VideoCapabilityConfig, "ratios" | "defaultRatio" | "resolutions" | "defaultResolution">;
|
||||
|
||||
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
|
||||
export const STANDARD_IMAGE_SIZE_VALUES = [
|
||||
"1:1",
|
||||
@@ -148,7 +153,7 @@ export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): M
|
||||
}
|
||||
: undefined,
|
||||
video: config.video
|
||||
? {
|
||||
? resolveWorkflowVideoScreenSpec({
|
||||
...config.video,
|
||||
ratios: normalizeCapabilityStrings(config.video.ratios),
|
||||
defaultRatio: normalizeCapabilityString(config.video.defaultRatio),
|
||||
@@ -156,7 +161,7 @@ export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): M
|
||||
defaultResolution: normalizeCapabilityString(config.video.defaultResolution),
|
||||
operations: normalizeCapabilityStrings(config.video.operations),
|
||||
defaultOperation: normalizeCapabilityString(config.video.defaultOperation),
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -215,6 +220,37 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
outputFormat: { supported: true },
|
||||
maxOutputs: 15,
|
||||
};
|
||||
if (protocol === "kacang-midjourney-special" || protocol === "kacang-midjourney-v7" || protocol === "kacang-midjourney") {
|
||||
const stable = protocol === "kacang-midjourney";
|
||||
const extendedRatios = stable || protocol === "kacang-midjourney-v7";
|
||||
const ratios = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9", ...(extendedRatios ? ["9:21"] : [])];
|
||||
// 卡藏未声明分辨率档。1K 预设只用于宿主比例选择,插件不发送 resolution。
|
||||
image.references = { ...image.references, maxImages: stable ? 5 : 1, maskSupported: false };
|
||||
image.size = { parameter: "aspect_ratio", values: [...(extendedRatios ? ["auto"] : []), ...ratios], default: stable ? "9:16" : "16:9", allowCustom: false, presets: ratios.map((ratio) => ({ ...imagePresetForRatio("1k", ratio), ratio })) };
|
||||
// 稳定版的数值 quality 通过插件命名空间设置,避免与宿主分辨率档混用。
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: false };
|
||||
image.outputFormat = { supported: false };
|
||||
image.maxOutputs = 1;
|
||||
return image;
|
||||
}
|
||||
if (protocol === "cangyuan-midjourney-v7" || protocol === "cangyuan-midjourney-v82") {
|
||||
const v7 = protocol === "cangyuan-midjourney-v7";
|
||||
const tier = !v7 && model.trim().toLowerCase().replace(/^models\//, "") === "midjourney-2k" ? "2k" : "1k";
|
||||
const ratios = v7
|
||||
? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"]
|
||||
: ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
|
||||
image.references = { ...image.references, promptMaxChars: v7 ? 4000 : 32000, maxImages: v7 ? 5 : 1, maskSupported: !v7 };
|
||||
image.size = { parameter: "aspect_ratio", values: ["auto", ...ratios], default: "auto", allowCustom: false, presets: ratios.map((ratio) => imagePresetForRatio(tier, ratio)) };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: false };
|
||||
image.outputFormat = { supported: false };
|
||||
// n=1 是一次提交;供应商返回的四张图片仍全部消费。
|
||||
image.maxOutputs = 1;
|
||||
return image;
|
||||
}
|
||||
if (protocol === "grok-image") {
|
||||
image.references.maxImages = 1;
|
||||
image.references.maskSupported = false;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type ModelProtocol = string;
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>; defaults?: Record<string, string | number | boolean> };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; workflows?: ModelProtocolWorkflow[] };
|
||||
export type ModelProtocolParameter = { name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string };
|
||||
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: ModelProtocolParameter[]; defaults?: Record<string, string | number | boolean> };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; parameters?: ModelProtocolParameter[]; workflows?: ModelProtocolWorkflow[] };
|
||||
|
||||
export function protocolGroups(protocols: ModelProtocolDefinition[]) {
|
||||
return (["text", "image", "video", "audio"] as ProtocolCapability[]).map((capability) => ({ label: { text: "文本", image: "图片", video: "视频", audio: "音频" }[capability], options: protocols.filter((item) => item.capability === capability && item.enabled !== false).map((item) => ({ label: `${item.label} · ${item.create.replace(/^POST /, "")}`, value: item.value })) }));
|
||||
|
||||
49
web/src/lib/update-announcement.ts
Normal file
49
web/src/lib/update-announcement.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type UpdateBlock = {
|
||||
id: string;
|
||||
type: "text" | "image" | "video";
|
||||
text: string;
|
||||
resourceId: string;
|
||||
caption: string;
|
||||
layout: "full" | "half";
|
||||
mediaUrl?: string;
|
||||
};
|
||||
export type UpdateRelease = { id: string; version: string; title: string; date: string; blocks: UpdateBlock[] };
|
||||
export type UpdateAnnouncement = { enabled: boolean; currentVersion: string; releases: UpdateRelease[] };
|
||||
export const DEFAULT_UPDATE_ANNOUNCEMENT: UpdateAnnouncement = { enabled: false, currentVersion: "", releases: [] };
|
||||
|
||||
export function normalizeUpdateAnnouncement(value?: UpdateAnnouncement | null): UpdateAnnouncement {
|
||||
return { ...DEFAULT_UPDATE_ANNOUNCEMENT, ...value, releases: Array.isArray(value?.releases) ? value.releases : [] };
|
||||
}
|
||||
|
||||
export function updateAnnouncementVersion(value: UpdateAnnouncement | undefined, builtInVersion: string) {
|
||||
return value?.enabled ? value.currentVersion : `v${builtInVersion.replace(/^v/, "")}`;
|
||||
}
|
||||
|
||||
export function moveUpdateItem<T>(items: T[], index: number, direction: -1 | 1): T[] {
|
||||
const destination = index + direction;
|
||||
if (index < 0 || index >= items.length || destination < 0 || destination >= items.length) return items;
|
||||
const result = [...items];
|
||||
[result[index], result[destination]] = [result[destination], result[index]];
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateUpdateAnnouncement(value: UpdateAnnouncement): string | undefined {
|
||||
if (value.enabled && (!value.currentVersion.trim() || !value.releases.length)) return "开启自定义公告前请填写当前版本并添加公告内容";
|
||||
if (value.enabled && !value.releases.some((release) => release.version.trim() === value.currentVersion.trim())) return "当前版本须对应已添加的公告版本";
|
||||
if (value.releases.length > 30) return "最多添加 30 个公告版本";
|
||||
const versions = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const release of value.releases) {
|
||||
const version = release.version.trim();
|
||||
if (!version || Array.from(version).length > 40) return "每个公告版本号须为 1 至 40 个字符";
|
||||
if (versions.has(version)) return "公告版本号不能重复";
|
||||
versions.add(version);
|
||||
if (!release.blocks.length || release.blocks.length > 50) return "每个版本须添加 1 至 50 个内容块";
|
||||
for (const block of release.blocks) {
|
||||
if (block.type === "text" && !block.text.trim()) return "请填写文字内容,或删除空白内容块";
|
||||
if (block.type !== "text" && !block.resourceId) return "请上传图片或视频,或删除空白内容块";
|
||||
totalBytes += new TextEncoder().encode(block.text.trim() + block.caption.trim()).length;
|
||||
}
|
||||
}
|
||||
if (totalBytes > 200000) return "公告内容总大小不能超过 200KB";
|
||||
}
|
||||
61
web/src/lib/video-screen-specs.ts
Normal file
61
web/src/lib/video-screen-specs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { VideoCapabilityConfig, VideoScreenSpecConfig } from "./model-capabilities";
|
||||
import type { ModelProtocolWorkflow } from "./model-protocols";
|
||||
|
||||
export function cleanVideoScreenSpecValues(values: readonly string[]) {
|
||||
const seen = new Set<string>();
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => {
|
||||
const key = value.toLowerCase();
|
||||
if (!value || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function workflowVideoScreenSpec(workflow: ModelProtocolWorkflow): VideoScreenSpecConfig {
|
||||
const resolution = workflow.parameters.find((field) => field.mapping === "resolution" || field.name === "resolution");
|
||||
return {
|
||||
ratios: [],
|
||||
defaultRatio: "",
|
||||
resolutions: cleanVideoScreenSpecValues(resolution?.values || []),
|
||||
defaultResolution: String(resolution ? workflow.defaults?.[resolution.name] || "" : "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkflowVideoScreenSpec(profile: VideoCapabilityConfig, workflow?: ModelProtocolWorkflow): VideoCapabilityConfig {
|
||||
const fixed = workflow ? workflowVideoScreenSpec(workflow) : profile.fixedScreenSpec;
|
||||
if (!fixed) return profile;
|
||||
const previous = profile.fixedScreenSpec;
|
||||
const workflowChanged = workflow && previous && (previous.defaultResolution !== fixed.defaultResolution || previous.resolutions.length !== fixed.resolutions.length || previous.resolutions.some((value, index) => value !== fixed.resolutions[index]));
|
||||
const initializing = workflow && !profile.fixedScreenSpec;
|
||||
const available = new Map(fixed.resolutions.map((value) => [value.toLowerCase(), value]));
|
||||
const configured = cleanVideoScreenSpecValues(profile.resolutions).map((value) => available.get(value.toLowerCase()) || value);
|
||||
const initialValues = configured.filter((value) => available.has(value.toLowerCase()));
|
||||
const source = workflowChanged || (initializing && !initialValues.length) ? fixed : profile;
|
||||
const resolutions = source === fixed ? fixed.resolutions : initializing ? initialValues : configured;
|
||||
const defaultResolution = source.defaultResolution.trim();
|
||||
const selected: VideoScreenSpecConfig = {
|
||||
ratios: [],
|
||||
defaultRatio: "",
|
||||
resolutions,
|
||||
defaultResolution: resolutions.find((value) => value.toLowerCase() === defaultResolution.toLowerCase()) || (initializing ? resolutions[0] || "" : defaultResolution),
|
||||
};
|
||||
return {
|
||||
...profile,
|
||||
...selected,
|
||||
fixedScreenSpec: fixed,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateWorkflowVideoScreenSpec(profile: VideoCapabilityConfig, workflow: ModelProtocolWorkflow | undefined, patch: Partial<VideoScreenSpecConfig>): VideoCapabilityConfig {
|
||||
const resolved = resolveWorkflowVideoScreenSpec(profile, workflow);
|
||||
if (!resolved.fixedScreenSpec) return profile;
|
||||
return resolveWorkflowVideoScreenSpec(
|
||||
{
|
||||
...resolved,
|
||||
...patch,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user