feat(canvas): deliver image previews before background archiving and remove generation caps

This commit is contained in:
134 2026-10-12 08:03:22 +08:00
parent 23ced0b760
commit 3a2ee064d0
32 changed files with 1350 additions and 1171 deletions

View File

@ -153,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues # Home Performance / Queues
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3 # Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000 CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3 EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20 EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000 EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

View File

@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell <LocalAiSectionShell
description={ description="生图任务直接发起,图片先显示,后台转存到存储桶。"
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")} expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue" id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")} onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={ summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
<LocalAiSectionSummary title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
items={[
{
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
tone: "neutral",
},
{
label: `${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒补位`,
tone: "neutral",
},
{ label: "全站生图生效", tone: "enabled" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
全站生图单模型并发与批次间隔
</span>
}
> >
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]"> <p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
<p>
•
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
</p>
<p>
•
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
</p>
<p>
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
</p>
<p>
•
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
</p>
<p>
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
池继续细分。
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
单模型最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
concurrency: e.target.value,
})
)
}
type="number"
value={canvasImageTaskQueueConfig.concurrency}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
下一批排队任务发起间隔(秒)
</Label>
<Input
id="canvasImageTaskBatchDispatchIntervalSeconds"
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
batchDispatchIntervalMs: Math.round(
Number.parseFloat(e.target.value || "0") * 1000
),
})
)
}
step="0.5"
type="number"
value={String(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
)}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
{(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)}{" "}
秒,默认值为{" "}
{(
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
).toFixed(1)}{" "}
秒。
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
</Button>
</div>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 批量处理配置 */} {/* 批量处理配置 */}

View File

@ -8915,9 +8915,7 @@ export function AdminSettingsClient() {
{ {
key: "realtime-canvas", key: "realtime-canvas",
title: "实时与队列", title: "实时与队列",
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${( description: "生图并发不限,任务即时发起",
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒`,
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭", status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral", tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
icon: SlidersHorizontal, icon: SlidersHorizontal,

View File

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const body = await request.json();
if (
!Array.isArray(body.urls) ||
body.urls.length > 100 ||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
) {
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
}
const urls: string[] = [...new Set<string>(body.urls)];
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
const source = imageArchiveTask.sourceUrl;
const rows = await db
.select({
sourceUrl: imageArchiveTask.sourceUrl,
targetUrl: imageArchiveTask.targetUrl,
status: imageArchiveTask.status,
})
.from(imageArchiveTask)
.where(
and(
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
sql`(
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
)`,
),
);
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
const replacements: Record<string, string> = {};
const pending: string[] = [];
for (const url of urls) {
const row = byKey.get(imageArchiveSourceKey(url));
if (row?.targetUrl) replacements[url] = row.targetUrl;
else if (row && row.status !== "abandoned") pending.push(url);
}
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
return NextResponse.json(
{ replacements, pending },
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema'; import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { import {
buildStaleCanvasSaveConflictPayload, buildStaleCanvasSaveConflictPayload,
@ -119,7 +120,8 @@ export async function PUT(
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
} }
const { title, description, thumbnail, canvasData } = body; const { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision; const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType") body && Object.prototype.hasOwnProperty.call(body, "workspaceType")

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { imageArchiveTask } from "@/lib/db/schema";
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
// readable by image nodes and reference-image providers without a session cookie.
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
const { key } = await params;
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
const origin = configured
? new URL(configured).origin
: `http://localhost:${process.env.PORT || "3000"}`;
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
const [task] = await db
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
.from(imageArchiveTask)
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
.limit(1);
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(match[2], "base64"), {
headers: {
"Content-Type": match[1],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
},
});
}

View File

@ -74,6 +74,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
import useSWR from "swr"; import useSWR from "swr";
// 深色主题组件 V2 (新版布局) // 深色主题组件 V2 (新版布局)
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
@ -1906,6 +1907,8 @@ export default function CanvasEditorLegacyPage({
[isVideoMode] [isVideoMode]
); );
useCanvasImageArchives(id);
// Store state // Store state
const projectTitle = useCanvasStore((state) => state.projectTitle); const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport); const viewport = useCanvasStore(canvasSelectors.viewport);

View File

@ -0,0 +1,105 @@
"use client";
import { useEffect } from "react";
import { useCanvasStore } from "@/store/canvas";
import {
collectImageArchiveSources,
type ArchivedImageUrlMap,
} from "@/lib/storage/image-archive-urls";
function preload(url: string, signal: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
const image = new Image();
let done = false;
const finish = (loaded: boolean) => {
if (done) return;
done = true;
clearTimeout(timer);
signal.removeEventListener("abort", cancel);
image.onload = image.onerror = null;
resolve(loaded);
};
const cancel = () => finish(false);
const timer = setTimeout(cancel, 15_000);
signal.addEventListener("abort", cancel, { once: true });
image.onload = () => finish(true);
image.onerror = cancel;
if (signal.aborted) cancel();
else image.src = url;
});
}
export function useCanvasImageArchives(projectId: string) {
useEffect(() => {
const controller = new AbortController();
const checked = new Set<string>();
const unknownChecks = new Map<string, number>();
let timer: ReturnType<typeof setTimeout> | undefined;
let polling = false;
const poll = async () => {
if (polling || controller.signal.aborted || document.visibilityState === "hidden") return;
const state = useCanvasStore.getState();
if (state.projectId !== projectId) return;
const urls = collectImageArchiveSources(state.nodes)
.filter((url) => !checked.has(url))
.slice(0, 100);
if (!urls.length) return;
polling = true;
try {
const response = await fetch(
`/api/canvas/${encodeURIComponent(projectId)}/image-archives`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
signal: controller.signal,
},
);
if (!response.ok) return;
const result = (await response.json()) as {
replacements: ArchivedImageUrlMap;
pending: string[];
};
const pending = new Set(result.pending);
const ready: ArchivedImageUrlMap = {};
await Promise.all(
urls.map(async (url) => {
const target = result.replacements[url];
if (target) {
if (await preload(target, controller.signal)) {
ready[url] = target;
checked.add(url);
checked.add(target);
}
} else if (!pending.has(url)) {
// A direct generation can reach the browser before its first autosave.
// Allow ownership records to catch up before classifying a URL as untracked.
const checks = (unknownChecks.get(url) || 0) + 1;
unknownChecks.set(url, checks);
if (checks >= 10) checked.add(url);
}
}),
);
if (
!controller.signal.aborted &&
useCanvasStore.getState().projectId === projectId &&
Object.keys(ready).length
) {
useCanvasStore.getState().replaceArchivedImageUrls(ready);
}
} catch {
// Temporary network errors keep provider previews and are retried on the next poll.
} finally {
polling = false;
}
};
const tick = () => {
void poll();
timer = setTimeout(tick, 3_000);
};
timer = setTimeout(tick, 1_000);
return () => {
controller.abort();
if (timer) clearTimeout(timer);
};
}, [projectId]);
}

View File

@ -350,14 +350,12 @@ async function uploadImageBuffer(buffer: Buffer, mimeType: string) {
} }
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> { async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "atlascloud" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) { if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl); const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType); return uploadImageBuffer(parsed.buffer, parsed.mimeType);
} }
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "atlascloud",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",

View File

@ -329,15 +329,13 @@ async function uploadImageBuffer(
} }
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> { async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "bananaimage" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) { if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl); const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType); return uploadImageBuffer(parsed.buffer, parsed.mimeType);
} }
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "bananaimage",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",

View File

@ -119,6 +119,9 @@ async function inferImageAspectRatioFromUrl(url: string): Promise<string | null>
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL * 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/ */
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> { async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "gemini-image" });
if (deferred) return deferred;
try { try {
// 解析 base64 数据 // 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64"); const buffer = Buffer.from(base64Data, "base64");

View File

@ -62,6 +62,9 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL * 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/ */
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> { async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "huanke-gemini" });
if (deferred) return deferred;
try { try {
// 解析 base64 数据 // 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64"); const buffer = Buffer.from(base64Data, "base64");

View File

@ -430,6 +430,9 @@ async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage>
} }
async function uploadBase64ToS3(base64Data: string, mimeType: string) { async function uploadBase64ToS3(base64Data: string, mimeType: string) {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "openai-image" });
if (deferred) return deferred;
const buffer = Buffer.from(base64Data, "base64"); const buffer = Buffer.from(base64Data, "base64");
const extension = detectImageExtension("", mimeType); const extension = detectImageExtension("", mimeType);
return uploadGeneratedBuffer( return uploadGeneratedBuffer(

View File

@ -665,6 +665,9 @@ function generateFilename(prefix: string, extension: string) {
} }
async function uploadImageBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> { async function uploadImageBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> {
const mime = extension === "jpg" || extension === "jpeg" ? "image/jpeg" : `image/${extension}`;
const deferred = await createDeferredUploadedImage(`data:${mime};base64,${buffer.toString("base64")}`, { providerType: "snapgen-image" });
if (deferred) return deferred;
const thumbnailBuffer = await createThumbnail(buffer); const thumbnailBuffer = await createThumbnail(buffer);
const originalFilename = generateFilename("snapgen-image-orig", extension); const originalFilename = generateFilename("snapgen-image-orig", extension);

View File

@ -177,6 +177,9 @@ async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage>
} }
async function uploadBase64ToS3(base64Data: string, mimeType: string) { async function uploadBase64ToS3(base64Data: string, mimeType: string) {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "stepfun-image" });
if (deferred) return deferred;
const buffer = Buffer.from(base64Data, "base64"); const buffer = Buffer.from(base64Data, "base64");
const extension = detectImageExtension("", mimeType); const extension = detectImageExtension("", mimeType);
return uploadGeneratedBuffer( return uploadGeneratedBuffer(

View File

@ -491,14 +491,12 @@ async function uploadBuffer(buffer: Buffer, mimeType: string) {
} }
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> { async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "vip98k" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) { if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl); const parsed = parseDataUrl(imageUrl);
return uploadBuffer(parsed.buffer, parsed.mimeType); return uploadBuffer(parsed.buffer, parsed.mimeType);
} }
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "vip98k",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",

View File

@ -269,15 +269,13 @@ async function uploadImageBuffer(
} }
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> { async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "visionary" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) { if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl); const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType); return uploadImageBuffer(parsed.buffer, parsed.mimeType);
} }
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "visionary",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",

View File

