ai-chatbot/lib/storage/image-url-replacement.ts

406 lines
14 KiB
TypeScript

import "server-only";
import { eq, like, or, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
assetMemorySnapshot,
canvasDetailArchive,
canvasNode,
canvasImageTask,
canvasProject,
conversationWorkingState,
externalImageApiRequestLog,
generatedImage,
mediaAsset,
message,
messageDeprecated,
model3dAsset,
} from "@/lib/db/schema";
import { inferOriginalImageUrl } from "@/lib/image-url";
function normalizeMediaUrl(value: string): string {
const raw = String(value || "").trim();
if (!raw) return "";
const canonical = inferOriginalImageUrl(raw);
try {
const parsed = new URL(canonical);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
} catch {
return canonical.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase();
}
}
export function replaceDeepUrls(
value: unknown,
sourceUrl: string,
targetUrl: string,
): { value: unknown; changed: boolean } {
if (typeof value === "string") {
if (!value.includes(sourceUrl)) {
return { value, changed: false };
}
return { value: value.split(sourceUrl).join(targetUrl), changed: true };
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item) => {
const result = replaceDeepUrls(item, sourceUrl, targetUrl);
if (result.changed) changed = true;
return result.value;
});
return { value: changed ? next : value, changed };
}
if (value && typeof value === "object") {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const result = replaceDeepUrls(item, sourceUrl, targetUrl);
next[key] = result.value;
if (result.changed) changed = true;
}
return { value: changed ? next : value, changed };
}
return { value, changed: false };
}
export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: string) {
if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) {
return { changed: 0 };
}
let changed = 0;
const sourceNormalizedUrl = normalizeMediaUrl(sourceUrl);
const targetNormalizedUrl = normalizeMediaUrl(targetUrl);
const generatedThumb = await db
.update(generatedImage)
.set({ thumbnailUrl: targetUrl })
.where(eq(generatedImage.thumbnailUrl, sourceUrl))
.returning({ id: generatedImage.id });
changed += generatedThumb.length;
const generatedOrig = await db
.update(generatedImage)
.set({ originalUrl: targetUrl })
.where(eq(generatedImage.originalUrl, sourceUrl))
.returning({ id: generatedImage.id });
changed += generatedOrig.length;
const assets = await db
.select()
.from(mediaAsset)
.where(
or(
eq(mediaAsset.originalUrl, sourceUrl),
eq(mediaAsset.previewUrl, sourceUrl),
eq(mediaAsset.thumbnailUrl, sourceUrl),
eq(mediaAsset.normalizedUrl, sourceNormalizedUrl),
sql`${mediaAsset.metadata}::text like ${`%${sourceUrl}%`}`,
sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
for (const item of assets) {
const original = replaceDeepUrls(item.originalUrl, sourceUrl, targetUrl);
const preview = replaceDeepUrls(item.previewUrl, sourceUrl, targetUrl);
const thumbnail = replaceDeepUrls(item.thumbnailUrl, sourceUrl, targetUrl);
const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl);
const summary = replaceDeepUrls(item.summary, sourceUrl, targetUrl);
const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl || original.changed;
const nextOriginal = (original.value as string) || item.originalUrl;
if (
!original.changed &&
!preview.changed &&
!thumbnail.changed &&
!metadata.changed &&
!summary.changed &&
!shouldNormalize
) {
continue;
}
try {
await db
.update(mediaAsset)
.set({
originalUrl: nextOriginal,
normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl,
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
metadata: (metadata.value ?? item.metadata) as any,
summary: (summary.value ?? item.summary) as any,
updatedAt: new Date(),
})
.where(eq(mediaAsset.id, item.id));
} catch (error) {
await db
.update(mediaAsset)
.set({
originalUrl: nextOriginal,
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
metadata: (metadata.value ?? item.metadata) as any,
summary: (summary.value ?? item.summary) as any,
updatedAt: new Date(),
})
.where(eq(mediaAsset.id, item.id));
}
changed += 1;
}
const snapshots = await db
.select()
.from(assetMemorySnapshot)
.where(
or(
eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl),
sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`,
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`),
),
)
.limit(500);
for (const item of snapshots) {
const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl);
const summary = replaceDeepUrls(item.summaryText, sourceUrl, targetUrl);
const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl;
if (!metadata.changed && !summary.changed && !shouldNormalize) continue;
await db
.update(assetMemorySnapshot)
.set({
normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl,
metadata: (metadata.value ?? item.metadata) as any,
summaryText: (summary.value as string | null | undefined) ?? item.summaryText,
updatedAt: new Date(),
})
.where(eq(assetMemorySnapshot.id, item.id));
changed += 1;
}
const messages = await db
.select({
id: message.id,
parts: message.parts,
attachments: message.attachments,
})
.from(message)
.where(
or(
sql`${message.parts}::text like ${`%${sourceUrl}%`}`,
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
for (const item of messages) {
const partsResult = replaceDeepUrls(item.parts, sourceUrl, targetUrl);
const attachmentsResult = replaceDeepUrls(item.attachments, sourceUrl, targetUrl);
if (!partsResult.changed && !attachmentsResult.changed) continue;
await db
.update(message)
.set({
parts: (partsResult.value ?? item.parts) as any,
attachments: (attachmentsResult.value ?? item.attachments) as any,
})
.where(eq(message.id, item.id));
changed += 1;
}
const legacyMessages = await db
.select({
id: messageDeprecated.id,
content: messageDeprecated.content,
})
.from(messageDeprecated)
.where(sql`${messageDeprecated.content}::text like ${`%${sourceUrl}%`}`)
.limit(500);
for (const item of legacyMessages) {
const replaced = replaceDeepUrls(item.content, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(messageDeprecated)
.set({ content: replaced.value as any })
.where(eq(messageDeprecated.id, item.id));
changed += 1;
}
const escapedSource = JSON.stringify(sourceUrl).slice(1, -1);
const escapedTarget = JSON.stringify(targetUrl).slice(1, -1);
const projects = await db
.update(canvasProject)
.set({
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.
})
.where(
or(
sql`position(${escapedSource} in ${canvasProject.canvasData}::text) > 0`,
eq(canvasProject.thumbnail, sourceUrl),
),
)
.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;
const workingStates = await db
.update(conversationWorkingState)
.set({
currentFocusImageUrls: sql`replace(${conversationWorkingState.currentFocusImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
selectedImageUrls: sql`replace(${conversationWorkingState.selectedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastUploadedImageUrls: sql`replace(${conversationWorkingState.lastUploadedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastGeneratedImageUrls: sql`replace(${conversationWorkingState.lastGeneratedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastReferencedImageUrls: sql`replace(${conversationWorkingState.lastReferencedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastResolvedTargetImageUrls: sql`replace(${conversationWorkingState.lastResolvedTargetImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastCanvasSnapshot: sql`replace(${conversationWorkingState.lastCanvasSnapshot}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastTurnSummary: sql`replace(${conversationWorkingState.lastTurnSummary}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
updatedAt: new Date(),
})
.where(
or(
sql`position(${escapedSource} in ${conversationWorkingState.currentFocusImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.selectedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastUploadedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastGeneratedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastReferencedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastResolvedTargetImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastCanvasSnapshot}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastTurnSummary}::text) > 0`,
),
)
.returning({ id: conversationWorkingState.id });
changed += workingStates.length;
const detailArchives = await db
.select({
id: canvasDetailArchive.id,
coverUrl: canvasDetailArchive.coverUrl,
brief: canvasDetailArchive.brief,
slots: canvasDetailArchive.slots,
uiSurfaceSnapshot: canvasDetailArchive.uiSurfaceSnapshot,
uiSurfaceLatestReview: canvasDetailArchive.uiSurfaceLatestReview,
})
.from(canvasDetailArchive)
.where(
or(
like(canvasDetailArchive.coverUrl, `%${sourceUrl}%`),
sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.uiSurfaceSnapshot}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
for (const item of detailArchives) {
const cover = replaceDeepUrls(item.coverUrl, sourceUrl, targetUrl);
const brief = replaceDeepUrls(item.brief, sourceUrl, targetUrl);
const slots = replaceDeepUrls(item.slots, sourceUrl, targetUrl);
const snapshot = replaceDeepUrls(item.uiSurfaceSnapshot, sourceUrl, targetUrl);
const review = replaceDeepUrls(item.uiSurfaceLatestReview, sourceUrl, targetUrl);
if (
!cover.changed &&
!brief.changed &&
!slots.changed &&
!snapshot.changed &&
!review.changed
) {
continue;
}
await db
.update(canvasDetailArchive)
.set({
coverUrl: (cover.value as string | null | undefined) ?? item.coverUrl,
brief: (brief.value ?? item.brief) as any,
slots: (slots.value ?? item.slots) as any,
uiSurfaceSnapshot: (snapshot.value ?? item.uiSurfaceSnapshot) as any,
uiSurfaceLatestReview: (review.value ?? item.uiSurfaceLatestReview) as any,
updatedAt: new Date(),
})
.where(eq(canvasDetailArchive.id, item.id));
changed += 1;
}
const modelAssets = await db
.select()
.from(model3dAsset)
.where(
or(
like(model3dAsset.previewImageUrl, `%${sourceUrl}%`),
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
for (const item of modelAssets) {
const preview = replaceDeepUrls(item.previewImageUrl, sourceUrl, targetUrl);
const payload = replaceDeepUrls(item.assetPayload, sourceUrl, targetUrl);
if (!preview.changed && !payload.changed) continue;
await db
.update(model3dAsset)
.set({
previewImageUrl: (preview.value as string | null | undefined) ?? item.previewImageUrl,
assetPayload: (payload.value ?? item.assetPayload) as any,
updatedAt: new Date(),
})
.where(eq(model3dAsset.id, item.id));
changed += 1;
}
const apiLogs = await db
.select()
.from(externalImageApiRequestLog)
.where(
or(
sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`,
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
for (const item of apiLogs) {
const responsePayload = replaceDeepUrls(item.responsePayload, sourceUrl, targetUrl);
const generatedUrls = replaceDeepUrls(item.generatedImageUrls, sourceUrl, targetUrl);
if (!responsePayload.changed && !generatedUrls.changed) continue;
await db
.update(externalImageApiRequestLog)
.set({
responsePayload: (responsePayload.value ?? item.responsePayload) as any,
generatedImageUrls: (generatedUrls.value ?? item.generatedImageUrls) as any,
})
.where(eq(externalImageApiRequestLog.id, item.id));
changed += 1;
}
return { changed };
}