ai-chatbot/lib/agents/workflow-preview-images.ts

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 };
}