@ -428,9 +428,9 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
/** /**
* 将图片 URL 或 base64 上传到 S3,返回缩略图和原图的 URL * 将图片 URL 或 base64 上传到 S3,返回缩略图和原图的 URL
*/ */
async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string; original: string }> { async function uploadImageToS3(imageData: string, deferArchive = true): Promise<{ thumbnail: string; original: string }> {
try { try {
if (/^https?:\/\//i.test(imageData)) { if (deferArchive && /^(?:https?:\/\/|data:image\/)/i.test(imageData)) {
const deferred = await createDeferredUploadedImage(imageData, { const deferred = await createDeferredUploadedImage(imageData, {
providerType: "volcano-vision", providerType: "volcano-vision",
}); });
@ -653,7 +653,7 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
if (imageUrl.startsWith('data:image')) { if (imageUrl.startsWith('data:image')) {
// Base64格式:需要先上传到S3获取URL // Base64格式:需要先上传到S3获取URL
logger.debug("[Volcano Vision] Found Base64 image, uploading to S3..."); logger.debug("[Volcano Vision] Found Base64 image, uploading to S3...");
const uploaded = await uploadImageToS3(imageUrl); const uploaded = await uploadImageToS3(imageUrl, false);
const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original); const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original);
processedImages.push(rewritten); processedImages.push(rewritten);
} else { } else {

View File

@ -1,3 +1,4 @@
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { import {
@ -122,6 +123,7 @@ export async function saveGeneratedImages(
savedOriginalUrls: string[]; savedOriginalUrls: string[];
}> { }> {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
imageMarkdown = await resolveArchivedImageUrls(imageMarkdown);
const matches = [...imageMarkdown.matchAll(imageRegex)]; const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0; let savedImageCount = 0;

View File

@ -1,192 +1,40 @@
import "server-only"; import "server-only";
import { getSystemSetting } from "@/lib/db/queries"; import { normalizeCanvasImageTaskQueueConfig } from "./image-task-config";
import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
type CanvasImageTaskQueueConfig,
normalizeCanvasImageTaskQueueConfig,
} from "./image-task-config";
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000; const stateKey = "__unrestricted_canvas_image_models__";
const RECENT_EXECUTION_SAMPLE_LIMIT = 24; type ModelState = { running: Set<string>; durations: number[] };
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000; function getModels() {
const concurrencyStateKey = "__canvas_image_model_concurrency__";
interface ModelWaiter {
ticketId: string;
ownerKey: string;
resolve: () => void;
}
interface ModelConcurrencyState {
running: Map<string, string>;
waiters: ModelWaiter[];
dispatchTimer: ReturnType<typeof setTimeout> | null;
delayedUntil: number | null;
recentExecutionDurationsMs: number[];
}
interface CanvasImageConcurrencyState {
models: Map<string, ModelConcurrencyState>;
}
let cachedConfig:
| {
expiresAt: number;
value: CanvasImageTaskQueueConfig;
}
| null = null;
function getGlobalConcurrencyState(): CanvasImageConcurrencyState {
const globalState = globalThis as typeof globalThis & { const globalState = globalThis as typeof globalThis & {
[concurrencyStateKey]?: CanvasImageConcurrencyState; [stateKey]?: Map<string, ModelState>;
}; };
return (globalState[stateKey] ??= new Map());
if (!globalState[concurrencyStateKey]) {
globalState[concurrencyStateKey] = {
models: new Map<string, ModelConcurrencyState>(),
};
}
return globalState[concurrencyStateKey] as CanvasImageConcurrencyState;
} }
function getModel(key: string) {
function getOrCreateModelState(modelKey: string) { const models = getModels();
const state = getGlobalConcurrencyState(); const normalized = normalizeCanvasImageModelKey(key);
const existing = state.models.get(modelKey); let state = models.get(normalized);
if (existing) { if (!state) {
return existing; state = { running: new Set(), durations: [] };
models.set(normalized, state);
} }
return state;
const created: ModelConcurrencyState = {
running: new Map<string, string>(),
waiters: [],
dispatchTimer: null,
delayedUntil: null,
recentExecutionDurationsMs: [],
};
state.models.set(modelKey, created);
return created;
} }
function clearModelDispatchTimer(modelState: ModelConcurrencyState) {
if (modelState.dispatchTimer) {
clearTimeout(modelState.dispatchTimer);
modelState.dispatchTimer = null;
}
modelState.delayedUntil = null;
}
function cleanupModelState(modelKey: string) {
const state = getGlobalConcurrencyState();
const modelState = state.models.get(modelKey);
if (!modelState) {
return;
}
if (
modelState.running.size === 0 &&
modelState.waiters.length === 0 &&
modelState.recentExecutionDurationsMs.length === 0
) {
clearModelDispatchTimer(modelState);
state.models.delete(modelKey);
}
}
export function normalizeCanvasImageModelKey(value: unknown) { export function normalizeCanvasImageModelKey(value: unknown) {
if (typeof value !== "string") { return typeof value === "string" && value.trim() ? value.trim() : "__default__";
return "__default__";
}
const normalized = value.trim();
return normalized || "__default__";
} }
export async function getCanvasImageTaskQueueConfig() {
function normalizeCanvasImageOwnerKey(value: unknown) { return normalizeCanvasImageTaskQueueConfig(undefined);
if (typeof value !== "string") {
return "__anonymous__";
}
const normalized = value.trim();
return normalized || "__anonymous__";
} }
export async function getCanvasImageSingleModelConcurrency() {
function getMaxConcurrentPerOwner(maxConcurrent: number) { return 0;
return Math.max(1, Math.ceil(Math.max(1, maxConcurrent) / 2));
} }
export async function getCanvasImageBatchDispatchIntervalMs() {
function getRunningCountForOwner( return 0;
modelState: ModelConcurrencyState,
ownerKey: string
) {
let count = 0;
for (const runningOwnerKey of modelState.running.values()) {
if (runningOwnerKey === ownerKey) {
count += 1;
}
}
return count;
} }
export async function getCanvasImageTaskQueueConfig(): Promise<CanvasImageTaskQueueConfig> {
const now = Date.now();
if (cachedConfig && cachedConfig.expiresAt > now) {
return cachedConfig.value;
}
const envConcurrency = process.env.CANVAS_IMAGE_TASK_CONCURRENCY;
const envBatchDispatchIntervalMs = process.env.CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS;
let config = normalizeCanvasImageTaskQueueConfig(undefined, {
envConcurrency,
envBatchDispatchIntervalMs,
});
try {
const setting = await getSystemSetting({
key: CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
});
if (setting?.value !== undefined && setting.value !== null) {
config = normalizeCanvasImageTaskQueueConfig(setting.value, {
envConcurrency,
envBatchDispatchIntervalMs,
});
}
} catch (error) {
console.warn("[Canvas Image Concurrency] Failed to load DB config, using fallback", {
error: error instanceof Error ? error.message : String(error),
});
}
cachedConfig = {
expiresAt: now + CONCURRENCY_CONFIG_CACHE_MS,
value: {
concurrency: config.concurrency || DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
batchDispatchIntervalMs: Number.isFinite(config.batchDispatchIntervalMs)
? config.batchDispatchIntervalMs
: DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
},
};
return cachedConfig.value;
}
export async function getCanvasImageSingleModelConcurrency(): Promise<number> {
return (await getCanvasImageTaskQueueConfig()).concurrency;
}
export async function getCanvasImageBatchDispatchIntervalMs(): Promise<number> {
return (await getCanvasImageTaskQueueConfig()).batchDispatchIntervalMs;
}
export function getCanvasImageRunningCountForModel(modelKey: string) { export function getCanvasImageRunningCountForModel(modelKey: string) {
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey); return getModels().get(normalizeCanvasImageModelKey(modelKey))?.running.size || 0;
return getGlobalConcurrencyState().models.get(normalizedModelKey)?.running.size || 0;
} }
export type CanvasImageModelQueueStatus = { export type CanvasImageModelQueueStatus = {
status: "pending" | "running" | "succeeded" | "failed"; status: "pending" | "running" | "succeeded" | "failed";
queuePosition: number; queuePosition: number;
@ -197,143 +45,27 @@ export type CanvasImageModelQueueStatus = {
source: "model_queue"; source: "model_queue";
}; };
function getRecentAverageExecutionMs(modelState: ModelConcurrencyState) {
const samples = modelState.recentExecutionDurationsMs.filter(
(value) => Number.isFinite(value) && value > 0
);
if (samples.length === 0) {
return DEFAULT_ESTIMATED_WAIT_MS;
}
const total = samples.reduce((sum, value) => sum + value, 0);
return Math.max(1000, Math.round(total / samples.length));
}
function buildModelQueueSnapshot(params: {
modelState: ModelConcurrencyState;
queuePosition: number;
sameModelPendingCount: number;
sameModelRunningCount: number;
concurrencyLimit: number;
}): CanvasImageModelQueueStatus {
const averageExecutionMs = getRecentAverageExecutionMs(params.modelState);
const estimatedWaitMs =
params.sameModelPendingCount <= 0
? 0
: Math.max(
averageExecutionMs,
Math.ceil((params.queuePosition + 1) / Math.max(1, params.concurrencyLimit)) *
averageExecutionMs
);
return {
status: "pending",
queuePosition: Math.max(0, params.queuePosition),
sameModelPendingCount: Math.max(0, params.sameModelPendingCount),
sameModelRunningCount: Math.max(0, params.sameModelRunningCount),
concurrencyLimit: Math.max(1, params.concurrencyLimit),
estimatedWaitMs,
source: "model_queue",
};
}
export async function getCanvasImageModelQueueStatus(params: { export async function getCanvasImageModelQueueStatus(params: {
modelKey: string; modelKey: string;
}): Promise<CanvasImageModelQueueStatus> { }): Promise<CanvasImageModelQueueStatus> {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey); return {
const { concurrency: maxConcurrent } = await getCanvasImageTaskQueueConfig(); status: "running",
const modelState = queuePosition: 0,
getGlobalConcurrencyState().models.get(normalizedModelKey) || { sameModelPendingCount: 0,
running: new Map<string, string>(), sameModelRunningCount: getCanvasImageRunningCountForModel(params.modelKey),
waiters: [], concurrencyLimit: 0,
dispatchTimer: null, estimatedWaitMs: 0,
delayedUntil: null, source: "model_queue",
recentExecutionDurationsMs: [], };
}; }
return buildModelQueueSnapshot({ export function recordCanvasImageModelExecutionDuration(modelKey: string, durationMs: number) {
modelState, if (!Number.isFinite(durationMs) || durationMs <= 0) return;
queuePosition: modelState.waiters.length > 0 ? modelState.waiters.length - 1 : 0, const state = getModel(modelKey);
sameModelPendingCount: modelState.waiters.length, state.durations.push(durationMs);
sameModelRunningCount: modelState.running.size, if (state.durations.length > 24) state.durations.shift();
concurrencyLimit: maxConcurrent,
});
}
export function recordCanvasImageModelExecutionDuration(
modelKey: string,
durationMs: number
) {
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return;
}
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
const modelState = getOrCreateModelState(normalizedModelKey);
modelState.recentExecutionDurationsMs.push(durationMs);
if (modelState.recentExecutionDurationsMs.length > RECENT_EXECUTION_SAMPLE_LIMIT) {
modelState.recentExecutionDurationsMs.splice(
0,
modelState.recentExecutionDurationsMs.length - RECENT_EXECUTION_SAMPLE_LIMIT
);
}
}
async function drainModelWaiters(
modelKey: string,
trigger: "arrival" | "slot-release" | "timer"
) {
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
const modelState = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!modelState || modelState.waiters.length === 0) {
cleanupModelState(normalizedModelKey);
return;
}
const { concurrency: maxConcurrent, batchDispatchIntervalMs } =
await getCanvasImageTaskQueueConfig();
if (modelState.running.size >= maxConcurrent) {
return;
}
const now = Date.now();
if (modelState.delayedUntil && modelState.delayedUntil > now) {
return;
}
if (trigger === "slot-release" && batchDispatchIntervalMs > 0) {
if (!modelState.dispatchTimer) {
modelState.delayedUntil = now + batchDispatchIntervalMs;
modelState.dispatchTimer = setTimeout(() => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
clearModelDispatchTimer(state);
void drainModelWaiters(normalizedModelKey, "timer");
}, batchDispatchIntervalMs);
}
return;
}
clearModelDispatchTimer(modelState);
while (modelState.running.size < maxConcurrent && modelState.waiters.length > 0) {
const maxConcurrentPerOwner = getMaxConcurrentPerOwner(maxConcurrent);
const waiterIndex = modelState.waiters.findIndex(
(item) =>
getRunningCountForOwner(modelState, item.ownerKey) < maxConcurrentPerOwner
);
if (waiterIndex < 0) {
break;
}
const [waiter] = modelState.waiters.splice(waiterIndex, 1);
if (!waiter) {
break;
}
modelState.running.set(waiter.ticketId, waiter.ownerKey);
waiter.resolve();
}
cleanupModelState(normalizedModelKey);
} }
// Keep execution tracking/release semantics, without model or owner admission limits.
export async function acquireCanvasImageModelSlot(params: { export async function acquireCanvasImageModelSlot(params: {
modelKey: string; modelKey: string;
ownerKey?: string; ownerKey?: string;
@ -341,86 +73,15 @@ export async function acquireCanvasImageModelSlot(params: {
debugMeta?: Record<string, unknown>; debugMeta?: Record<string, unknown>;
onQueued?: (status: CanvasImageModelQueueStatus) => void | Promise<void>; onQueued?: (status: CanvasImageModelQueueStatus) => void | Promise<void>;
}) { }) {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey); const state = getModel(params.modelKey);
const normalizedOwnerKey = normalizeCanvasImageOwnerKey(params.ownerKey); const ticket = crypto.randomUUID();
const { state.running.add(ticket);
concurrency: maxConcurrent,
batchDispatchIntervalMs,
} = await getCanvasImageTaskQueueConfig();
const modelState = getOrCreateModelState(normalizedModelKey);
const ticketId = `${normalizedModelKey}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`;
const waitingSince = Date.now();
const hasActiveDelay =
modelState.delayedUntil !== null && modelState.delayedUntil > waitingSince;
const maxConcurrentPerOwner = getMaxConcurrentPerOwner(maxConcurrent);
if (
modelState.waiters.length === 0 &&
!hasActiveDelay &&
modelState.running.size < maxConcurrent &&
getRunningCountForOwner(modelState, normalizedOwnerKey) < maxConcurrentPerOwner
) {
modelState.running.set(ticketId, normalizedOwnerKey);
return {
release: async () => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey, "slot-release");
},
waitMs: 0,
maxConcurrent,
batchDispatchIntervalMs,
};
}
await new Promise<void>((resolve) => {
const queuePosition = modelState.waiters.length;
modelState.waiters.push({
ticketId,
ownerKey: normalizedOwnerKey,
resolve,
});
if (typeof params.onQueued === "function") {
const queueStatus = buildModelQueueSnapshot({
modelState,
queuePosition,
sameModelPendingCount: modelState.waiters.length,
sameModelRunningCount: modelState.running.size,
concurrencyLimit: maxConcurrent,
});
Promise.resolve(params.onQueued(queueStatus)).catch((error) => {
console.warn("[Canvas Image Concurrency] onQueued callback failed:", {
debugLabel: params.debugLabel || "canvas-image",
modelKey: normalizedModelKey,
error: error instanceof Error ? error.message : String(error),
...params.debugMeta,
});
});
}
void drainModelWaiters(normalizedModelKey, "arrival");
});
const waitMs = Date.now() - waitingSince;
if (waitMs > 250) {
console.info("[Canvas Image Concurrency] Waited for model slot:", {
debugLabel: params.debugLabel || "canvas-image",
modelKey: normalizedModelKey,
waitMs,
batchDispatchIntervalMs,
...params.debugMeta,
});
}
return { return {
release: async () => { release: async () => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey); state.running.delete(ticket);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey, "slot-release");
}, },
waitMs, waitMs: 0,
maxConcurrent, maxConcurrent: 0,
batchDispatchIntervalMs, batchDispatchIntervalMs: 0,
}; };
} }

View File

