fix: optimize workflow prompt preview images
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-14 17:15:03 +08:00
parent 90e56b7dde
commit 591f462136
8 changed files with 495 additions and 8 deletions

View File

@ -579,6 +579,7 @@ function createWorkflowOptionDraft(
buttonName: `选项${optionIndex + 1}`,
description: "",
previewImageUrl: "",
previewImageSourceUrl: "",
enabled: true,
sortOrder: optionIndex,
promptFragment: `请按选项${optionIndex + 1}执行。`,
@ -3621,6 +3622,7 @@ export function AgentWorkflowSettingsClient({
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "agent-workflow-preview");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
@ -3631,7 +3633,9 @@ export function AgentWorkflowSettingsClient({
updateOption(stepKey, optionKey, (option) => ({
...option,
previewImageUrl: result.url,
previewImageUrl: result.previewUrl || result.thumbnailUrl || result.url,
previewImageSourceUrl:
result.previewImageSourceUrl || result.originalUrl || result.previewUrl || result.url,
}));
toast.success("预览图上传成功");
} catch (error) {

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { uploadFile } from "@/lib/storage/service";
@ -83,6 +84,11 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer>
}
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
return view.slice().buffer;
}
// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
file: z
@ -165,6 +171,54 @@ export async function POST(request: Request) {
console.log("[Upload API] File buffer size:", fileBuffer.byteLength);
try {
if (uploadPurpose === "agent-workflow-preview" && file.type !== "image/svg+xml") {
const preview = await createAgentWorkflowPreviewImageBuffer(Buffer.from(fileBuffer));
const previewResult = await uploadFile(
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
toArrayBuffer(preview.buffer)
);
let assetId: string | null = null;
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "agent_workflow_preview_upload",
status: "ready",
originalUrl: previewResult.url,
previewUrl: previewResult.url,
thumbnailUrl: previewResult.url,
mimeType: "image/webp",
width: preview.width,
height: preview.height,
displayName: filename,
sourceTable: "agent_workflow_preview_upload",
metadata: {
pathname: previewResult.pathname,
originalSize: fileBuffer.byteLength,
previewSize: preview.buffer.length,
purpose: uploadPurpose,
},
});
assetId = asset?.id ?? null;
} catch (assetError) {
console.warn("[Upload API] Failed to register workflow preview asset:", assetError);
}
return NextResponse.json({
...previewResult,
url: previewResult.url,
previewUrl: previewResult.url,
thumbnailUrl: previewResult.url,
originalUrl: previewResult.url,
previewImageSourceUrl: previewResult.url,
assetId,
width: preview.width,
height: preview.height,
optimizedFor: uploadPurpose,
});
}
// ✅ 使用Sharp优化图片(保持原格式)
let optimizedBuffer: Buffer = Buffer.from(fileBuffer);

View File

@ -38,6 +38,7 @@ import {
normalizeAgentWorkflowStepOrdering,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import { optimizeAgentWorkflowUiConfigPreviewImages } from "@/lib/agents/workflow-preview-images";
import {
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
defaultAgentWorkflowBrandModelConfig,
@ -1340,6 +1341,7 @@ const agentWorkflowOptionSchema: z.ZodTypeAny = z.lazy(() =>
buttonName: z.string().trim().min(1).max(64),
description: z.string().trim().max(300).nullable().optional(),
previewImageUrl: z.string().trim().max(1000).nullable().optional(),
previewImageSourceUrl: z.string().trim().max(1000).nullable().optional(),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
promptFragment: z
@ -2309,9 +2311,14 @@ export async function PUT(request: NextRequest) {
if (agentWorkflowUiConfig !== undefined) {
const normalized = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig);
const { config: optimized, summary } =
await optimizeAgentWorkflowUiConfigPreviewImages(normalized);
if (summary.optimized > 0 || summary.failed > 0) {
console.log("[Site Settings API] Agent workflow preview optimization:", summary);
}
await persistSystemSettingWithHistory(
AGENT_WORKFLOW_UI_CONFIG_KEY,
normalized,
optimized,
"agent-workflow"
);
}
@ -2961,9 +2968,14 @@ export async function PUT(request: NextRequest) {
if (agentWorkflowUiConfig !== undefined) {
const normalized = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig);
const { config: optimized, summary } =
await optimizeAgentWorkflowUiConfigPreviewImages(normalized);
if (summary.optimized > 0 || summary.failed > 0) {
console.log("[Site Settings API] Agent workflow preview optimization:", summary);
}
await persistSystemSettingWithHistory(
AGENT_WORKFLOW_UI_CONFIG_KEY,
normalized,
optimized,
"agent-workflow"
);
}

View File

@ -111,14 +111,28 @@ const VIRTUAL_OPTION_GRID_GAP = 16;
const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640;
const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122;
const VIRTUAL_OPTION_GRID_THRESHOLD = 12;
const WORKFLOW_OPTION_PREVIEW_WIDTH = 360;
const WORKFLOW_OPTION_PREVIEW_WIDTH = 512;
const WORKFLOW_OPTION_PREVIEW_QUALITY = 62;
const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10;
const WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT = 12;
function buildWorkflowOptionPreviewUrl(url?: string | null) {
function isLikelyStoredWorkflowPreview(url: string, sourceUrl?: string | null) {
const source = String(sourceUrl || "").trim();
if (source && source !== url) {
return true;
}
if (source && source === url && /\.webp(?:[?#]|$)/i.test(url)) {
return true;
}
return /[?&]x-oss-process=/i.test(url);
}
function buildWorkflowOptionPreviewUrl(url?: string | null, sourceUrl?: string | null) {
const source = String(url || "").trim();
if (!source) return "";
if (isLikelyStoredWorkflowPreview(source, sourceUrl)) {
return source;
}
return buildImageProxyUrl(source, {
width: WORKFLOW_OPTION_PREVIEW_WIDTH,
quality: WORKFLOW_OPTION_PREVIEW_QUALITY,
@ -291,6 +305,7 @@ const StepOptionCard = memo(function StepOptionCard({
buttonName: string;
description?: string | null;
previewImageUrl?: string | null;
previewImageSourceUrl?: string | null;
};
step: { title: string };
selected: boolean;
@ -303,8 +318,8 @@ const StepOptionCard = memo(function StepOptionCard({
onSelect(option.key);
}, [onSelect, option.key]);
const previewUrl = useMemo(
() => buildWorkflowOptionPreviewUrl(option.previewImageUrl),
[option.previewImageUrl]
() => buildWorkflowOptionPreviewUrl(option.previewImageUrl, option.previewImageSourceUrl),
[option.previewImageSourceUrl, option.previewImageUrl]
);
const shouldLoadFast = selected || index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT;
@ -1133,7 +1148,9 @@ function AgentWorkflowPanelDarkV2Component({
new Set(
options
.slice(0, WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT)
.map((option) => buildWorkflowOptionPreviewUrl(option.previewImageUrl))
.map((option) =>
buildWorkflowOptionPreviewUrl(option.previewImageUrl, option.previewImageSourceUrl)
)
.filter(Boolean)
)
);

View File

@ -0,0 +1,306 @@
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<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 };
}

View File

@ -149,6 +149,7 @@ function cloneOption(
...option,
description: option.description || "",
previewImageUrl: option.previewImageUrl || "",
previewImageSourceUrl: option.previewImageSourceUrl || "",
promptFragment: option.promptFragment || "",
blockedOptionKeys: Array.from(new Set((option.blockedOptionKeys || []).filter(Boolean))).slice(
0,
@ -205,6 +206,7 @@ function createOption(
buttonName,
description: "",
previewImageUrl: "",
previewImageSourceUrl: "",
enabled: true,
sortOrder,
promptFragment,
@ -999,6 +1001,7 @@ function createSpecialModuleRootOptionFromStep(
buttonName: step.title || step.key,
description: step.description || "",
previewImageUrl: "",
previewImageSourceUrl: "",
enabled: step.enabled,
sortOrder: step.sortOrder,
promptFragment: "",
@ -1459,6 +1462,10 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
description:
normalizeText(optionRecord.description, 300) || defaultOption?.description || "",
previewImageUrl: normalizeText(optionRecord.previewImageUrl, 1000),
previewImageSourceUrl:
normalizeText(optionRecord.previewImageSourceUrl, 1000) ||
defaultOption?.previewImageSourceUrl ||
"",
enabled: normalizeBoolean(optionRecord.enabled, defaultOption?.enabled ?? true),
sortOrder: normalizeNumber(
optionRecord.sortOrder,

View File

@ -0,0 +1,86 @@
import { config as loadEnv } from "dotenv";
import { eq } from "drizzle-orm";
import Module from "node:module";
loadEnv({ path: ".env.local" });
const moduleLoader = Module as typeof Module & {
_load: (request: string, parent: NodeModule | null, isMain: boolean) => unknown;
};
const originalLoad = moduleLoader._load;
moduleLoader._load = function patchedServerOnlyLoad(
this: typeof moduleLoader,
request,
parent,
isMain
) {
if (request === "server-only") {
return {};
}
return originalLoad.call(this, request, parent, isMain);
};
const dryRun = process.argv.includes("--dry-run");
const force = process.argv.includes("--force");
async function main() {
const dbModule = await import("../lib/db");
const schemaModule = await import("../lib/db/schema");
const workflowConfig = await import("../lib/agents/workflow-ui-config");
const previewImages = await import("../lib/agents/workflow-preview-images");
const { db } = dbModule;
const { systemSettings } = schemaModule;
const [setting] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, workflowConfig.AGENT_WORKFLOW_UI_CONFIG_KEY))
.limit(1);
if (!setting?.value) {
console.log("No agent workflow UI config found.");
return;
}
const normalized = workflowConfig.normalizeAgentWorkflowUiConfig(setting.value);
const { config: optimized, summary } =
await previewImages.optimizeAgentWorkflowUiConfigPreviewImages(normalized, {
force,
concurrency: 3,
dryRun,
});
console.log(
JSON.stringify(
{
dryRun,
force,
summary,
},
null,
2
)
);
if (dryRun) {
return;
}
if (summary.optimized > 0) {
await db
.update(systemSettings)
.set({
value: optimized,
updatedAt: new Date(),
})
.where(eq(systemSettings.key, workflowConfig.AGENT_WORKFLOW_UI_CONFIG_KEY));
console.log(`Updated ${summary.optimized} agent workflow preview images.`);
} else {
console.log("No preview image update needed.");
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -84,6 +84,7 @@ export interface AgentWorkflowOptionUiConfig {
buttonName: string;
description?: string | null;
previewImageUrl?: string | null;
previewImageSourceUrl?: string | null;
enabled: boolean;
sortOrder: number;
promptFragment?: string | null;