chatgpt2api / web /src /store /image-conversations.ts
tx1538's picture
Upload 179 files
9d7ddb9 verified
Raw
History Blame
10.3 kB
"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 },
);
}