@ -1,130 +1,21 @@
export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config"; export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config";
export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 1; // Zero means unrestricted. Legacy DB/environment caps must not re-enable queueing.
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 3; export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 100; export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
export const MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0; export const MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export const DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0; export const DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 60 * 1000; export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export interface CanvasImageTaskQueueConfig { export interface CanvasImageTaskQueueConfig {
concurrency: number; concurrency: number;
batchDispatchIntervalMs: number; batchDispatchIntervalMs: number;
} }
function clampConcurrency(value: number) {
return Math.min(
MAX_CANVAS_IMAGE_TASK_CONCURRENCY,
Math.max(MIN_CANVAS_IMAGE_TASK_CONCURRENCY, Math.floor(value))
);
}
function clampBatchDispatchIntervalMs(value: number) {
return Math.min(
MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
Math.max(MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS, Math.floor(value))
);
}
function parseConcurrencyCandidate(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return clampConcurrency(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed)) {
return clampConcurrency(parsed);
}
}
return null;
}
function parseBatchDispatchIntervalCandidate(
value: unknown,
options?: { treatAsSeconds?: boolean }
): number | null {
const normalize = (candidate: number) =>
clampBatchDispatchIntervalMs(
options?.treatAsSeconds ? candidate * 1000 : candidate
);
if (typeof value === "number" && Number.isFinite(value)) {
return normalize(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseFloat(value.trim());
if (Number.isFinite(parsed)) {
return normalize(parsed);
}
}
return null;
}
export function normalizeCanvasImageTaskQueueConfig( export function normalizeCanvasImageTaskQueueConfig(
value: unknown, _value: unknown,
options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown } _options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown },
): CanvasImageTaskQueueConfig { ): CanvasImageTaskQueueConfig {
const raw = value && typeof value === "object" && !Array.isArray(value) return { concurrency: 0, batchDispatchIntervalMs: 0 };
? (value as Record<string, unknown>)
: {};
const concurrencyCandidates = [
raw.concurrency,
raw.maxConcurrency,
raw.maxConcurrent,
typeof value === "number" || typeof value === "string" ? value : undefined,
options?.envConcurrency,
];
let concurrency = DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY;
for (const candidate of concurrencyCandidates) {
const parsed = parseConcurrencyCandidate(candidate);
if (parsed !== null) {
concurrency = parsed;
break;
}
}
const intervalCandidates = [
raw.batchDispatchIntervalMs,
raw.dispatchIntervalMs,
raw.batchDispatchDelayMs,
raw.batchDispatchInterval,
raw.dispatchInterval,
options?.envBatchDispatchIntervalMs,
];
const intervalSecondsCandidates = [
raw.batchDispatchIntervalSeconds,
raw.dispatchIntervalSeconds,
];
let batchDispatchIntervalMs = DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS;
for (const candidate of intervalCandidates) {
const parsed = parseBatchDispatchIntervalCandidate(candidate);
if (parsed !== null) {
batchDispatchIntervalMs = parsed;
break;
}
}
if (batchDispatchIntervalMs === DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS) {
for (const candidate of intervalSecondsCandidates) {
const parsed = parseBatchDispatchIntervalCandidate(candidate, {
treatAsSeconds: true,
});
if (parsed !== null) {
batchDispatchIntervalMs = parsed;
break;
}
}
}
return {
concurrency,
batchDispatchIntervalMs,
};
} }

View File

