import { db } from "@/lib/db"; import { generatedImage } from "@/lib/db/schema"; import { consumeUserPoints, createMediaAssetRelation, getAiModelById, getEnabledAiModelsWithProvider, getMediaAssetsByUserAndUrls, getPointsSystemConfig, getUserById, upsertMediaAsset, } from "@/lib/db/queries"; import { persistImageWorkflowTurn } from "@/lib/ai/conversation-state-protocol"; import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config"; const MODEL_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function parseImageAltMetadata(altText: string, thumbnailUrl: string): { originalUrl: string; imageSize: string; } { let originalUrl = thumbnailUrl; let imageSize = "2K"; if (altText.includes("thumb|||")) { const content = altText.substring(altText.indexOf("thumb|||") + 8); const parts = content.split("|||"); originalUrl = parts[0] || thumbnailUrl; imageSize = parts[1] || "2K"; } else if (altText.includes("thumb$$")) { const content = altText.substring(altText.indexOf("thumb$$") + 7); const parts = content.split("$$"); originalUrl = parts[0] || thumbnailUrl; imageSize = parts[1] || "2K"; } else if (altText.includes("thumb|")) { const content = altText.substring(altText.indexOf("thumb|") + 6); const parts = content.split("|"); originalUrl = parts[0] || thumbnailUrl; imageSize = parts[1] || "2K"; } if (originalUrl === thumbnailUrl) { if (thumbnailUrl.includes("gemini-thumb-")) { originalUrl = thumbnailUrl.replace("gemini-thumb-", "gemini-orig-"); } else if (thumbnailUrl.includes("openai-thumb-")) { originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-"); } else if (thumbnailUrl.includes("volcano-thumb-")) { originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-"); } else if (thumbnailUrl.includes("bfl-image-thumb-")) { originalUrl = thumbnailUrl.replace("bfl-image-thumb-", "bfl-image-orig-"); } else if (thumbnailUrl.includes("alibaba-image-thumb-")) { originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-"); } else if (thumbnailUrl.includes("ruxa-image-thumb-")) { originalUrl = thumbnailUrl.replace("ruxa-image-thumb-", "ruxa-image-orig-"); } else if (thumbnailUrl.includes("stepfun-image-thumb-")) { originalUrl = thumbnailUrl.replace("stepfun-image-thumb-", "stepfun-image-orig-"); } else if (thumbnailUrl.includes("fal-image-thumb-")) { originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-"); } } return { originalUrl, imageSize }; } function getModelCreditCost( modelConfig: any, target: "image" | "video", options?: { duration?: number | null; resolution?: string | null }, ): number { const config = (modelConfig as any)?.imageGenConfig || {}; const sourceValue = target === "video" ? (() => { const resolvedVideoCost = getVideoCreditCostFromConfig( config, options?.duration, options?.resolution, ); if (resolvedVideoCost > 0) return resolvedVideoCost; return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0; })() : config.creditCostPerImage ?? 0; const normalized = Number(sourceValue); return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; } async function resolveBillingModelByReference(modelRef: string): Promise { if (!modelRef) return undefined; if (MODEL_UUID_REGEX.test(modelRef)) { return await getAiModelById({ id: modelRef }).catch(() => undefined); } const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []); const matched = allModels.find( ({ model }: any) => model.id === modelRef || model.modelId === modelRef, ); return matched?.model; } export async function saveGeneratedImages( imageMarkdown: string, userId: string, chatId: string, messageId: string, prompt: string, modelId: string, options?: { sourceAssetIds?: string[]; sourceImageUrls?: string[]; relationType?: "generated_from" | "edited_from"; sourceType?: string; projectId?: string | null; userPrompt?: string; optimizedPrompt?: string; requestedImageCount?: number; aspectRatio?: string; imageSize?: string; }, ): Promise { const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const matches = [...imageMarkdown.matchAll(imageRegex)]; let savedImageCount = 0; const savedAssetIds: string[] = []; const savedOriginalUrls: string[] = []; let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds) ? options.sourceAssetIds.filter((assetId): assetId is string => Boolean(assetId)) : []; if ( relationSourceAssetIds.length === 0 && Array.isArray(options?.sourceImageUrls) && options.sourceImageUrls.length > 0 ) { try { const sourceAssets = await getMediaAssetsByUserAndUrls({ userId, urls: options.sourceImageUrls, }); relationSourceAssetIds = sourceAssets.map((asset) => asset.id); } catch (sourceLookupError) { console.error("[Gallery] Failed to resolve source assets by URL:", sourceLookupError); } } for (const match of matches) { const altText = match[1] || ""; const thumbnailUrl = match[2]; const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl); try { const [savedImage] = await db .insert(generatedImage) .values({ userId, chatId, messageId, thumbnailUrl, originalUrl, imageSize, prompt, modelId, }) .returning(); savedImageCount += 1; if (!savedOriginalUrls.includes(originalUrl)) { savedOriginalUrls.push(originalUrl); } const media = await upsertMediaAsset({ userId, chatId, projectId: options?.projectId ?? null, mediaType: "image", sourceType: options?.sourceType || "generated", status: "ready", originalUrl, previewUrl: originalUrl, thumbnailUrl, displayName: prompt?.slice(0, 80) || "AI 生成图片", prompt, modelId, sourceMessageId: messageId, sourceTable: "GeneratedImage", sourceRowId: savedImage?.id || null, primaryParentAssetId: relationSourceAssetIds[0] || null, metadata: { imageSize, }, }); if (media?.id && !savedAssetIds.includes(media.id)) { savedAssetIds.push(media.id); } if (media && relationSourceAssetIds.length > 0) { for (const sourceAssetId of relationSourceAssetIds) { if (!sourceAssetId) continue; await createMediaAssetRelation({ userId, chatId, projectId: options?.projectId ?? null, fromAssetId: sourceAssetId, toAssetId: media.id, relationType: options?.relationType || "generated_from", sourceMessageId: messageId, metadata: { modelId, prompt, sourceImageUrls: options?.sourceImageUrls || [], }, }); } } console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`); } catch (error) { console.error("[Gallery] Failed to save image record:", error); } } if (savedOriginalUrls.length > 0) { try { const resultAssetIds = savedAssetIds.length > 0 ? savedAssetIds : null; await persistImageWorkflowTurn({ userId, chatId, projectId: options?.projectId || null, currentIntent: options?.relationType === "edited_from" ? "image_edit" : "image_gen", currentFocusAssetIds: resultAssetIds, currentFocusImageUrls: savedOriginalUrls, lastGeneratedAssetIds: resultAssetIds, lastGeneratedImageUrls: savedOriginalUrls, lastResolvedTargetAssetIds: resultAssetIds, lastResolvedTargetImageUrls: savedOriginalUrls, lastMessageId: messageId, lastUserPrompt: String(options?.userPrompt || prompt || "").trim() || prompt, lastOptimizedPrompt: String( options?.optimizedPrompt || (options?.relationType !== "edited_from" ? prompt : "") || "", ).trim() || undefined, lastRequestedImageCount: options?.requestedImageCount || savedOriginalUrls.length, lastAspectRatio: options?.aspectRatio, lastImageSize: options?.imageSize, lastSourceImageUrls: options?.sourceImageUrls || null, lastResultImageUrls: savedOriginalUrls, }); } catch (memoryError) { console.warn("[Gallery] Failed to update working state with generated images:", memoryError); } } if (savedImageCount <= 0) return; try { const pointsConfig = await getPointsSystemConfig(); if (!pointsConfig.enabled) return; const userData = await getUserById({ id: userId }).catch(() => undefined); if (userData?.isHighVip) { console.log(`[Points] Skip image points for VIP user=${userId} model=${modelId}`); return; } const modelConfig = await resolveBillingModelByReference(modelId); const unitCost = getModelCreditCost(modelConfig, "image"); if (unitCost <= 0) return; const totalCost = unitCost * savedImageCount; const consumeResult = await consumeUserPoints({ userId, amount: totalCost, reason: "image_generation", modelId: modelConfig?.id || modelId, chatId, messageId, note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`, }); if (!consumeResult.ok) { console.warn( `[Points] Failed to consume points after generation. user=${userId} model=${modelId} cost=${totalCost}`, ); } else if (consumeResult.skipped) { console.log( `[Points] Skipped ${totalCost} image points for VIP user=${userId} model=${modelId}`, ); } else { console.log( `[Points] Consumed ${totalCost} points (${unitCost} x ${savedImageCount}). balance=${consumeResult.balance}`, ); } } catch (error) { console.error("[Points] Failed to consume points after image generation:", error); } }