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

477 lines
15 KiB
TypeScript

import "server-only";
import { eq, like, or, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
assetMemorySnapshot,
canvasDetailArchive,
canvasNode,
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 projects = await db
.select({
id: canvasProject.id,
canvasData: canvasProject.canvasData,
})
.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(
or(
sql`${conversationWorkingState.currentFocusImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.selectedImageUrls}::text like ${`%${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);
for (const item of workingStates) {
const currentFocus = replaceDeepUrls(
item.currentFocusImageUrls,
sourceUrl,
targetUrl
);
const selected = replaceDeepUrls(item.selectedImageUrls, sourceUrl, targetUrl);
const uploaded = replaceDeepUrls(item.lastUploadedImageUrls, sourceUrl, targetUrl);
const generated = replaceDeepUrls(item.lastGeneratedImageUrls, sourceUrl, targetUrl);
const referenced = replaceDeepUrls(item.lastReferencedImageUrls, sourceUrl, targetUrl);
const resolved = replaceDeepUrls(
item.lastResolvedTargetImageUrls,
sourceUrl,
targetUrl
);
const canvasSnapshot = replaceDeepUrls(
item.lastCanvasSnapshot,
sourceUrl,
targetUrl
);
const turnSummary = replaceDeepUrls(item.lastTurnSummary, sourceUrl, targetUrl);
if (
!currentFocus.changed &&
!selected.changed &&
!uploaded.changed &&
!generated.changed &&
!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
.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 };
}