import "server-only"; import { createHash } from "node:crypto"; import type { AgentWorkflowOptionUiConfig, AgentWorkflowUiConfig } from "@/types/agent"; import { uploadFile } from "@/lib/storage/service"; const PREVIEW_WIDTH = 512; const PREVIEW_QUALITY = 68; const 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 { 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( items: T[], concurrency: number, worker: (item: T) => Promise ) { 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 }; }