diff --git a/package.json b/package.json index 65eb24962..881febf87 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "smoke:routes": "node scripts/smoke-routes.mjs", "verify:release": "node scripts/verify-release.mjs", "backfill:agent-preview-images": "pnpm exec tsx scripts/backfill-agent-workflow-preview-images.ts", + "rewrite:agent-preview-image-urls": "pnpm exec tsx scripts/rewrite-agent-workflow-preview-image-urls.ts", "format": "pnpm exec ultracite fix", "db:generate": "drizzle-kit generate", "db:migrate": "npx tsx lib/db/migrate.ts", diff --git a/scripts/rewrite-agent-workflow-preview-image-urls.ts b/scripts/rewrite-agent-workflow-preview-image-urls.ts new file mode 100644 index 000000000..f81ff98b4 --- /dev/null +++ b/scripts/rewrite-agent-workflow-preview-image-urls.ts @@ -0,0 +1,191 @@ +import { config as loadEnv } from "dotenv"; +import { eq } from "drizzle-orm"; +import { mkdir, writeFile } from "node:fs/promises"; +import Module from "node:module"; +import path from "node:path"; + +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 PREVIEW_WIDTH = 230; +const PREVIEW_QUALITY = 76; +const dryRun = process.argv.includes("--dry-run"); + +function normalizeUrl(value: unknown): string { + return String(value || "").trim(); +} + +function isAbsoluteHttpUrl(value: string) { + return /^https?:\/\//i.test(value); +} + +function isUnsupportedPreviewSource(value: string) { + return !value || value.startsWith("data:") || value.startsWith("blob:"); +} + +function withoutKnownImageProcess(url: URL) { + const next = new URL(url.toString()); + next.searchParams.delete("x-oss-process"); + const rawSearch = next.search.replace(/^\?/, ""); + if (/^imageView2\//i.test(rawSearch)) { + next.search = ""; + } + return next; +} + +function buildOptimizedPreviewUrl(source: string): string | null { + const normalized = normalizeUrl(source); + if (!isAbsoluteHttpUrl(normalized) || isUnsupportedPreviewSource(normalized)) { + return null; + } + + let url: URL; + try { + url = new URL(normalized); + } catch { + return null; + } + + const host = url.hostname.toLowerCase(); + const sourceUrl = withoutKnownImageProcess(url); + + if (host.includes("aliyuncs.com") || host.includes("oss-")) { + sourceUrl.searchParams.set( + "x-oss-process", + `image/resize,w_${PREVIEW_WIDTH}/quality,q_${PREVIEW_QUALITY}/format,webp` + ); + return sourceUrl.toString(); + } + + if (host.includes("qiniu") || host.includes("clouddn") || host.includes("qiniucdn")) { + const separator = sourceUrl.search ? "&" : "?"; + return `${sourceUrl.toString()}${separator}imageView2/2/w/${PREVIEW_WIDTH}/q/${PREVIEW_QUALITY}/format/webp`; + } + + return null; +} + +function visitOptions(options: any[], visitor: (option: any) => void) { + for (const option of options || []) { + visitor(option); + visitOptions(option.children || [], visitor); + } +} + +function visitSteps(steps: any[], visitor: (option: any) => void) { + for (const step of steps || []) { + visitOptions(step.options || [], visitor); + visitSteps(step.children || [], visitor); + } +} + +function createBackupPath() { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + return path.join( + process.cwd(), + "backups", + `agent-workflow-ui-config-before-url-rewrite-${timestamp}.json` + ); +} + +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 { 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 nextConfig = structuredClone(normalized); + const summary = { + total: 0, + rewritten: 0, + skipped: 0, + unsupported: 0, + }; + + visitSteps(nextConfig.steps || [], (option) => { + summary.total += 1; + const currentPreview = normalizeUrl(option.previewImageUrl); + const source = normalizeUrl(option.previewImageSourceUrl) || currentPreview; + const optimized = buildOptimizedPreviewUrl(source); + if (!currentPreview || !source) { + summary.skipped += 1; + return; + } + if (!optimized) { + summary.unsupported += 1; + return; + } + if (currentPreview === optimized && option.previewImageSourceUrl === source) { + summary.skipped += 1; + return; + } + option.previewImageUrl = optimized; + option.previewImageSourceUrl = source; + summary.rewritten += 1; + }); + + console.log(JSON.stringify({ dryRun, summary }, null, 2)); + + if (dryRun || summary.rewritten === 0) { + return; + } + + const backupPath = createBackupPath(); + await mkdir(path.dirname(backupPath), { recursive: true }); + await writeFile( + backupPath, + JSON.stringify( + { + key: workflowConfig.AGENT_WORKFLOW_UI_CONFIG_KEY, + backedUpAt: new Date().toISOString(), + value: setting.value, + }, + null, + 2 + ) + ); + console.log(`Backup written: ${backupPath}`); + + await db + .update(systemSettings) + .set({ + value: nextConfig, + updatedAt: new Date(), + }) + .where(eq(systemSettings.key, workflowConfig.AGENT_WORKFLOW_UI_CONFIG_KEY)); + console.log(`Rewritten ${summary.rewritten} agent workflow preview image URLs.`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +