diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index ee3f433d5..31988c1bc 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -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) { diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index 186d6c44c..e63c1eb07 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -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 } } +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); diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 051adb5fa..5f750b71f 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -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" ); } diff --git a/components/agents/AgentWorkflowPanelDarkV2.tsx b/components/agents/AgentWorkflowPanelDarkV2.tsx index 1843f4ed3..f609accdc 100644 --- a/components/agents/AgentWorkflowPanelDarkV2.tsx +++ b/components/agents/AgentWorkflowPanelDarkV2.tsx @@ -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) ) ); diff --git a/lib/agents/workflow-preview-images.ts b/lib/agents/workflow-preview-images.ts new file mode 100644 index 000000000..b917ee324 --- /dev/null +++ b/lib/agents/workflow-preview-images.ts @@ -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 { + 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 }; +} diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index 1dbba25be..fa8bc27e2 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -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, diff --git a/scripts/backfill-agent-workflow-preview-images.ts b/scripts/backfill-agent-workflow-preview-images.ts new file mode 100644 index 000000000..98f176be6 --- /dev/null +++ b/scripts/backfill-agent-workflow-preview-images.ts @@ -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); +}); diff --git a/types/agent.ts b/types/agent.ts index fe58ef46a..e59813d61 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -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;