@ -1,10 +1,11 @@
import "server-only"; import "server-only";
import { and, asc, desc, eq, lt, notInArray, sql } from "drizzle-orm"; import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasImageTask } from "@/lib/db/schema"; import { canvasImageTask } from "@/lib/db/schema";
import { import {
getCanvasImageBatchDispatchIntervalMs,
getCanvasImageRunningCountForModel, getCanvasImageRunningCountForModel,
getCanvasImageSingleModelConcurrency, getCanvasImageSingleModelConcurrency,
normalizeCanvasImageModelKey, normalizeCanvasImageModelKey,
@ -70,10 +71,6 @@ interface QueueState {
pendingSet: Set<string>; pendingSet: Set<string>;
running: Map<string, RunningTaskEntry>; running: Map<string, RunningTaskEntry>;
runningByModel: Map<string, Set<string>>; runningByModel: Map<string, Set<string>>;
runningByModelUser: Map<string, Set<string>>;
nextBatchEligibleAt: Map<string, number>;
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
awaitingNextBatch: Set<string>;
lastStaleRunningCleanupAt: number; lastStaleRunningCleanupAt: number;
draining: boolean; draining: boolean;
} }
@ -83,9 +80,6 @@ const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000;
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000; const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000; const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
const PENDING_LOOKAHEAD_LIMIT = 100; const PENDING_LOOKAHEAD_LIMIT = 100;
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
const queueStateKey = "__canvas_image_task_queue_state__"; const queueStateKey = "__canvas_image_task_queue_state__";
function getQueueState(): QueueState { function getQueueState(): QueueState {
@ -98,10 +92,6 @@ function getQueueState(): QueueState {
pendingSet: new Set<string>(), pendingSet: new Set<string>(),
running: new Map<string, RunningTaskEntry>(), running: new Map<string, RunningTaskEntry>(),
runningByModel: new Map<string, Set<string>>(), runningByModel: new Map<string, Set<string>>(),
runningByModelUser: new Map<string, Set<string>>(),
nextBatchEligibleAt: new Map<string, number>(),
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
awaitingNextBatch: new Set<string>(),
lastStaleRunningCleanupAt: 0, lastStaleRunningCleanupAt: 0,
draining: false, draining: false,
}; };
@ -184,14 +174,6 @@ function isStyleProTaskRequest(request: unknown): request is StyleProTaskRequest
); );
} }
function getModelUserKey(modelKey: string, userId: string) {
return `${modelKey}::${userId || "__anonymous__"}`;
}
function getMaxConcurrentPerUser(maxConcurrentPerModel: number) {
return Math.max(1, Math.ceil(Math.max(1, maxConcurrentPerModel) / 2));
}
async function getTaskQueueMeta(taskId: string) { async function getTaskQueueMeta(taskId: string) {
const rows = await db const rows = await db
.select({ .select({
@ -209,27 +191,12 @@ async function getTaskQueueMeta(taskId: string) {
}; };
} }
function getRunningCountForModel(modelKey: string) {
const state = getQueueState();
return state.runningByModel.get(modelKey)?.size || 0;
}
function getRunningCountForModelUser(modelKey: string, userId: string) {
const state = getQueueState();
return state.runningByModelUser.get(getModelUserKey(modelKey, userId))?.size || 0;
}
function markRunningTask(taskId: string, modelKey: string, userId: string) { function markRunningTask(taskId: string, modelKey: string, userId: string) {
const state = getQueueState(); const state = getQueueState();
state.running.set(taskId, { modelKey, userId }); state.running.set(taskId, { modelKey, userId });
const tasks = state.runningByModel.get(modelKey) || new Set<string>(); const tasks = state.runningByModel.get(modelKey) || new Set<string>();
tasks.add(taskId); tasks.add(taskId);
state.runningByModel.set(modelKey, tasks); state.runningByModel.set(modelKey, tasks);
const modelUserKey = getModelUserKey(modelKey, userId);
const userTasks = state.runningByModelUser.get(modelUserKey) || new Set<string>();
userTasks.add(taskId);
state.runningByModelUser.set(modelUserKey, userTasks);
} }
function releaseRunningTask(taskId: string) { function releaseRunningTask(taskId: string) {
@ -240,7 +207,7 @@ function releaseRunningTask(taskId: string) {
} }
state.running.delete(taskId); state.running.delete(taskId);
const { modelKey, userId } = entry; const { modelKey } = entry;
const tasks = state.runningByModel.get(modelKey); const tasks = state.runningByModel.get(modelKey);
if (tasks) { if (tasks) {
tasks.delete(taskId); tasks.delete(taskId);
@ -249,65 +216,9 @@ function releaseRunningTask(taskId: string) {
} }
} }
const modelUserKey = getModelUserKey(modelKey, userId);
const userTasks = state.runningByModelUser.get(modelUserKey);
if (userTasks) {
userTasks.delete(taskId);
if (userTasks.size === 0) {
state.runningByModelUser.delete(modelUserKey);
}
}
return modelKey; return modelKey;
} }
function isModelBatchDispatchDelayed(modelKey: string) {
const state = getQueueState();
const delayedUntil = state.nextBatchEligibleAt.get(modelKey);
if (!delayedUntil) {
return false;
}
if (delayedUntil <= Date.now()) {
state.nextBatchEligibleAt.delete(modelKey);
const timer = state.nextBatchTimers.get(modelKey);
if (timer) {
clearTimeout(timer);
state.nextBatchTimers.delete(modelKey);
}
return false;
}
return true;
}
function markModelAwaitingNextBatch(modelKey: string) {
getQueueState().awaitingNextBatch.add(modelKey);
}
function scheduleModelNextBatch(modelKey: string, delayMs: number) {
const state = getQueueState();
if (delayMs <= 0) {
state.awaitingNextBatch.delete(modelKey);
return false;
}
if (state.nextBatchTimers.has(modelKey)) {
return true;
}
state.awaitingNextBatch.delete(modelKey);
state.nextBatchEligibleAt.set(modelKey, Date.now() + delayMs);
const timer = setTimeout(() => {
const latestState = getQueueState();
latestState.nextBatchTimers.delete(modelKey);
latestState.nextBatchEligibleAt.delete(modelKey);
void drainCanvasImageTaskQueue();
}, delayMs);
state.nextBatchTimers.set(modelKey, timer);
return true;
}
async function enqueueLocalTask( async function enqueueLocalTask(
taskId: string, taskId: string,
meta?: { modelKey?: string; userId?: string } | string meta?: { modelKey?: string; userId?: string } | string
@ -334,42 +245,14 @@ async function enqueueLocalTask(
}); });
state.pendingSet.add(taskId); state.pendingSet.add(taskId);
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
if (
getRunningCountForModel(resolvedModelKey) >= maxConcurrentPerModel ||
getRunningCountForModelUser(resolvedModelKey, resolvedUserId) >=
getMaxConcurrentPerUser(maxConcurrentPerModel)
) {
markModelAwaitingNextBatch(resolvedModelKey);
}
} }
function hasModelCapacity( async function claimNextPendingTask(): Promise<PendingTaskEntry | null> {
modelKey: string,
userId: string,
maxConcurrentPerModel: number
) {
return (
getRunningCountForModel(modelKey) < maxConcurrentPerModel &&
getRunningCountForModelUser(modelKey, userId) <
getMaxConcurrentPerUser(maxConcurrentPerModel)
);
}
async function claimNextPendingTask(
maxConcurrentPerModel: number
): Promise<PendingTaskEntry | null> {
const state = getQueueState(); const state = getQueueState();
for (let index = 0; index < state.pending.length; index += 1) { for (let index = 0; index < state.pending.length; index += 1) {
const entry = state.pending[index]; const entry = state.pending[index];
if (!entry) continue; if (!entry) continue;
if (state.running.has(entry.taskId)) continue; if (state.running.has(entry.taskId)) continue;
if (isModelBatchDispatchDelayed(entry.modelKey)) continue;
if (!hasModelCapacity(entry.modelKey, entry.userId, maxConcurrentPerModel)) {
markModelAwaitingNextBatch(entry.modelKey);
continue;
}
state.pending.splice(index, 1); state.pending.splice(index, 1);
state.pendingSet.delete(entry.taskId); state.pendingSet.delete(entry.taskId);
return entry; return entry;
@ -396,15 +279,7 @@ async function claimNextPendingTask(
if (state.pendingSet.has(row.id) || state.running.has(row.id)) continue; if (state.pendingSet.has(row.id) || state.running.has(row.id)) continue;
const modelKey = normalizeTaskModelKey(row.request); const modelKey = normalizeTaskModelKey(row.request);
if (isModelBatchDispatchDelayed(modelKey)) {
continue;
}
const userId = String(row.userId || "__anonymous__"); const userId = String(row.userId || "__anonymous__");
if (!hasModelCapacity(modelKey, userId, maxConcurrentPerModel)) {
markModelAwaitingNextBatch(modelKey);
continue;
}
return { return {
taskId: row.id, taskId: row.id,
modelKey, modelKey,
@ -553,7 +428,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
.update(canvasImageTask) .update(canvasImageTask)
.set({ .set({
status: "succeeded", status: "succeeded",
result: result as CanvasImageTaskResult, result: await resolveArchivedImageUrls(result) as CanvasImageTaskResult,
error: null, error: null,
completedAt: new Date(), completedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@ -575,7 +450,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running")) and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
); );
} finally { } finally {
const modelKey = releaseRunningTask(entry.taskId); releaseRunningTask(entry.taskId);
let nextBatchTaskId: string | null = null; let nextBatchTaskId: string | null = null;
if (completedBatchRequest?.batchId) { if (completedBatchRequest?.batchId) {
try { try {
@ -592,12 +467,6 @@ async function runSingleTask(entry: PendingTaskEntry) {
}); });
} }
} }
if (modelKey) {
const batchDispatchIntervalMs = await getCanvasImageBatchDispatchIntervalMs();
if (getQueueState().awaitingNextBatch.has(modelKey)) {
scheduleModelNextBatch(modelKey, batchDispatchIntervalMs);
}
}
if (nextBatchTaskId) { if (nextBatchTaskId) {
await enqueueLocalTask(nextBatchTaskId); await enqueueLocalTask(nextBatchTaskId);
} }
@ -612,9 +481,8 @@ export async function drainCanvasImageTaskQueue() {
state.draining = true; state.draining = true;
try { try {
await markStaleDatabaseRunningTasksFailed(); await markStaleDatabaseRunningTasksFailed();
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
while (true) { while (true) {
const nextTask = await claimNextPendingTask(maxConcurrentPerModel); const nextTask = await claimNextPendingTask();
if (!nextTask) break; if (!nextTask) break;
if (state.running.has(nextTask.taskId)) continue; if (state.running.has(nextTask.taskId)) continue;
markRunningTask(nextTask.taskId, nextTask.modelKey, nextTask.userId); markRunningTask(nextTask.taskId, nextTask.modelKey, nextTask.userId);
@ -622,6 +490,11 @@ export async function drainCanvasImageTaskQueue() {
} }
} finally { } finally {
state.draining = false; state.draining = false;
// An arrival can occur while the final database scan is in flight. Dispatch it
// immediately rather than relying on a later status poll to wake the queue.
if (state.pending.some((entry) => !state.running.has(entry.taskId))) {
void drainCanvasImageTaskQueue();
}
} }
} }
@ -718,58 +591,6 @@ export async function createCanvasImageTask(params: {
return task; return task;
} }
async function estimateModelQueueWaitMs(params: {
modelKey: string;
sameModelRunningCount: number;
queuePosition: number;
concurrencyLimit: number;
}) {
const rows = await db
.select({
request: canvasImageTask.request,
startedAt: canvasImageTask.startedAt,
completedAt: canvasImageTask.completedAt,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "succeeded"))
.orderBy(desc(canvasImageTask.completedAt))
.limit(QUEUE_STATUS_RECENT_SAMPLE_LIMIT);
const durations = rows
.filter((row) => normalizeTaskModelKey(row.request) === params.modelKey)
.map((row) => {
if (!row.startedAt || !row.completedAt) return 0;
return row.completedAt.getTime() - row.startedAt.getTime();
})
.filter((value) => Number.isFinite(value) && value > 1000)
.slice(0, 12);
const averageDurationMs =
durations.length > 0
? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length)
: DEFAULT_ESTIMATED_WAIT_MS;
const immediatelyStartable = Math.max(
params.concurrencyLimit - params.sameModelRunningCount,
0
);
if (params.queuePosition < immediatelyStartable) {
return 0;
}
const tasksAheadAfterImmediateSlots = Math.max(
0,
params.queuePosition - immediatelyStartable
);
const waveCount =
Math.floor(
tasksAheadAfterImmediateSlots / Math.max(1, params.concurrencyLimit)
) +
(params.sameModelRunningCount > 0 || immediatelyStartable === 0 ? 1 : 0);
return Math.max(0, waveCount * averageDurationMs);
}
async function buildCanvasImageTaskQueueStatus(task: { async function buildCanvasImageTaskQueueStatus(task: {
id: string; id: string;
status: CanvasImageTaskStatus; status: CanvasImageTaskStatus;
@ -789,51 +610,13 @@ async function buildCanvasImageTaskQueueStatus(task: {
getCanvasImageRunningCountForModel(modelKey) getCanvasImageRunningCountForModel(modelKey)
); );
if (task.status !== "pending") {
return {
status: task.status,
queuePosition: 0,
sameModelPendingCount: 0,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs: 0,
source: "model_queue" as const,
};
}
const pendingRows = await db
.select({
id: canvasImageTask.id,
request: canvasImageTask.request,
createdAt: canvasImageTask.createdAt,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "pending"))
.orderBy(asc(canvasImageTask.createdAt))
.limit(QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT);
const sameModelPendingRows = pendingRows.filter(
(row) => normalizeTaskModelKey(row.request) === modelKey
);
const queuePosition = Math.max(
sameModelPendingRows.findIndex((row) => row.id === task.id),
0
);
const sameModelPendingCount = sameModelPendingRows.length;
const estimatedWaitMs = await estimateModelQueueWaitMs({
modelKey,
sameModelRunningCount,
queuePosition,
concurrencyLimit,
});
return { return {
status: task.status, status: task.status,
queuePosition, queuePosition: 0,
sameModelPendingCount, sameModelPendingCount: 0,
sameModelRunningCount, sameModelRunningCount,
concurrencyLimit, concurrencyLimit,
estimatedWaitMs, estimatedWaitMs: 0,
source: "model_queue" as const, source: "model_queue" as const,
}; };
} }

View File

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { import {
and, and,
@ -1590,6 +1591,7 @@ export async function syncCanvasNodesForProject(params: {
viewport?: { x: number; y: number; zoom: number }; viewport?: { x: number; y: number; zoom: number };
} | null | undefined; } | null | undefined;
}) { }) {
params = { ...params, canvasData: await resolveArchivedImageUrls(params.canvasData) };
const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : []; const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : [];
const assetUrls = Array.from( const assetUrls = Array.from(
new Set( new Set(

View File

@ -3,6 +3,7 @@ import "server-only";
import { archiveTemporaryImage } from "@/lib/storage/image-archive"; import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { import {
getImageArchiveTaskStats, getImageArchiveTaskStats,
recoverStaleImageArchiveTasks,
getImageArchiveTasksToReplace, getImageArchiveTasksToReplace,
getImageArchiveTasksToStore, getImageArchiveTasksToStore,
markImageArchiveTaskArchiving, markImageArchiveTaskArchiving,
@ -26,14 +27,16 @@ export async function runImageArchiveJob() {
const batchSize = getBatchSize(); const batchSize = getBatchSize();
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`); console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
await recoverStaleImageArchiveTasks();
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize }); const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
let storeSucceeded = 0; let storeSucceeded = 0;
let storeFailed = 0; let storeFailed = 0;
for (const task of storeTasks) { const storeOne = async (task: (typeof storeTasks)[number]) => {
try { try {
await markImageArchiveTaskArchiving(task.id); if (!(await markImageArchiveTaskArchiving(task.id))) return;
const archived = await archiveTemporaryImage(task.sourceUrl); const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl);
await markImageArchiveTaskStored({ await markImageArchiveTaskStored({
id: task.id, id: task.id,
targetUrl: archived.url, targetUrl: archived.url,
@ -54,12 +57,13 @@ export async function runImageArchiveJob() {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
} }
} };
const remainingSlots = Math.max(1, batchSize - storeTasks.length); // Bucket work has a small worker pool independent of unrestricted model generation.
const replaceTasks = await getImageArchiveTasksToReplace({ for (let index = 0; index < storeTasks.length; index += 4) {
limit: remainingSlots, await Promise.all(storeTasks.slice(index, index + 4).map(storeOne));
}); }
const replaceTasks = await getImageArchiveTasksToReplace({ limit: 50 });
let replaceSucceeded = 0; let replaceSucceeded = 0;
let replaceFailed = 0; let replaceFailed = 0;
@ -74,7 +78,7 @@ export async function runImageArchiveJob() {
} }
try { try {
await markImageArchiveTaskReplacing(task.id); if (!(await markImageArchiveTaskReplacing(task.id))) continue;
const result = await replaceImageUrlInDatabase(task.sourceUrl, task.targetUrl); const result = await replaceImageUrlInDatabase(task.sourceUrl, task.targetUrl);
await markImageArchiveTaskCompleted({ await markImageArchiveTaskCompleted({
id: task.id, id: task.id,

View File

@ -1,30 +1,34 @@
import "server-only"; import "server-only";
import { and, asc, eq, inArray, lt, lte, sql } from "drizzle-orm"; import { createHash } from "node:crypto";
import {
isGeneratedImageSource,
collectImageArchiveSources,
replaceArchivedImageUrls,
} from "./image-archive-urls";
import { and, asc, eq, inArray, lt, lte, or, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { imageArchiveTask, type ImageArchiveTask } from "@/lib/db/schema"; import { imageArchiveTask, type ImageArchiveTask } from "@/lib/db/schema";
export type ImageArchiveTaskRecord = ImageArchiveTask; export type ImageArchiveTaskRecord = ImageArchiveTask;
const DEFAULT_DISPLAY_HOLD_HOURS = 1;
const MAX_IMAGE_ARCHIVE_ATTEMPTS = 12; const MAX_IMAGE_ARCHIVE_ATTEMPTS = 12;
const ARCHIVE_LEASE_MS = 30 * 60 * 1000;
function getDisplayHoldHours() { // Replace as soon as the durable object exists, regardless of legacy hold settings.
const raw = Number(
process.env.IMAGE_ARCHIVE_DISPLAY_HOLD_HOURS || DEFAULT_DISPLAY_HOLD_HOURS
);
if (!Number.isFinite(raw)) return DEFAULT_DISPLAY_HOLD_HOURS;
return Math.max(0, Math.min(24 * 30, raw));
}
export function getImageArchiveDisplayHoldMs() { export function getImageArchiveDisplayHoldMs() {
return getDisplayHoldHours() * 60 * 60 * 1000; return 0;
} }
function isArchivableHttpUrl(value: string) { export function imageArchiveSourceKey(source: string) {
if (!/^https?:\/\//i.test(value)) return false; if (!source.startsWith("data:")) return source;
if (/^(data|blob):/i.test(value)) return false; const configured =
return true; process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
const origin = configured
? new URL(configured).origin
: `http://localhost:${process.env.PORT || "3000"}`;
const key = createHash("sha256").update(source).digest("hex");
return `${origin}/api/generated-image-preview/${key}`;
} }
export async function enqueueImageArchiveTask(params: { export async function enqueueImageArchiveTask(params: {
@ -34,30 +38,35 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}) { }) {
const sourceUrl = String(params.sourceUrl || "").trim(); const sourceUrl = String(params.sourceUrl || "").trim();
if (!isArchivableHttpUrl(sourceUrl)) return null; if (!isGeneratedImageSource(sourceUrl)) return null;
const now = new Date(); const now = new Date();
const archiveAfterAt = params.archiveAfterAt || now; const archiveAfterAt = params.archiveAfterAt || now;
const replaceAfterAt = const replaceAfterAt =
params.replaceAfterAt || params.replaceAfterAt || new Date(now.getTime() + getImageArchiveDisplayHoldMs());
new Date(now.getTime() + getImageArchiveDisplayHoldMs());
const [task] = await db const [task] = await db
.insert(imageArchiveTask) .insert(imageArchiveTask)
.values({ .values({
sourceUrl, sourceUrl: imageArchiveSourceKey(sourceUrl),
status: "pending", status: "pending",
archiveAfterAt, archiveAfterAt,
replaceAfterAt, replaceAfterAt,
metadata: params.metadata || null, metadata: sourceUrl.startsWith("data:")
? { ...params.metadata, inlineSource: sourceUrl }
: params.metadata || null,
updatedAt: now, updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: imageArchiveTask.sourceUrl, target: imageArchiveTask.sourceUrl,
set: { set: {
replaceAfterAt, replaceAfterAt,
metadata: params.metadata || undefined, metadata: sql`case when ${imageArchiveTask.targetUrl} is not null then ${imageArchiveTask.metadata}
updatedAt: now, else coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify(
sourceUrl.startsWith("data:")
? { ...params.metadata, inlineSource: sourceUrl }
: params.metadata || {},
)}::jsonb end`,
}, },
}) })
.returning(); .returning();
@ -71,10 +80,8 @@ export async function enqueueImageArchiveTasks(params: {
}) { }) {
const urls = Array.from( const urls = Array.from(
new Set( new Set(
(params.urls || []) (params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
.map((url) => String(url || "").trim()) ),
.filter(isArchivableHttpUrl)
)
); );
const tasks: ImageArchiveTaskRecord[] = []; const tasks: ImageArchiveTaskRecord[] = [];
@ -97,15 +104,15 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
and( and(
inArray(imageArchiveTask.status, ["pending", "failed"]), inArray(imageArchiveTask.status, ["pending", "failed"]),
lte(imageArchiveTask.archiveAfterAt, now), lte(imageArchiveTask.archiveAfterAt, now),
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS) lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
) ),
) )
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) .orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit))); .limit(Math.max(1, Math.min(50, params.limit)));
} }
export async function markImageArchiveTaskArchiving(id: string) { export async function markImageArchiveTaskArchiving(id: string) {
await db const claimed = await db
.update(imageArchiveTask) .update(imageArchiveTask)
.set({ .set({
status: "archiving", status: "archiving",
@ -113,7 +120,15 @@ export async function markImageArchiveTaskArchiving(id: string) {
lastError: null, lastError: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(imageArchiveTask.id, id)); .where(
and(
eq(imageArchiveTask.id, id),
inArray(imageArchiveTask.status, ["pending", "failed"]),
lte(imageArchiveTask.archiveAfterAt, new Date()),
),
)
.returning({ id: imageArchiveTask.id });
return claimed.length > 0;
} }
export async function markImageArchiveTaskStored(params: { export async function markImageArchiveTaskStored(params: {
@ -128,18 +143,16 @@ export async function markImageArchiveTaskStored(params: {
targetUrl: params.targetUrl, targetUrl: params.targetUrl,
targetStorageConfigId: params.targetStorageConfigId || null, targetStorageConfigId: params.targetStorageConfigId || null,
archivedAt: new Date(), archivedAt: new Date(),
replaceAfterAt: new Date(),
lastError: null, lastError: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(imageArchiveTask.id, params.id)); .where(eq(imageArchiveTask.id, params.id));
} }
export async function markImageArchiveTaskFailed(params: { export async function markImageArchiveTaskFailed(params: { id: string; error: unknown }) {
id: string;
error: unknown;
}) {
const message = String( const message = String(
params.error instanceof Error ? params.error.message : params.error || "Unknown error" params.error instanceof Error ? params.error.message : params.error || "Unknown error",
).slice(0, 2000); ).slice(0, 2000);
const isPermanentSourceError = /\bHTTP (?:404|410)\b/i.test(message); const isPermanentSourceError = /\bHTTP (?:404|410)\b/i.test(message);
@ -160,29 +173,35 @@ export async function markImageArchiveTaskFailed(params: {
.where(eq(imageArchiveTask.id, params.id)); .where(eq(imageArchiveTask.id, params.id));
} }
function isArchiveReplacementDue(now: Date) {
return and(
or(
eq(imageArchiveTask.status, "stored"),
and(
eq(imageArchiveTask.status, "completed"),
sql`${imageArchiveTask.metadata}->>'replacementPasses' = '1'`,
),
),
lte(imageArchiveTask.replaceAfterAt, now),
);
}
export async function getImageArchiveTasksToReplace(params: { limit: number }) { export async function getImageArchiveTasksToReplace(params: { limit: number }) {
const now = new Date();
return await db return await db
.select() .select()
.from(imageArchiveTask) .from(imageArchiveTask)
.where( .where(isArchiveReplacementDue(new Date()))
and(
eq(imageArchiveTask.status, "stored"),
lte(imageArchiveTask.replaceAfterAt, now)
)
)
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) .orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit))); .limit(Math.max(1, Math.min(50, params.limit)));
} }
export async function markImageArchiveTaskReplacing(id: string) { export async function markImageArchiveTaskReplacing(id: string) {
await db const claimed = await db
.update(imageArchiveTask) .update(imageArchiveTask)
.set({ .set({ status: "replacing", updatedAt: new Date() })
status: "replacing", .where(and(eq(imageArchiveTask.id, id), isArchiveReplacementDue(new Date())))
updatedAt: new Date(), .returning({ id: imageArchiveTask.id });
}) return claimed.length > 0;
.where(eq(imageArchiveTask.id, id));
} }
export async function markImageArchiveTaskCompleted(params: { export async function markImageArchiveTaskCompleted(params: {
@ -195,23 +214,20 @@ export async function markImageArchiveTaskCompleted(params: {
status: "completed", status: "completed",
replacedAt: new Date(), replacedAt: new Date(),
lastError: null, lastError: null,
metadata: // Reconcile once more after delivery has settled: asynchronous generation/task
params.replacedCount === undefined // writes may arrive just after the first replacement. The live URL is already ready.
? undefined replaceAfterAt: new Date(Date.now() + 60_000),
: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify({ metadata: sql`(coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) - 'inlineSource')
replacedCount: params.replacedCount, || jsonb_build_object('replacementPasses', coalesce((${imageArchiveTask.metadata}->>'replacementPasses')::int, 0) + 1)
})}::jsonb`, || ${JSON.stringify(params.replacedCount === undefined ? {} : { replacedCount: params.replacedCount })}::jsonb`,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(imageArchiveTask.id, params.id)); .where(eq(imageArchiveTask.id, params.id));
} }
export async function markImageArchiveTaskReplaceFailed(params: { export async function markImageArchiveTaskReplaceFailed(params: { id: string; error: unknown }) {
id: string;
error: unknown;
}) {
const message = String( const message = String(
params.error instanceof Error ? params.error.message : params.error || "Unknown error" params.error instanceof Error ? params.error.message : params.error || "Unknown error",
).slice(0, 2000); ).slice(0, 2000);
await db await db
@ -230,6 +246,7 @@ export async function getImageArchiveTaskStats() {
.select({ .select({
status: imageArchiveTask.status, status: imageArchiveTask.status,
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
reconciliationCount: sql<number>`count(*) filter (where ${imageArchiveTask.status} = 'completed' and ${imageArchiveTask.metadata}->>'replacementPasses' = '1')::int`,
}) })
.from(imageArchiveTask) .from(imageArchiveTask)
.groupBy(imageArchiveTask.status); .groupBy(imageArchiveTask.status);
@ -237,9 +254,11 @@ export async function getImageArchiveTaskStats() {
const pendingStore = grouped const pendingStore = grouped
.filter((item) => item.status === "pending" || item.status === "failed") .filter((item) => item.status === "pending" || item.status === "failed")
.reduce((sum, item) => sum + (item.count || 0), 0); .reduce((sum, item) => sum + (item.count || 0), 0);
const pendingReplace = grouped const pendingReplace =
.filter((item) => item.status === "stored") grouped
.reduce((sum, item) => sum + (item.count || 0), 0); .filter((item) => item.status === "stored")
.reduce((sum, item) => sum + (item.count || 0), 0) +
grouped.reduce((sum, item) => sum + (item.reconciliationCount || 0), 0);
return { return {
grouped, grouped,
@ -247,3 +266,50 @@ export async function getImageArchiveTaskStats() {
pendingReplace, pendingReplace,
}; };
} }
// Interrupted workers can be claimed again after a lease expires. Failed uploads keep
// their original source and retry time, so transient bucket errors cannot lose images.
export async function recoverStaleImageArchiveTasks() {
const staleBefore = new Date(Date.now() - ARCHIVE_LEASE_MS);
await db
.update(imageArchiveTask)
.set({
status: sql`case when ${imageArchiveTask.attemptCount} >= ${MAX_IMAGE_ARCHIVE_ATTEMPTS} then 'abandoned' else 'failed' end`,
archiveAfterAt: new Date(),
updatedAt: new Date(),
})
.where(
and(eq(imageArchiveTask.status, "archiving"), lt(imageArchiveTask.updatedAt, staleBefore)),
);
await db
.update(imageArchiveTask)
.set({ status: "stored", replaceAfterAt: new Date(), updatedAt: new Date() })
.where(
and(eq(imageArchiveTask.status, "replacing"), lt(imageArchiveTask.updatedAt, staleBefore)),
);
}
export async function getArchivedImageUrlMappings(urls: string[]) {
if (!urls.length) return {};
const sources = [...new Set(urls.filter(isGeneratedImageSource))];
const tasks = await db
.select({ sourceUrl: imageArchiveTask.sourceUrl, targetUrl: imageArchiveTask.targetUrl })
.from(imageArchiveTask)
.where(inArray(imageArchiveTask.sourceUrl, sources.map(imageArchiveSourceKey)));
const targets = new Map(
tasks.filter((task) => task.targetUrl).map((task) => [task.sourceUrl, task.targetUrl!]),
);
const replacements: Record<string, string> = {};
for (const source of sources) {
const target = targets.get(imageArchiveSourceKey(source));
if (target) replacements[source] = target;
}
return replacements;
}
// Normalize late writes too: an archive can finish before a task/canvas save arrives.
export async function resolveArchivedImageUrls<T>(value: T): Promise<T> {
const sources = collectImageArchiveSources(value);
if (!sources.length) return value;
return replaceArchivedImageUrls(value, await getArchivedImageUrlMappings(sources));
}

View File

@ -0,0 +1,56 @@
// Shared by the server and canvas; no storage or network work happens here.
export type ArchivedImageUrlMap = Record<string, string>;
export function isGeneratedImageSource(value: string) {
return (
/^https?:\/\//i.test(value) || /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(value)
);
}
export function collectImageArchiveSources(value: unknown): string[] {
const urls = new Set<string>();
const visit = (item: unknown) => {
if (typeof item === "string") {
if (isGeneratedImageSource(item)) urls.add(item);
else
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
if (isGeneratedImageSource(match[1])) urls.add(match[1]);
}
} else if (Array.isArray(item)) item.forEach(visit);
else if (item && typeof item === "object" && !(item instanceof Date))
Object.values(item).forEach(visit);
};
visit(value);
return [...urls];
}
// Structural sharing preserves unaffected nodes and Date values; no undo step is added.
export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImageUrlMap): T {
if (typeof value === "string") {
if (replacements[value]) return replacements[value] as T;
// URLs inside generated-image Markdown carry both preview and original URLs.
return value.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt: string, url: string) => {
const nextAlt = alt
.split("|||")
.map((part) => replacements[part] || part)
.join("|||");
return `![${nextAlt}](${replacements[url] || url})`;
}) as T;
}
if (!value || typeof value !== "object" || value instanceof Date) return value;
let changed = false;
if (Array.isArray(value)) {
const next = value.map((item) => {
const replaced = replaceArchivedImageUrls(item, replacements);
if (replaced !== item) changed = true;
return replaced;
});
return (changed ? next : value) as T;
}
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
next[key] = replaceArchivedImageUrls(item, replacements);
if (next[key] !== item) changed = true;
}
return (changed ? next : value) as T;
}

View File

@ -0,0 +1,66 @@
import "server-only";
import { after } from "next/server";
const workerKey = "__generated_image_archive_worker__";
type Worker = {
running: boolean;
scheduled: boolean;
requested: boolean;
retryTimer?: ReturnType<typeof setTimeout>;
};
function getWorker(): Worker {
const root = globalThis as typeof globalThis & { [workerKey]?: Worker };
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
}
export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
const worker = getWorker();
worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true;
if (worker.retryTimer) {
clearTimeout(worker.retryTimer);
worker.retryTimer = undefined;
}
const run = async () => {
if (worker.running) return;
worker.scheduled = false;
worker.running = true;
let retryNeeded = false;
try {
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do {
worker.requested = false;
const result = await runImageArchiveJob();
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0;
// Stop when only backoff tasks remain; do not spin while storage is down.
if (!worker.requested && result.store.total === 0 && result.replace.total === 0) break;
} while (true);
} catch (error) {
retryNeeded = true;
console.error("[ImageArchiveWorker] background archive failed", error);
} finally {
worker.running = false;
if (worker.requested) scheduleImageArchiveWorker();
else if (retryNeeded) {
worker.retryTimer = setTimeout(scheduleImageArchiveWorker, 60_000);
worker.retryTimer.unref?.();
}
}
};
// Detached generation tasks may inherit a request that has already closed. Keep
// a Node-server fallback so archiving never depends on the user leaving a tab open.
const deliveryFallback = setTimeout(() => {
if (worker.scheduled && !worker.running) void run();
}, 1_000);
deliveryFallback.unref?.();
try {
// Request handlers deliver first. Durable task rows also allow cron/restart recovery.
after(run);
} catch {
// Durable canvas tasks can run outside Next's request context on the Node server.
setImmediate(() => {
void run();
});
}
}

View File

@ -2,10 +2,13 @@ import "server-only";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import sharp from "sharp"; import sharp from "sharp";
import { isGeneratedImageSource } from "./image-archive-urls";
import { scheduleImageArchiveWorker } from "./image-archive-worker";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { import {
enqueueImageArchiveTasks, enqueueImageArchiveTasks,
getImageArchiveDisplayHoldMs, getImageArchiveDisplayHoldMs,
imageArchiveSourceKey,
} from "@/lib/storage/image-archive-db"; } from "@/lib/storage/image-archive-db";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
@ -25,16 +28,12 @@ function getDownloadTimeoutMs() {
return Math.max(5_000, Math.min(5 * 60_000, Math.round(parsed))); return Math.max(5_000, Math.min(5 * 60_000, Math.round(parsed)));
} }
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(String(value || "").trim());
}
export function isDeferredImageArchiveEnabled() { export function isDeferredImageArchiveEnabled() {
return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false"; return true;
} }
export function isImmediateImageArchiveEnabled() { export function isImmediateImageArchiveEnabled() {
return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false"; return false;
} }
export async function registerGeneratedImageTemporaryUrls(params: { export async function registerGeneratedImageTemporaryUrls(params: {
@ -44,24 +43,24 @@ export async function registerGeneratedImageTemporaryUrls(params: {
if (!isDeferredImageArchiveEnabled()) return []; if (!isDeferredImageArchiveEnabled()) return [];
const urls = Array.from( const urls = Array.from(
new Set( new Set(
(params.urls || []) (params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
.map((url) => String(url || "").trim()) ),
.filter(isHttpUrl)
)
); );
if (urls.length === 0) return []; if (urls.length === 0) return [];
try { try {
return await enqueueImageArchiveTasks({ const tasks = await enqueueImageArchiveTasks({
urls, urls,
metadata: { metadata: {
...(params.metadata || {}), ...(params.metadata || {}),
displayHoldMs: getImageArchiveDisplayHoldMs(), displayHoldMs: getImageArchiveDisplayHoldMs(),
}, },
}); });
scheduleImageArchiveWorker();
return tasks;
} catch (error) { } catch (error) {
logger.warn("enqueue temporary image archive failed", { logger.warn("enqueue temporary image archive failed", {
urls, imageCount: urls.length,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
return []; return [];
@ -77,10 +76,8 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
}) { }) {
const urls = Array.from( const urls = Array.from(
new Set( new Set(
(params.urls || []) (params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
.map((url) => String(url || "").trim()) ),
.filter(isHttpUrl)
)
); );
if (urls.length === 0) { if (urls.length === 0) {
throw new Error("AI 未返回可用图片地址"); throw new Error("AI 未返回可用图片地址");
@ -97,17 +94,17 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
return persisted return persisted
.map( .map(
({ originalUrl, thumbnailUrl }) => ({ originalUrl, thumbnailUrl }) =>
`![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})` `![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})`,
) )
.join("\n\n"); .join("\n\n");
} }
export async function createDeferredUploadedImage( export async function createDeferredUploadedImage(
sourceUrl: string, sourceUrl: string,
metadata?: DeferredImageArchiveMetadata | null metadata?: DeferredImageArchiveMetadata | null,
) { ) {
const url = String(sourceUrl || "").trim(); const url = String(sourceUrl || "").trim();
if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null; if (!isDeferredImageArchiveEnabled() || !isGeneratedImageSource(url)) return null;
const [persisted] = await persistGeneratedImageUrls({ const [persisted] = await persistGeneratedImageUrls({
urls: [url], urls: [url],
@ -124,48 +121,20 @@ async function persistGeneratedImageUrls(params: {
urls: string[]; urls: string[];
metadata?: DeferredImageArchiveMetadata | null; metadata?: DeferredImageArchiveMetadata | null;
}) { }) {
if (!isImmediateImageArchiveEnabled()) { // Only the durable queue insert is awaited. Downloading, thumbnailing and bucket
await registerGeneratedImageTemporaryUrls(params); // uploads run after delivery and never extend the provider response time.
return params.urls.map((url) => ({ await registerGeneratedImageTemporaryUrls(params);
originalUrl: url, return params.urls.map((url) => {
thumbnailUrl: url, const preview = imageArchiveSourceKey(url);
})); return { originalUrl: preview, thumbnailUrl: preview };
} });
return await Promise.all(
params.urls.map(async (url) => {
try {
const archived = await archiveTemporaryImage(url);
logger.info("generated image archived before response", {
providerType: params.metadata?.providerType,
modelId: params.metadata?.modelId,
bytes: archived.bytes,
});
return {
originalUrl: archived.url,
thumbnailUrl: archived.thumbnailUrl || archived.url,
};
} catch (error) {
logger.error("immediate generated image archive failed; queued for retry", {
providerType: params.metadata?.providerType,
modelId: params.metadata?.modelId,
error: error instanceof Error ? error.message : String(error),
});
await registerGeneratedImageTemporaryUrls({
urls: [url],
metadata: params.metadata || null,
});
return {
originalUrl: url,
thumbnailUrl: url,
};
}
})
);
} }
function getImageExtension(contentType: string | null, sourceUrl: string) { function getImageExtension(contentType: string | null, sourceUrl: string) {
const normalized = String(contentType || "").split(";")[0].trim().toLowerCase(); const normalized = String(contentType || "")
.split(";")[0]
.trim()
.toLowerCase();
const byType: Record<string, string> = { const byType: Record<string, string> = {
"image/jpeg": "jpg", "image/jpeg": "jpg",
"image/jpg": "jpg", "image/jpg": "jpg",
@ -178,7 +147,11 @@ function getImageExtension(contentType: string | null, sourceUrl: string) {
try { try {
const pathname = new URL(sourceUrl).pathname.toLowerCase(); const pathname = new URL(sourceUrl).pathname.toLowerCase();
const ext = pathname.split(".").pop()?.replace(/[^a-z0-9]+/g, "") || ""; const ext =
pathname
.split(".")
.pop()
?.replace(/[^a-z0-9]+/g, "") || "";
if (["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) { if (["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) {
return ext === "jpeg" ? "jpg" : ext; return ext === "jpeg" ? "jpg" : ext;
} }
@ -192,14 +165,20 @@ function getImageExtension(contentType: string | null, sourceUrl: string) {
function toArrayBuffer(buffer: Buffer): ArrayBuffer { function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice( return buffer.buffer.slice(
buffer.byteOffset, buffer.byteOffset,
buffer.byteOffset + buffer.byteLength buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer; ) as ArrayBuffer;
} }
export async function downloadTemporaryImage(params: { export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
sourceUrl: string; if (params.sourceUrl.startsWith("data:")) {
retries?: number; const match = params.sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
}) { if (!match) throw new Error("Invalid inline generated image");
return {
buffer: Buffer.from(match[2], "base64"),
contentType: match[1],
extension: getImageExtension(match[1], ""),
};
}
const retries = Math.max(0, Math.min(5, params.retries ?? 2)); const retries = Math.max(0, Math.min(5, params.retries ?? 2));
for (let attempt = 0; attempt <= retries; attempt += 1) { for (let attempt = 0; attempt <= retries; attempt += 1) {
@ -249,7 +228,7 @@ export async function archiveTemporaryImage(sourceUrl: string) {
const thumbnailUploaded = thumbnailBuffer const thumbnailUploaded = thumbnailBuffer
? await uploadFile( ? await uploadFile(
`ai-generated-archive-thumb-${objectSuffix}.webp`, `ai-generated-archive-thumb-${objectSuffix}.webp`,
toArrayBuffer(thumbnailBuffer) toArrayBuffer(thumbnailBuffer),
).catch((error) => { ).catch((error) => {
logger.warn("generated image thumbnail upload failed", { logger.warn("generated image thumbnail upload failed", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),

View File

@ -6,6 +6,7 @@ import {
assetMemorySnapshot, assetMemorySnapshot,
canvasDetailArchive, canvasDetailArchive,
canvasNode, canvasNode,
canvasImageTask,
canvasProject, canvasProject,
conversationWorkingState, conversationWorkingState,
externalImageApiRequestLog, externalImageApiRequestLog,
@ -26,18 +27,14 @@ function normalizeMediaUrl(value: string): string {
const parsed = new URL(canonical); const parsed = new URL(canonical);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase(); return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
} catch { } catch {
return canonical return canonical.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase();
.split("#")[0]
.split("?")[0]
.replace(/\/+$/, "")
.toLowerCase();
} }
} }
export function replaceDeepUrls( export function replaceDeepUrls(
value: unknown, value: unknown,
sourceUrl: string, sourceUrl: string,
targetUrl: string targetUrl: string,
): { value: unknown; changed: boolean } { ): { value: unknown; changed: boolean } {
if (typeof value === "string") { if (typeof value === "string") {
if (!value.includes(sourceUrl)) { if (!value.includes(sourceUrl)) {
@ -70,10 +67,7 @@ export function replaceDeepUrls(
return { value, changed: false }; return { value, changed: false };
} }
export async function replaceImageUrlInDatabase( export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: string) {
sourceUrl: string,
targetUrl: string
) {
if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) { if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) {
return { changed: 0 }; return { changed: 0 };
} }
@ -106,8 +100,8 @@ export async function replaceImageUrlInDatabase(
eq(mediaAsset.thumbnailUrl, sourceUrl), eq(mediaAsset.thumbnailUrl, sourceUrl),
eq(mediaAsset.normalizedUrl, sourceNormalizedUrl), eq(mediaAsset.normalizedUrl, sourceNormalizedUrl),
sql`${mediaAsset.metadata}::text like ${`%${sourceUrl}%`}`, sql`${mediaAsset.metadata}::text like ${`%${sourceUrl}%`}`,
sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}` sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`,
) ),
) )
.limit(500); .limit(500);
@ -117,8 +111,7 @@ export async function replaceImageUrlInDatabase(
const thumbnail = replaceDeepUrls(item.thumbnailUrl, sourceUrl, targetUrl); const thumbnail = replaceDeepUrls(item.thumbnailUrl, sourceUrl, targetUrl);
const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl); const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl);
const summary = replaceDeepUrls(item.summary, sourceUrl, targetUrl); const summary = replaceDeepUrls(item.summary, sourceUrl, targetUrl);
const shouldNormalize = const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl || original.changed;
item.normalizedUrl === sourceNormalizedUrl || original.changed;
const nextOriginal = (original.value as string) || item.originalUrl; const nextOriginal = (original.value as string) || item.originalUrl;
if ( if (
@ -139,8 +132,7 @@ export async function replaceImageUrlInDatabase(
originalUrl: nextOriginal, originalUrl: nextOriginal,
normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl, normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl,
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl, previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
thumbnailUrl: thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
(thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
metadata: (metadata.value ?? item.metadata) as any, metadata: (metadata.value ?? item.metadata) as any,
summary: (summary.value ?? item.summary) as any, summary: (summary.value ?? item.summary) as any,
updatedAt: new Date(), updatedAt: new Date(),
@ -152,8 +144,7 @@ export async function replaceImageUrlInDatabase(
.set({ .set({
originalUrl: nextOriginal, originalUrl: nextOriginal,
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl, previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
thumbnailUrl: thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
(thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
metadata: (metadata.value ?? item.metadata) as any, metadata: (metadata.value ?? item.metadata) as any,
summary: (summary.value ?? item.summary) as any, summary: (summary.value ?? item.summary) as any,
updatedAt: new Date(), updatedAt: new Date(),
@ -170,8 +161,8 @@ export async function replaceImageUrlInDatabase(
or( or(
eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl), eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl),
sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`, sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`,
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`) like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`),
) ),
) )
.limit(500); .limit(500);
@ -203,8 +194,8 @@ export async function replaceImageUrlInDatabase(
.where( .where(
or( or(
sql`${message.parts}::text like ${`%${sourceUrl}%`}`, sql`${message.parts}::text like ${`%${sourceUrl}%`}`,
sql`${message.attachments}::text like ${`%${sourceUrl}%`}` sql`${message.attachments}::text like ${`%${sourceUrl}%`}`,
) ),
) )
.limit(500); .limit(500);
@ -243,120 +234,67 @@ export async function replaceImageUrlInDatabase(
changed += 1; changed += 1;
} }
const escapedSource = JSON.stringify(sourceUrl).slice(1, -1);
const escapedTarget = JSON.stringify(targetUrl).slice(1, -1);
const projects = await db const projects = await db
.select({ .update(canvasProject)
id: canvasProject.id, .set({
canvasData: canvasProject.canvasData, canvasData: sql`replace(${canvasProject.canvasData}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
thumbnail: sql`case when ${canvasProject.thumbnail} = ${sourceUrl} then ${targetUrl} else ${canvasProject.thumbnail} end`,
// URL maintenance is not a content revision; keep autosave conflict detection valid.
}) })
.from(canvasProject)
.where(sql`${canvasProject.canvasData}::text like ${`%${sourceUrl}%`}`)
.limit(500);
for (const item of projects) {
const replaced = replaceDeepUrls(item.canvasData, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(canvasProject)
.set({
canvasData: replaced.value as any,
updatedAt: new Date(),
})
.where(eq(canvasProject.id, item.id));
changed += 1;
}
const nodes = await db
.select({
id: canvasNode.id,
data: canvasNode.data,
})
.from(canvasNode)
.where(sql`${canvasNode.data}::text like ${`%${sourceUrl}%`}`)
.limit(500);
for (const item of nodes) {
const replaced = replaceDeepUrls(item.data, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(canvasNode)
.set({
data: replaced.value as any,
updatedAt: new Date(),
})
.where(eq(canvasNode.id, item.id));
changed += 1;
}
const workingStates = await db
.select()
.from(conversationWorkingState)
.where( .where(
or( or(
sql`${conversationWorkingState.currentFocusImageUrls}::text like ${`%${sourceUrl}%`}`, sql`position(${escapedSource} in ${canvasProject.canvasData}::text) > 0`,
sql`${conversationWorkingState.selectedImageUrls}::text like ${`%${sourceUrl}%`}`, eq(canvasProject.thumbnail, sourceUrl),
sql`${conversationWorkingState.lastUploadedImageUrls}::text like ${`%${sourceUrl}%`}`, ),
sql`${conversationWorkingState.lastGeneratedImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastReferencedImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastResolvedTargetImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastCanvasSnapshot}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastTurnSummary}::text like ${`%${sourceUrl}%`}`
)
) )
.limit(500); .returning({ id: canvasProject.id });
changed += projects.length;
const nodes = await db
.update(canvasNode)
.set({
data: sql`replace(${canvasNode.data}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
})
.where(sql`position(${escapedSource} in ${canvasNode.data}::text) > 0`)
.returning({ id: canvasNode.id });
changed += nodes.length;
const tasks = await db
.update(canvasImageTask)
.set({
result: sql`replace(${canvasImageTask.result}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
})
.where(sql`position(${escapedSource} in ${canvasImageTask.result}::text) > 0`)
.returning({ id: canvasImageTask.id });
changed += tasks.length;
for (const item of workingStates) { const workingStates = await db
const currentFocus = replaceDeepUrls( .update(conversationWorkingState)
item.currentFocusImageUrls, .set({
sourceUrl, currentFocusImageUrls: sql`replace(${conversationWorkingState.currentFocusImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
targetUrl selectedImageUrls: sql`replace(${conversationWorkingState.selectedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
); lastUploadedImageUrls: sql`replace(${conversationWorkingState.lastUploadedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
const selected = replaceDeepUrls(item.selectedImageUrls, sourceUrl, targetUrl); lastGeneratedImageUrls: sql`replace(${conversationWorkingState.lastGeneratedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
const uploaded = replaceDeepUrls(item.lastUploadedImageUrls, sourceUrl, targetUrl); lastReferencedImageUrls: sql`replace(${conversationWorkingState.lastReferencedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
const generated = replaceDeepUrls(item.lastGeneratedImageUrls, sourceUrl, targetUrl); lastResolvedTargetImageUrls: sql`replace(${conversationWorkingState.lastResolvedTargetImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
const referenced = replaceDeepUrls(item.lastReferencedImageUrls, sourceUrl, targetUrl); lastCanvasSnapshot: sql`replace(${conversationWorkingState.lastCanvasSnapshot}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
const resolved = replaceDeepUrls( lastTurnSummary: sql`replace(${conversationWorkingState.lastTurnSummary}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
item.lastResolvedTargetImageUrls, updatedAt: new Date(),
sourceUrl, })
targetUrl .where(
); or(
const canvasSnapshot = replaceDeepUrls( sql`position(${escapedSource} in ${conversationWorkingState.currentFocusImageUrls}::text) > 0`,
item.lastCanvasSnapshot, sql`position(${escapedSource} in ${conversationWorkingState.selectedImageUrls}::text) > 0`,
sourceUrl, sql`position(${escapedSource} in ${conversationWorkingState.lastUploadedImageUrls}::text) > 0`,
targetUrl sql`position(${escapedSource} in ${conversationWorkingState.lastGeneratedImageUrls}::text) > 0`,
); sql`position(${escapedSource} in ${conversationWorkingState.lastReferencedImageUrls}::text) > 0`,
const turnSummary = replaceDeepUrls(item.lastTurnSummary, sourceUrl, targetUrl); sql`position(${escapedSource} in ${conversationWorkingState.lastResolvedTargetImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastCanvasSnapshot}::text) > 0`,
if ( sql`position(${escapedSource} in ${conversationWorkingState.lastTurnSummary}::text) > 0`,
!currentFocus.changed && ),
!selected.changed && )
!uploaded.changed && .returning({ id: conversationWorkingState.id });
!generated.changed && changed += workingStates.length;
!referenced.changed &&
!resolved.changed &&
!canvasSnapshot.changed &&
!turnSummary.changed
) {
continue;
}
await db
.update(conversationWorkingState)
.set({
currentFocusImageUrls: currentFocus.value as any,
selectedImageUrls: selected.value as any,
lastUploadedImageUrls: uploaded.value as any,
lastGeneratedImageUrls: generated.value as any,
lastReferencedImageUrls: referenced.value as any,
lastResolvedTargetImageUrls: resolved.value as any,
lastCanvasSnapshot: (canvasSnapshot.value ?? item.lastCanvasSnapshot) as any,
lastTurnSummary: (turnSummary.value ?? item.lastTurnSummary) as any,
updatedAt: new Date(),
})
.where(eq(conversationWorkingState.id, item.id));
changed += 1;
}
const detailArchives = await db const detailArchives = await db
.select({ .select({
@ -374,8 +312,8 @@ export async function replaceImageUrlInDatabase(
sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`, sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`, sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.uiSurfaceSnapshot}::text like ${`%${sourceUrl}%`}`, sql`${canvasDetailArchive.uiSurfaceSnapshot}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}` sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`,
) ),
) )
.limit(500); .limit(500);
@ -416,8 +354,8 @@ export async function replaceImageUrlInDatabase(
.where( .where(
or( or(
like(model3dAsset.previewImageUrl, `%${sourceUrl}%`), like(model3dAsset.previewImageUrl, `%${sourceUrl}%`),
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}` sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`,
) ),
) )
.limit(500); .limit(500);
@ -429,8 +367,7 @@ export async function replaceImageUrlInDatabase(
await db await db
.update(model3dAsset) .update(model3dAsset)
.set({ .set({
previewImageUrl: previewImageUrl: (preview.value as string | null | undefined) ?? item.previewImageUrl,
(preview.value as string | null | undefined) ?? item.previewImageUrl,
assetPayload: (payload.value ?? item.assetPayload) as any, assetPayload: (payload.value ?? item.assetPayload) as any,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@ -444,22 +381,14 @@ export async function replaceImageUrlInDatabase(
.where( .where(
or( or(
sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`, sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`,
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}` sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`,
) ),
) )
.limit(500); .limit(500);
for (const item of apiLogs) { for (const item of apiLogs) {
const responsePayload = replaceDeepUrls( const responsePayload = replaceDeepUrls(item.responsePayload, sourceUrl, targetUrl);
item.responsePayload, const generatedUrls = replaceDeepUrls(item.generatedImageUrls, sourceUrl, targetUrl);
sourceUrl,
targetUrl
);
const generatedUrls = replaceDeepUrls(
item.generatedImageUrls,
sourceUrl,
targetUrl
);
if (!responsePayload.changed && !generatedUrls.changed) continue; if (!responsePayload.changed && !generatedUrls.changed) continue;
await db await db

View File

@ -1,3 +1,4 @@
import { replaceArchivedImageUrls, type ArchivedImageUrlMap } from "@/lib/storage/image-archive-urls";
import { create } from 'zustand'; import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer'; import { immer } from 'zustand/middleware/immer';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
@ -161,6 +162,7 @@ interface CanvasStore {
// 节点操作 // 节点操作
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string; addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
updateNode: (id: string, updates: Partial<CanvasNode>) => void; updateNode: (id: string, updates: Partial<CanvasNode>) => void;
replaceArchivedImageUrls: (replacements: ArchivedImageUrlMap) => void;
deleteNode: (id: string) => void; deleteNode: (id: string) => void;
deleteSelectedNodes: () => void; deleteSelectedNodes: () => void;
addSuppressedMediaKeys: (keys: string[]) => void; addSuppressedMediaKeys: (keys: string[]) => void;
@ -410,6 +412,19 @@ export const useCanvasStore = create<CanvasStore>()(
} }
}), }),
replaceArchivedImageUrls: (replacements) => set((state) => {
state.nodes = replaceArchivedImageUrls(state.nodes, replacements);
state.history = replaceArchivedImageUrls(state.history, replacements);
state.styleProState = replaceArchivedImageUrls(state.styleProState, replacements);
state.pendingChatAttachments = replaceArchivedImageUrls(state.pendingChatAttachments, replacements);
state.pendingPickedObjects = replaceArchivedImageUrls(state.pendingPickedObjects, replacements);
state.pickedObject = replaceArchivedImageUrls(state.pickedObject, replacements);
state.imageNodeActionRequest = replaceArchivedImageUrls(state.imageNodeActionRequest, replacements);
state.imageNodeActionQueue = replaceArchivedImageUrls(state.imageNodeActionQueue, replacements);
state.canvasImageSelectionSession = replaceArchivedImageUrls(state.canvasImageSelectionSession, replacements);
state.suppressedMediaKeys = replaceArchivedImageUrls(state.suppressedMediaKeys, replacements);
}),
addSuppressedMediaKeys: (keys) => set((state) => { addSuppressedMediaKeys: (keys) => set((state) => {
const normalized = Array.from( const normalized = Array.from(
new Set((keys || []).map((item) => String(item || "").trim()).filter(Boolean)) new Set((keys || []).map((item) => String(item || "").trim()).filter(Boolean))

View File

@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./routes",
testMatch: /image-archive-flow\.test\.ts/,
workers: 1,
reporter: "list",
timeout: 30_000,
});

View File

@ -0,0 +1,626 @@
import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import vm from "node:vm";
import * as crypto from "node:crypto";
import ts from "typescript";
import {
collectImageArchiveSources,
replaceArchivedImageUrls,
} from "@/lib/storage/image-archive-urls";
import { normalizeCanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { ImageNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/store/canvas";
const root = path.resolve(__dirname, "../..");
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=",
"base64",
);
const raw = "https://provider.example/result.png?signature=keep-me";
const permanent = "https://bucket.example/result.png";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
// Execute the actual server modules with isolated DB/storage adapters: no provider
// requests, production DB connections, or paid generations are made by these tests.
function serverLoader(mocks: Record<string, unknown>, env: Record<string, string> = {}) {
const modules = new Map<string, { exports: any }>();
const runtimeGlobal = {};
const load = (relative: string): any => {
const filename = path.resolve(root, relative);
if (modules.has(filename)) return modules.get(filename)!.exports;
const module = { exports: {} };
modules.set(filename, module);
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
},
}).outputText;
const nativeRequire = createRequire(filename);
const requireMock = (name: string) => {
if (name === "server-only") return {};
if (Object.prototype.hasOwnProperty.call(mocks, name)) return mocks[name];
if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`);
if (name.startsWith("."))
return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`)));
return nativeRequire(name);
};
const context = vm.createContext({
module,
exports: module.exports,
require: requireMock,
globalThis: runtimeGlobal,
process: { env },
Buffer,
URL,
Response,
crypto,
setTimeout,
clearTimeout,
setImmediate,
AbortSignal,
console: { log() {}, warn() {}, error() {}, info() {} },
fetch:
mocks.fetch ||
(() => {
throw new Error("Unexpected network call");
}),
});
new vm.Script(source, { filename }).runInContext(context);
return module.exports;
};
return load;
}
const loggerMock = { createLogger: () => ({ info() {}, warn() {}, error() {} }) };
test("160 requests from the same user and model start without admission waiting", async () => {
const module = serverLoader({})("lib/canvas/image-concurrency.ts");
let queued = 0;
const slots = await Promise.all(
Array.from({ length: 160 }, () =>
module.acquireCanvasImageModelSlot({
modelKey: "same-model",
ownerKey: "same-user",
onQueued: () => {
queued += 1;
},
}),
),
);
expect(module.getCanvasImageRunningCountForModel("same-model")).toBe(160);
expect(queued).toBe(0);
expect(slots.every((slot: any) => slot.waitMs === 0 && slot.maxConcurrent === 0)).toBe(true);
await Promise.all(slots.map((slot: any) => slot.release()));
await slots[0].release();
expect(module.getCanvasImageRunningCountForModel("same-model")).toBe(0);
expect(
(await module.getCanvasImageModelQueueStatus({ modelKey: "same-model" })).estimatedWaitMs,
).toBe(0);
expect(
normalizeCanvasImageTaskQueueConfig(
{ concurrency: 3, batchDispatchIntervalMs: 1000 },
{ envConcurrency: "2" },
),
).toEqual({ concurrency: 0, batchDispatchIntervalMs: 0 });
});
test("provider preview resolves while a bucket upload is still blocked", async () => {
const bucket = deferred<any>();
let uploadCalls = 0;
let downloadCalls = 0;
let workerCalls = 0;
const registered: string[] = [];
const module = serverLoader(
{
"@/lib/observability/logger": loggerMock,
"@/lib/storage/image-archive-db": {
enqueueImageArchiveTasks: async ({ urls }: { urls: string[] }) => {
registered.push(...urls);
return [];
},
getImageArchiveDisplayHoldMs: () => 0,
imageArchiveSourceKey: (url: string) => url,
},
"./image-archive-worker": {
scheduleImageArchiveWorker: () => {
workerCalls += 1;
},
},
"@/lib/storage/service": {
uploadFile: async () => {
uploadCalls += 1;
return bucket.promise;
},
},
fetch: async () => {
downloadCalls += 1;
return new Response(png, { headers: { "content-type": "image/png" } });
},
},
{ AI_IMAGE_IMMEDIATE_ARCHIVE: "true", AI_IMAGE_DEFERRED_ARCHIVE: "false" },
)("lib/storage/image-archive.ts");
const archive = module.archiveTemporaryImage(raw);
await expect.poll(() => uploadCalls).toBe(1);
const preview = await module.createDeferredUploadedImage(raw);
expect(preview).toEqual({ original: raw, thumbnail: raw });
expect(uploadCalls).toBe(1);
expect(downloadCalls).toBe(1);
expect(registered).toEqual([raw]);
expect(workerCalls).toBe(1);
bucket.resolve({ url: permanent, pathname: "result.png", contentType: "image/png" });
expect((await archive).url).toBe(permanent);
});
test("inline outputs have short, stable preview identities and durable queue payloads", async () => {
let inserted: any;
const db = {
insert: () => ({
values: (value: unknown) => {
inserted = value;
return { onConflictDoUpdate: () => ({ returning: async () => [inserted] }) };
},
}),
};
const module = serverLoader(
{
"@/lib/db": { db },
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "sourceUrl" } },
},
{ NEXT_PUBLIC_APP_URL: "https://app.example/subpath", IMAGE_ARCHIVE_DISPLAY_HOLD_HOURS: "1" },
)("lib/storage/image-archive-db.ts");
const data = `data:image/png;base64,${png.toString("base64")}`;
await module.enqueueImageArchiveTask({
sourceUrl: data,
metadata: { providerType: "gemini-image" },
});
expect(inserted.sourceUrl).toMatch(
/^https:\/\/app\.example\/api\/generated-image-preview\/[a-f0-9]{64}$/,
);
expect(inserted.metadata.inlineSource).toBe(data);
expect(module.imageArchiveSourceKey(data)).toBe(inserted.sourceUrl);
expect(module.getImageArchiveDisplayHoldMs()).toBe(0);
});
test("background storage starts after delivery and coalesces repeated wakeups", async () => {
const callbacks: Array<() => Promise<void>> = [];
let runs = 0;
const module = serverLoader({
"next/server": {
after: (fn: () => Promise<void>) => {
callbacks.push(fn);
},
},
"@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => {
runs += 1;
return {
store: { total: runs === 1 ? 1 : 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
};
},
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
module.scheduleImageArchiveWorker();
expect(callbacks).toHaveLength(1);
expect(runs).toBe(0);
await callbacks[0]();
expect(runs).toBe(2);
});
test("bucket failure retains the provider URL and retry task", async () => {
let failed = 0;
let stored = 0;
let replaced = 0;
const module = serverLoader({
"@/lib/storage/image-archive": {
archiveTemporaryImage: async () => {
throw new Error("Bucket unavailable");
},
},
"@/lib/storage/image-archive-db": {
recoverStaleImageArchiveTasks: async () => {},
getImageArchiveTasksToStore: async () => [{ id: "task", sourceUrl: raw, metadata: null }],
markImageArchiveTaskArchiving: async () => true,
markImageArchiveTaskStored: async () => {
stored += 1;
},
markImageArchiveTaskFailed: async () => {
failed += 1;
},
getImageArchiveTasksToReplace: async () => [],
getImageArchiveTaskStats: async () => ({ pendingStore: 1, pendingReplace: 0 }),
},
"@/lib/storage/image-url-replacement": {
replaceImageUrlInDatabase: async () => {
replaced += 1;
},
},
})("lib/jobs/image-archive-job.ts");
const result = await module.runImageArchiveJob();
expect(result.store.failed).toBe(1);
expect(failed).toBe(1);
expect(stored).toBe(0);
expect(replaced).toBe(0);
});
test("URL replacement preserves canvas geometry, selection, dates and undo position", () => {
const time = new Date("2026-10-11T01:00:00Z");
const node = {
id: "node",
type: "image",
position: { x: 300, y: 40 },
size: { width: 300, height: 500 },
data: { src: raw, originalUrl: raw },
createdAt: time,
};
const unaffected = { id: "text", type: "text", data: { text: "unchanged" } };
const nodes = [node, unaffected];
useCanvasStore.setState({
projectId: "project",
nodes: nodes as any,
history: [{ nodes } as any],
historyIndex: 0,
selectedNodeIds: ["node"],
});
useCanvasStore.getState().replaceArchivedImageUrls({ [raw]: permanent });
const state = useCanvasStore.getState();
expect((state.nodes[0].data as ImageNodeData).src).toBe(permanent);
expect(state.nodes[0].position).toEqual(node.position);
expect(state.nodes[0].size).toEqual(node.size);
expect(state.nodes[0].createdAt).toBe(time);
expect(state.nodes[1]).toBe(unaffected);
expect((state.history[0].nodes[0].data as ImageNodeData).src).toBe(permanent);
expect(state.historyIndex).toBe(0);
expect(state.selectedNodeIds).toEqual(["node"]);
const markdown = `![thumb|||${raw}|||2K](${raw})`;
expect(replaceArchivedImageUrls(markdown, { [raw]: permanent })).toBe(
`![thumb|||${permanent}|||2K](${permanent})`,
);
expect(collectImageArchiveSources({ nodes, markdown })).toEqual([raw]);
});
test("durable canvas queue starts all same-user jobs and claims each only once", async () => {
const tasks = Array.from({ length: 20 }, (_, index) => ({
id: `task-${index}`,
userId: "one-user",
status: "pending",
attempts: 0,
request: { model: "one-model", prompt: "test" },
createdAt: new Date(),
startedAt: null,
})) as any[];
const gate = deferred<any>();
let started = 0;
const projection = (rows: any[], shape?: Record<string, string>) =>
shape
? rows.map((row) =>
Object.fromEntries(Object.entries(shape).map(([key, field]) => [key, row[field]])),
)
: rows;
const query = (shape?: Record<string, string>, updates?: any) => {
let predicate = (_row: any) => true;
let limit = Infinity;
const execute = () => {
const rows = tasks.filter(predicate).slice(0, limit);
if (updates)
for (const row of rows)
Object.assign(row, updates, {
attempts: updates.status === "running" ? row.attempts + 1 : row.attempts,
});
return rows;
};
const builder: any = {
from: () => builder,
where: (condition: any) => {
predicate = condition;
return builder;
},
orderBy: () => builder,
limit: (count: number) => {
limit = count;
return builder;
},
returning: async (returnShape: any) => projection(execute(), returnShape),
then: (resolve: any, reject: any) =>
Promise.resolve(projection(execute(), shape)).then(resolve, reject),
};
return builder;
};
const sqlMock = (_parts: TemplateStringsArray, ..._args: unknown[]) => ({});
const taskTable = Object.fromEntries(
[
"id",
"request",
"userId",
"status",
"attempts",
"createdAt",
"startedAt",
"completedAt",
"result",
"error",
"updatedAt",
].map((field) => [field, field]),
);
const module = serverLoader({
"@/lib/db": {
db: {
select: (shape: any) => query(shape),
update: () => ({ set: (updates: any) => query(undefined, updates) }),
},
},
"@/lib/db/schema": { canvasImageTask: taskTable },
"drizzle-orm": {
eq: (field: string, value: unknown) => (row: any) => row[field] === value,
and:
(...predicates: any[]) =>
(row: any) =>
predicates.every((fn) => fn(row)),
lt: (field: string, value: any) => (row: any) => row[field] != null && row[field] < value,
asc: (field: unknown) => field,
notInArray: (field: string, values: unknown[]) => (row: any) => !values.includes(row[field]),
sql: sqlMock,
},
"./generate-image": {
generateCanvasImage: async () => {
started += 1;
return gate.promise;
},
},
"./scale-adjust-model-relight": {
applyScaleAdjustModelRelight: async ({ initialResult }: any) => initialResult,
},
"@/lib/style-pro/task": {
getStyleProTaskPrimaryModelKey: () => "style",
executeStyleProTask: async () => {
throw new Error("Unexpected style task");
},
},
"@/lib/canvas/layout-replace-task": { isCanvasLayoutReplaceTaskRequest: () => false },
"@/lib/canvas/layout-replace-batch": {},
"@/lib/agents/agent-project-result-persistence": {},
"@/lib/storage/image-archive-db": { resolveArchivedImageUrls: async (value: unknown) => value },
})("lib/canvas/image-task-queue.ts");
await module.enqueueCanvasImageTask(tasks[0].id);
await expect.poll(() => started).toBe(20);
await module.enqueueCanvasImageTask(tasks[0].id);
expect(started).toBe(20);
expect(tasks.every((task) => task.status === "running" && task.attempts === 1)).toBe(true);
gate.resolve({
images: [{ originalUrl: raw, thumbnailUrl: raw }],
model: "one-model",
prompt: "test",
});
await expect.poll(() => tasks.filter((task) => task.status === "succeeded").length).toBe(20);
});
test("successful background archive replaces saved references immediately", async () => {
let stored = false;
let claimed = false;
let completed = false;
const calls: string[][] = [];
const module = serverLoader({
"@/lib/storage/image-archive": {
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
},
"@/lib/storage/image-archive-db": {
recoverStaleImageArchiveTasks: async () => {},
getImageArchiveTasksToStore: async () => [{ id: "task", sourceUrl: raw, metadata: null }],
markImageArchiveTaskArchiving: async () => {
if (claimed) return false;
claimed = true;
return true;
},
markImageArchiveTaskStored: async () => {
stored = true;
},
markImageArchiveTaskFailed: async () => {
throw new Error("Unexpected storage failure");
},
getImageArchiveTasksToReplace: async () =>
stored && !completed ? [{ id: "task", sourceUrl: raw, targetUrl: permanent }] : [],
markImageArchiveTaskReplacing: async () => true,
markImageArchiveTaskCompleted: async () => {
completed = true;
},
getImageArchiveTaskStats: async () => ({ pendingStore: 0, pendingReplace: 0 }),
},
"@/lib/storage/image-url-replacement": {
replaceImageUrlInDatabase: async (...urls: string[]) => {
calls.push(urls);
return { changed: 2 };
},
},
})("lib/jobs/image-archive-job.ts");
const result = await module.runImageArchiveJob();
expect(result.store.succeeded).toBe(1);
expect(result.replace.succeeded).toBe(1);
expect(calls).toEqual([[raw, permanent]]);
await module.runImageArchiveJob();
expect(calls).toHaveLength(1);
});
test("inline preview serves exact image bytes then redirects to the stored object", async () => {
const key = "a".repeat(64);
let archived = false;
const rows = () => [
{
targetUrl: archived ? permanent : null,
metadata: { inlineSource: `data:image/png;base64,${png.toString("base64")}` },
},
];
const module = serverLoader(
{
"@/lib/db": {
db: { select: () => ({ from: () => ({ where: () => ({ limit: async () => rows() }) }) }) },
},
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "sourceUrl" } },
"next/server": {
NextResponse: class extends Response {
static redirect(url: string, status: number) {
return new Response(null, { status, headers: { Location: url } });
}
},
},
},
{ NEXT_PUBLIC_APP_URL: "https://app.example" },
)("app/api/generated-image-preview/[key]/route.ts");
const response = await module.GET({}, { params: Promise.resolve({ key }) });
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(Buffer.from(await response.arrayBuffer())).toEqual(png);
archived = true;
const redirect = await module.GET({}, { params: Promise.resolve({ key }) });
expect(redirect.status).toBe(307);
expect(redirect.headers.get("Location")).toBe(permanent);
const invalid = await module.GET({}, { params: Promise.resolve({ key: "guess" }) });
expect(invalid.status).toBe(404);
});
test("archive lookup requires login and an owned canvas before reading image mappings", async () => {
let signedIn = false;
let reads = 0;
const module = serverLoader({
"@/app/(auth)/auth": { auth: async () => (signedIn ? { user: { id: "owner" } } : null) },
"@/lib/db": {
db: {
select: () => {
reads += 1;
return { from: () => ({ where: () => ({ limit: async () => [] }) }) };
},
},
},
"@/lib/db/schema": {
canvasProject: { id: "id", userId: "userId" },
imageArchiveTask: {},
generatedImage: {},
canvasImageTask: {},
},
"@/lib/storage/image-archive-db": { imageArchiveSourceKey: (url: string) => url },
"@/lib/storage/image-archive-worker": {
scheduleImageArchiveWorker: () => {
throw new Error("Unowned request must not start archiving");
},
},
"next/server": {
NextResponse: class extends Response {
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), init);
}
},
},
})("app/api/canvas/[id]/image-archives/route.ts");
const request = { json: async () => ({ urls: [raw] }) };
expect(
(await module.POST(request, { params: Promise.resolve({ id: "other-project" }) })).status,
).toBe(401);
expect(reads).toBe(0);
signedIn = true;
expect(
(await module.POST(request, { params: Promise.resolve({ id: "other-project" }) })).status,
).toBe(404);
expect(reads).toBe(1);
});
test("replacement retries reconcile late writes once, without reclaiming completed jobs forever", async () => {
const past = new Date(Date.now() - 1000);
const future = new Date(Date.now() + 60_000);
const rows = [
{ id: "stored", status: "stored", replaceAfterAt: past },
{ id: "settle", status: "completed", replaceAfterAt: past, metadata: { replacementPasses: 1 } },
{
id: "too-soon",
status: "completed",
replaceAfterAt: future,
metadata: { replacementPasses: 1 },
},
{ id: "done", status: "completed", replaceAfterAt: past, metadata: { replacementPasses: 2 } },
{ id: "legacy", status: "completed", replaceAfterAt: past },
];
const table = Object.fromEntries(
["id", "status", "replaceAfterAt", "metadata", "createdAt", "updatedAt"].map((key) => [
key,
key,
]),
);
const query = (updates?: any) => {
let predicate = (_row: any) => true;
const execute = () => {
const found = rows.filter(predicate);
if (updates) found.forEach((row) => Object.assign(row, updates));
return found;
};
const builder: any = {
from: () => builder,
orderBy: () => builder,
limit: () => builder,
where: (value: any) => {
predicate = value;
return builder;
},
returning: async () => execute(),
then: (resolve: any) => Promise.resolve(execute()).then(resolve),
};
return builder;
};
const module = serverLoader({
"@/lib/db": {
db: { select: () => query(), update: () => ({ set: (updates: any) => query(updates) }) },
},
"@/lib/db/schema": { imageArchiveTask: table },
"drizzle-orm": {
and:
(...conditions: any[]) =>
(row: any) =>
conditions.every((fn) => fn(row)),
or:
(...conditions: any[]) =>
(row: any) =>
conditions.some((fn) => fn(row)),
eq: (field: string, value: unknown) => (row: any) => row[field] === value,
lte: (field: string, value: any) => (row: any) => row[field] <= value,
asc: (field: unknown) => field,
sql: (parts: TemplateStringsArray) => {
if (!parts.join("").includes("replacementPasses")) throw new Error("Unexpected predicate");
return (row: any) => row.metadata?.replacementPasses === 1;
},
},
})("lib/storage/image-archive-db.ts");
expect(
(await module.getImageArchiveTasksToReplace({ limit: 50 })).map((row: any) => row.id),
).toEqual(["stored", "settle"]);
expect(await module.markImageArchiveTaskReplacing("settle")).toBe(true);
expect(await module.markImageArchiveTaskReplacing("settle")).toBe(false);
expect(await module.markImageArchiveTaskReplacing("done")).toBe(false);
});
test("detached generation still archives when its original request has already closed", async () => {
let runs = 0;
const module = serverLoader({
// A closed request may accept a callback that is never invoked.
"next/server": { after: () => {} },
"@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => {
runs += 1;
return {
store: { total: 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
};
},
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
expect(runs).toBe(0);
await expect.poll(() => runs, { timeout: 5_000 }).toBe(1);
});