Spaces:
Paused
Paused
| "use client"; | |
| import localforage from "localforage"; | |
| import type { ImageModel } from "@/lib/api"; | |
| export type ImageConversationMode = "generate" | "edit"; | |
| export type StoredReferenceImage = { | |
| name: string; | |
| type: string; | |
| dataUrl: string; | |
| }; | |
| export type StoredImage = { | |
| id: string; | |
| taskId?: string; | |
| status?: "loading" | "success" | "error"; | |
| b64_json?: string; | |
| url?: string; | |
| revised_prompt?: string; | |
| error?: string; | |
| }; | |
| export type ImageTurnStatus = "queued" | "generating" | "success" | "error"; | |
| export type ImageTurn = { | |
| id: string; | |
| prompt: string; | |
| model: ImageModel; | |
| mode: ImageConversationMode; | |
| referenceImages: StoredReferenceImage[]; | |
| count: number; | |
| size: string; | |
| images: StoredImage[]; | |
| createdAt: string; | |
| status: ImageTurnStatus; | |
| error?: string; | |
| promptDeleted?: boolean; | |
| resultsDeleted?: boolean; | |
| }; | |
| export type ImageConversation = { | |
| id: string; | |
| title: string; | |
| createdAt: string; | |
| updatedAt: string; | |
| turns: ImageTurn[]; | |
| }; | |
| export type ImageConversationStats = { | |
| queued: number; | |
| running: number; | |
| }; | |
| const imageConversationStorage = localforage.createInstance({ | |
| name: "chatgpt2api", | |
| storeName: "image_conversations", | |
| }); | |
| const IMAGE_CONVERSATIONS_KEY = "items"; | |
| let imageConversationWriteQueue: Promise<void> = Promise.resolve(); | |
| function normalizeStoredImage(image: StoredImage): StoredImage { | |
| const normalized = { | |
| ...image, | |
| taskId: typeof image.taskId === "string" && image.taskId ? image.taskId : undefined, | |
| url: typeof image.url === "string" && image.url ? image.url : undefined, | |
| revised_prompt: typeof image.revised_prompt === "string" ? image.revised_prompt : undefined, | |
| }; | |
| if (image.status === "loading" || image.status === "error" || image.status === "success") { | |
| return normalized; | |
| } | |
| return { | |
| ...normalized, | |
| status: image.b64_json || image.url ? "success" : "loading", | |
| }; | |
| } | |
| function normalizeReferenceImage(image: StoredReferenceImage): StoredReferenceImage { | |
| return { | |
| name: image.name || "reference.png", | |
| type: image.type || "image/png", | |
| dataUrl: image.dataUrl, | |
| }; | |
| } | |
| function dataUrlMimeType(dataUrl: string) { | |
| const match = dataUrl.match(/^data:(.*?);base64,/); | |
| return match?.[1] || "image/png"; | |
| } | |
| function getLegacyReferenceImages(source: Record<string, unknown>): StoredReferenceImage[] { | |
| if (Array.isArray(source.referenceImages)) { | |
| return source.referenceImages | |
| .filter((image): image is StoredReferenceImage => { | |
| if (!image || typeof image !== "object") { | |
| return false; | |
| } | |
| const candidate = image as StoredReferenceImage; | |
| return typeof candidate.dataUrl === "string" && candidate.dataUrl.length > 0; | |
| }) | |
| .map(normalizeReferenceImage); | |
| } | |
| if (source.sourceImage && typeof source.sourceImage === "object") { | |
| const image = source.sourceImage as { dataUrl?: unknown; fileName?: unknown }; | |
| if (typeof image.dataUrl === "string" && image.dataUrl) { | |
| return [ | |
| { | |
| name: typeof image.fileName === "string" && image.fileName ? image.fileName : "reference.png", | |
| type: dataUrlMimeType(image.dataUrl), | |
| dataUrl: image.dataUrl, | |
| }, | |
| ]; | |
| } | |
| } | |
| return []; | |
| } | |
| function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn { | |
| const normalizedImages = Array.isArray(turn.images) ? turn.images.map(normalizeStoredImage) : []; | |
| const derivedStatus: ImageTurnStatus = | |
| normalizedImages.some((image) => image.status === "loading") | |
| ? "generating" | |
| : normalizedImages.some((image) => image.status === "error") | |
| ? "error" | |
| : "success"; | |
| return { | |
| id: String(turn.id || `${Date.now()}`), | |
| prompt: String(turn.prompt || ""), | |
| model: (turn.model as ImageModel) || "gpt-image-2", | |
| mode: turn.mode === "edit" ? "edit" : "generate", | |
| referenceImages: getLegacyReferenceImages(turn), | |
| count: Math.max(1, Number(turn.count || normalizedImages.length || 1)), | |
| size: typeof turn.size === "string" ? turn.size : "", | |
| images: normalizedImages, | |
| createdAt: String(turn.createdAt || new Date().toISOString()), | |
| status: | |
| turn.status === "queued" || | |
| turn.status === "generating" || | |
| turn.status === "success" || | |
| turn.status === "error" | |
| ? turn.status | |
| : derivedStatus, | |
| error: typeof turn.error === "string" ? turn.error : undefined, | |
| promptDeleted: turn.promptDeleted === true, | |
| resultsDeleted: turn.resultsDeleted === true, | |
| }; | |
| } | |
| function normalizeConversation(conversation: ImageConversation & Record<string, unknown>): ImageConversation { | |
| const turns = Array.isArray(conversation.turns) | |
| ? conversation.turns.map((turn) => normalizeTurn(turn as ImageTurn & Record<string, unknown>)) | |
| : [ | |
| normalizeTurn({ | |
| id: String(conversation.id || `${Date.now()}`), | |
| prompt: String(conversation.prompt || ""), | |
| model: (conversation.model as ImageModel) || "gpt-image-2", | |
| mode: conversation.mode === "edit" ? "edit" : "generate", | |
| referenceImages: getLegacyReferenceImages(conversation), | |
| count: Number(conversation.count || 1), | |
| size: typeof conversation.size === "string" ? conversation.size : "", | |
| images: Array.isArray(conversation.images) ? (conversation.images as StoredImage[]) : [], | |
| createdAt: String(conversation.createdAt || new Date().toISOString()), | |
| status: | |
| conversation.status === "generating" || conversation.status === "success" || conversation.status === "error" | |
| ? conversation.status | |
| : "success", | |
| error: typeof conversation.error === "string" ? conversation.error : undefined, | |
| }), | |
| ]; | |
| const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null; | |
| return { | |
| id: String(conversation.id || `${Date.now()}`), | |
| title: String(conversation.title || ""), | |
| createdAt: String(conversation.createdAt || lastTurn?.createdAt || new Date().toISOString()), | |
| updatedAt: String(conversation.updatedAt || lastTurn?.createdAt || new Date().toISOString()), | |
| turns, | |
| }; | |
| } | |
| function sortImageConversations(conversations: ImageConversation[]): ImageConversation[] { | |
| return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); | |
| } | |
| function getTimestamp(value: string) { | |
| const time = new Date(value).getTime(); | |
| return Number.isFinite(time) ? time : 0; | |
| } | |
| function pickLatestConversation(current: ImageConversation, next: ImageConversation) { | |
| return getTimestamp(next.updatedAt) >= getTimestamp(current.updatedAt) ? next : current; | |
| } | |
| function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T> { | |
| const result = imageConversationWriteQueue.then(operation); | |
| imageConversationWriteQueue = result.then( | |
| () => undefined, | |
| () => undefined, | |
| ); | |
| return result; | |
| } | |
| async function readStoredImageConversations(): Promise<ImageConversation[]> { | |
| const items = | |
| (await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>( | |
| IMAGE_CONVERSATIONS_KEY, | |
| )) || []; | |
| return items.map(normalizeConversation); | |
| } | |
| export async function listImageConversations(): Promise<ImageConversation[]> { | |
| return sortImageConversations(await readStoredImageConversations()); | |
| } | |
| export async function saveImageConversations(conversations: ImageConversation[]): Promise<void> { | |
| await queueImageConversationWrite(async () => { | |
| const items = await readStoredImageConversations(); | |
| const conversationMap = new Map(items.map((item) => [item.id, item])); | |
| for (const conversation of conversations.map(normalizeConversation)) { | |
| const current = conversationMap.get(conversation.id); | |
| conversationMap.set(conversation.id, current ? pickLatestConversation(current, conversation) : conversation); | |
| } | |
| await imageConversationStorage.setItem( | |
| IMAGE_CONVERSATIONS_KEY, | |
| sortImageConversations([...conversationMap.values()]), | |
| ); | |
| }); | |
| } | |
| export async function saveImageConversation(conversation: ImageConversation): Promise<void> { | |
| await queueImageConversationWrite(async () => { | |
| const items = await readStoredImageConversations(); | |
| const nextConversation = normalizeConversation(conversation); | |
| const current = items.find((item) => item.id === nextConversation.id); | |
| const persistedConversation = current ? pickLatestConversation(current, nextConversation) : nextConversation; | |
| const nextItems = sortImageConversations([ | |
| persistedConversation, | |
| ...items.filter((item) => item.id !== persistedConversation.id), | |
| ]); | |
| await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems); | |
| }); | |
| } | |
| export async function renameImageConversation(id: string, title: string): Promise<void> { | |
| await queueImageConversationWrite(async () => { | |
| const items = await readStoredImageConversations(); | |
| const target = items.find((item) => item.id === id); | |
| if (!target) return; | |
| const updated = { ...target, title, updatedAt: new Date().toISOString() }; | |
| const nextItems = sortImageConversations([ | |
| updated, | |
| ...items.filter((item) => item.id !== id), | |
| ]); | |
| await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems); | |
| }); | |
| } | |
| export async function deleteImageConversation(id: string): Promise<void> { | |
| await queueImageConversationWrite(async () => { | |
| const items = await readStoredImageConversations(); | |
| await imageConversationStorage.setItem( | |
| IMAGE_CONVERSATIONS_KEY, | |
| items.filter((item) => item.id !== id), | |
| ); | |
| }); | |
| } | |
| export async function clearImageConversations(): Promise<void> { | |
| await queueImageConversationWrite(async () => { | |
| await imageConversationStorage.removeItem(IMAGE_CONVERSATIONS_KEY); | |
| }); | |
| } | |
| export function getImageConversationStats(conversation: ImageConversation | null): ImageConversationStats { | |
| if (!conversation) { | |
| return { queued: 0, running: 0 }; | |
| } | |
| return conversation.turns.reduce( | |
| (acc, turn) => { | |
| if (turn.resultsDeleted) { | |
| return acc; | |
| } | |
| if (turn.status === "queued") { | |
| acc.queued += 1; | |
| } else if (turn.status === "generating") { | |
| acc.running += 1; | |
| } | |
| return acc; | |
| }, | |
| { queued: 0, running: 0 }, | |
| ); | |
| } | |