307 lines
8.9 KiB
TypeScript
307 lines
8.9 KiB
TypeScript
import "server-only";
|
|
|
|
import { createHash } from "node:crypto";
|
|
import type { AgentWorkflowOptionUiConfig, AgentWorkflowUiConfig } from "@/types/agent";
|
|
import { uploadFile } from "@/lib/storage/service";
|
|
|
|
const PREVIEW_WIDTH = 230;
|
|
const PREVIEW_QUALITY = 76;
|
|
const FETCH_TIMEOUT_MS = Number(process.env.AGENT_WORKFLOW_PREVIEW_FETCH_TIMEOUT_MS || 15_000);
|
|
const MAX_SOURCE_BYTES = 40 * 1024 * 1024;
|
|
const DEFAULT_CONCURRENCY = 3;
|
|
|
|
export interface AgentWorkflowPreviewImageResult {
|
|
url: string;
|
|
width: number | null;
|
|
height: number | null;
|
|
size: number;
|
|
}
|
|
|
|
export interface AgentWorkflowPreviewOptimizeSummary {
|
|
total: number;
|
|
optimized: number;
|
|
skipped: number;
|
|
failed: number;
|
|
failures: Array<{ optionKey: string; url: string; error: string }>;
|
|
}
|
|
|
|
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
return view.slice().buffer;
|
|
}
|
|
|
|
function normalizeUrl(value: string | null | undefined): string {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function isAbsoluteHttpUrl(value: string): boolean {
|
|
return /^https?:\/\//i.test(value);
|
|
}
|
|
|
|
function isPrivateIpv4(hostname: string): boolean {
|
|
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
|
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return false;
|
|
if (parts[0] === 10 || parts[0] === 127) return true;
|
|
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
return false;
|
|
}
|
|
|
|
function isBlockedHost(hostname: string): boolean {
|
|
const normalized = hostname.trim().toLowerCase();
|
|
if (!normalized) return true;
|
|
if (
|
|
normalized === "localhost" ||
|
|
normalized === "::1" ||
|
|
normalized.endsWith(".local") ||
|
|
normalized.endsWith(".internal")
|
|
) {
|
|
return true;
|
|
}
|
|
if (isPrivateIpv4(normalized)) return true;
|
|
if (normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isUnsupportedSource(url: string): boolean {
|
|
const lower = url.trim().toLowerCase();
|
|
return (
|
|
!lower ||
|
|
lower.startsWith("data:") ||
|
|
lower.startsWith("blob:") ||
|
|
/\.(svg|gif|ico)(?:[?#]|$)/i.test(lower)
|
|
);
|
|
}
|
|
|
|
function isLikelyGeneratedPreview(url: string): boolean {
|
|
try {
|
|
const target = new URL(url);
|
|
return /\.webp$/i.test(target.pathname);
|
|
} catch {
|
|
return /\.webp(?:[?#]|$)/i.test(url);
|
|
}
|
|
}
|
|
|
|
async function fetchImageBuffer(url: string): Promise<{ buffer: Buffer; contentType: string }> {
|
|
let target: URL;
|
|
try {
|
|
target = new URL(url);
|
|
} catch {
|
|
throw new Error("Invalid preview image URL");
|
|
}
|
|
|
|
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
throw new Error("Only http/https preview images are supported");
|
|
}
|
|
|
|
if (isBlockedHost(target.hostname)) {
|
|
throw new Error("Blocked preview image host");
|
|
}
|
|
|
|
const response = await fetch(target.toString(), {
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
headers: {
|
|
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Preview image fetch failed: ${response.status}`);
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
throw new Error("Preview source is not an image");
|
|
}
|
|
|
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_BYTES) {
|
|
throw new Error("Preview source is too large");
|
|
}
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
if (buffer.length > MAX_SOURCE_BYTES) {
|
|
throw new Error("Preview source is too large");
|
|
}
|
|
|
|
return { buffer, contentType };
|
|
}
|
|
|
|
export async function createAgentWorkflowPreviewImageBuffer(
|
|
input: Buffer
|
|
): Promise<{ buffer: Buffer; width: number | null; height: number | null }> {
|
|
const sharp = (await import("sharp")).default;
|
|
const image = sharp(input, { failOn: "none" }).rotate();
|
|
const resized = image.resize({
|
|
width: PREVIEW_WIDTH,
|
|
fit: "inside",
|
|
withoutEnlargement: true,
|
|
});
|
|
const buffer = await resized
|
|
.webp({
|
|
quality: PREVIEW_QUALITY,
|
|
effort: 5,
|
|
smartSubsample: true,
|
|
})
|
|
.toBuffer();
|
|
const metadata = await sharp(buffer, { failOn: "none" }).metadata();
|
|
return {
|
|
buffer,
|
|
width: metadata.width ?? null,
|
|
height: metadata.height ?? null,
|
|
};
|
|
}
|
|
|
|
export async function createAgentWorkflowPreviewImageFromUrl(
|
|
sourceUrl: string
|
|
): Promise<AgentWorkflowPreviewImageResult> {
|
|
const normalizedSourceUrl = normalizeUrl(sourceUrl);
|
|
if (!normalizedSourceUrl || isUnsupportedSource(normalizedSourceUrl)) {
|
|
throw new Error("Unsupported preview source");
|
|
}
|
|
|
|
const { buffer } = await fetchImageBuffer(normalizedSourceUrl);
|
|
const preview = await createAgentWorkflowPreviewImageBuffer(buffer);
|
|
const hash = createHash("sha1").update(normalizedSourceUrl).digest("hex").slice(0, 12);
|
|
const uploaded = await uploadFile(`agent-workflow-preview-${hash}.webp`, toArrayBuffer(preview.buffer));
|
|
|
|
return {
|
|
url: uploaded.url,
|
|
width: preview.width,
|
|
height: preview.height,
|
|
size: preview.buffer.length,
|
|
};
|
|
}
|
|
|
|
function cloneOptionTree(options: AgentWorkflowOptionUiConfig[]): AgentWorkflowOptionUiConfig[] {
|
|
return options.map((option) => ({
|
|
...option,
|
|
previewImageUrl: normalizeUrl(option.previewImageUrl),
|
|
previewImageSourceUrl: normalizeUrl(option.previewImageSourceUrl),
|
|
children: cloneOptionTree(option.children || []),
|
|
}));
|
|
}
|
|
|
|
function collectOptions(
|
|
options: AgentWorkflowOptionUiConfig[],
|
|
output: AgentWorkflowOptionUiConfig[]
|
|
) {
|
|
for (const option of options) {
|
|
output.push(option);
|
|
collectOptions(option.children || [], output);
|
|
}
|
|
}
|
|
|
|
function cloneStepTree(steps: AgentWorkflowUiConfig["steps"]): AgentWorkflowUiConfig["steps"] {
|
|
return (steps || []).map((step) => ({
|
|
...step,
|
|
options: cloneOptionTree(step.options || []),
|
|
children: cloneStepTree(step.children || []),
|
|
}));
|
|
}
|
|
|
|
async function runWithConcurrency<T>(
|
|
items: T[],
|
|
concurrency: number,
|
|
worker: (item: T) => Promise<void>
|
|
) {
|
|
let cursor = 0;
|
|
const workerCount = Math.max(1, Math.min(concurrency, items.length || 1));
|
|
await Promise.all(
|
|
Array.from({ length: workerCount }, async () => {
|
|
for (;;) {
|
|
const index = cursor;
|
|
cursor += 1;
|
|
if (index >= items.length) return;
|
|
await worker(items[index]!);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
function resolveOptimizationSource(
|
|
option: AgentWorkflowOptionUiConfig,
|
|
force: boolean
|
|
): string | null {
|
|
const previewUrl = normalizeUrl(option.previewImageUrl);
|
|
const sourceUrl = normalizeUrl(option.previewImageSourceUrl);
|
|
if (!previewUrl || !isAbsoluteHttpUrl(previewUrl) || isUnsupportedSource(previewUrl)) {
|
|
return null;
|
|
}
|
|
|
|
if (!force) {
|
|
if (sourceUrl && sourceUrl !== previewUrl) {
|
|
return null;
|
|
}
|
|
if (sourceUrl && sourceUrl === previewUrl && isLikelyGeneratedPreview(previewUrl)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return sourceUrl || previewUrl;
|
|
}
|
|
|
|
export async function optimizeAgentWorkflowUiConfigPreviewImages(
|
|
config: AgentWorkflowUiConfig,
|
|
options: { force?: boolean; concurrency?: number; dryRun?: boolean } = {}
|
|
): Promise<{ config: AgentWorkflowUiConfig; summary: AgentWorkflowPreviewOptimizeSummary }> {
|
|
const nextConfig: AgentWorkflowUiConfig = {
|
|
...config,
|
|
steps: cloneStepTree(config.steps || []),
|
|
};
|
|
|
|
const allOptions: AgentWorkflowOptionUiConfig[] = [];
|
|
const collectStepOptions = (steps: AgentWorkflowUiConfig["steps"]) => {
|
|
for (const step of steps || []) {
|
|
collectOptions(step.options || [], allOptions);
|
|
collectStepOptions(step.children || []);
|
|
}
|
|
};
|
|
collectStepOptions(nextConfig.steps);
|
|
|
|
const summary: AgentWorkflowPreviewOptimizeSummary = {
|
|
total: allOptions.length,
|
|
optimized: 0,
|
|
skipped: 0,
|
|
failed: 0,
|
|
failures: [],
|
|
};
|
|
|
|
await runWithConcurrency(
|
|
allOptions,
|
|
options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
async (option) => {
|
|
const sourceUrl = resolveOptimizationSource(option, Boolean(options.force));
|
|
if (!sourceUrl) {
|
|
summary.skipped += 1;
|
|
return;
|
|
}
|
|
|
|
if (options.dryRun) {
|
|
summary.optimized += 1;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const preview = await createAgentWorkflowPreviewImageFromUrl(sourceUrl);
|
|
option.previewImageUrl = preview.url;
|
|
option.previewImageSourceUrl = sourceUrl;
|
|
summary.optimized += 1;
|
|
} catch (error) {
|
|
summary.failed += 1;
|
|
summary.failures.push({
|
|
optionKey: option.key,
|
|
url: sourceUrl,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
summary.skipped = summary.total - summary.optimized - summary.failed;
|
|
return { config: nextConfig, summary };
|
|
}
|