diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index c0b99dd5e..45ca26ff5 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -50,8 +50,11 @@ import { checkUserImageQuota, consumeUserPoints, createMessageTargetResolution, + createMediaAssetRelation, getConversationWorkingStateByChatId, + getMediaAssetsByUserAndUrls, upsertConversationWorkingState, + upsertMediaAsset, } from "@/lib/db/queries"; import { db } from "@/lib/db"; import type { DBMessage } from "@/lib/db/schema"; @@ -1628,14 +1631,41 @@ async function saveGeneratedImages( chatId: string, messageId: string, prompt: string, - modelId: string + modelId: string, + options?: { + sourceAssetIds?: string[]; + sourceImageUrls?: string[]; + relationType?: "generated_from" | "edited_from"; + sourceType?: string; + projectId?: string | null; + }, ): Promise { // 匹配 Markdown 图片格式: ![alt](url) 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] || ""; @@ -1643,7 +1673,7 @@ async function saveGeneratedImages( const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl); try { - await db.insert(generatedImage).values({ + const [savedImage] = await db.insert(generatedImage).values({ userId, chatId, messageId, @@ -1652,11 +1682,54 @@ async function saveGeneratedImages( 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); @@ -1668,8 +1741,11 @@ async function saveGeneratedImages( await upsertConversationWorkingState({ userId, chatId, + currentFocusAssetIds: savedAssetIds, currentFocusImageUrls: savedOriginalUrls, + lastGeneratedAssetIds: savedAssetIds, lastGeneratedImageUrls: savedOriginalUrls, + lastResolvedTargetAssetIds: savedAssetIds, lastResolvedTargetImageUrls: savedOriginalUrls, lastMessageId: messageId, }); @@ -3411,7 +3487,14 @@ export async function POST(request: Request) { id, messageId, userPrompt, - finalModelId + finalModelId, + { + sourceAssetIds: requestTargetResolution?.targetAssetIds, + sourceImageUrls: requestTargetResolution?.targetImageUrls, + relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from", + sourceType: requestResolvedMediaInput?.hasImage ? "generated" : "generated", + projectId: contextPayload?.projectId || null, + }, ); } catch (saveError) { console.error("[Chat API] ❌ Failed to save Gemini message to database:", saveError); @@ -3515,7 +3598,14 @@ export async function POST(request: Request) { id, messageId, userPrompt, - volcanoModel.model.id + volcanoModel.model.id, + { + sourceAssetIds: requestTargetResolution?.targetAssetIds, + sourceImageUrls: requestTargetResolution?.targetImageUrls, + relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from", + sourceType: "generated", + projectId: contextPayload?.projectId || null, + }, ); } catch (saveError) { console.error("[Chat API] Failed to save failover message:", saveError); @@ -3869,7 +3959,14 @@ export async function POST(request: Request) { id, messageId, userPrompt, - finalModelId + finalModelId, + { + sourceAssetIds: requestTargetResolution?.targetAssetIds, + sourceImageUrls: requestTargetResolution?.targetImageUrls, + relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from", + sourceType: "generated", + projectId: contextPayload?.projectId || null, + }, ); } catch (saveError) { console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError); @@ -4211,7 +4308,14 @@ export async function POST(request: Request) { id, messageId, userPrompt, - finalModelId + finalModelId, + { + sourceAssetIds: requestTargetResolution?.targetAssetIds, + sourceImageUrls: requestTargetResolution?.targetImageUrls, + relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from", + sourceType: "generated", + projectId: contextPayload?.projectId || null, + }, ); } catch (saveError) { console.error("[Chat API] ❌ Failed to save Volcano Vision message to database:", saveError); @@ -4834,7 +4938,14 @@ export async function POST(request: Request) { id, messageId, userPrompt, - finalModelId + finalModelId, + { + sourceAssetIds: requestTargetResolution?.targetAssetIds, + sourceImageUrls: requestTargetResolution?.targetImageUrls, + relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from", + sourceType: "generated", + projectId: contextPayload?.projectId || null, + }, ); return; } catch (error) { diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index f340e4363..31cb5fde2 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -10,6 +10,7 @@ const filePartSchema = z.object({ mediaType: z.enum(["image/jpeg", "image/png", "image/webp", "image/gif", "image/jpg", "image/bmp", "image/svg+xml"]), name: z.string().min(1).max(100), url: z.string().url(), + assetId: z.string().uuid().optional(), }); const partSchema = z.union([textPartSchema, filePartSchema]); diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index 77198dd3c..7dfa5fe93 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; +import { upsertMediaAsset } from "@/lib/db/queries"; import { uploadFile } from "@/lib/storage/service"; /** @@ -167,8 +168,34 @@ export async function POST(request: Request) { // 使用优化后的buffer上传 const data = await uploadFile(filename, optimizedArrayBuffer); + let assetId: string | null = null; + try { + const asset = await upsertMediaAsset({ + userId: session.user.id, + mediaType: "image", + sourceType: "upload", + status: "ready", + originalUrl: data.url, + previewUrl: data.url, + thumbnailUrl: data.url, + mimeType: data.contentType || file.type || null, + displayName: filename, + sourceTable: "upload", + metadata: { + pathname: data.pathname, + size: optimizedBuffer.length, + }, + }); + assetId = asset?.id ?? null; + } catch (assetError) { + console.warn("[Upload API] Failed to register uploaded image as media asset:", assetError); + } + console.log("[Upload API] Upload successful:", data.url); - return NextResponse.json(data); + return NextResponse.json({ + ...data, + assetId, + }); } catch (_error) { console.error("[Upload API] Upload failed:", _error); return NextResponse.json({ diff --git a/app/api/angle-edit/route.ts b/app/api/angle-edit/route.ts index ec34afc7a..d6a7fb659 100644 --- a/app/api/angle-edit/route.ts +++ b/app/api/angle-edit/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSystemSetting } from "@/lib/db/queries"; +import { + createMediaAssetRelation, + getMediaAssetsByUserAndUrls, + getSystemSetting, + upsertMediaAsset, +} from "@/lib/db/queries"; import { auth } from "@/app/(auth)/auth"; import { uploadFile } from "@/lib/storage/service"; import { db } from "@/lib/db"; @@ -214,14 +219,61 @@ export async function POST(request: NextRequest) { // 8. 保存到数据库 const processingTime = (Date.now() - startTime) / 1000; - await db.insert(generatedImage).values({ + const [savedImage] = await db.insert(generatedImage).values({ userId: session.user.id, thumbnailUrl: s3Result.thumbnail, originalUrl: s3Result.original, prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`, modelId: "qwen-image-edit-2511-multiple-angles", createdAt: new Date(), - }); + }).returning(); + + try { + const sourceAssets = + typeof image_url === "string" && image_url.trim() + ? await getMediaAssetsByUserAndUrls({ + userId: session.user.id, + urls: [image_url], + }) + : []; + const media = await upsertMediaAsset({ + userId: session.user.id, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl: s3Result.original, + previewUrl: s3Result.original, + thumbnailUrl: s3Result.thumbnail, + displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80), + prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`, + modelId: "qwen-image-edit-2511-multiple-angles", + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + primaryParentAssetId: sourceAssets[0]?.id ?? null, + metadata: { + horizontalAngle: finalHorizontalAngle, + verticalAngle: finalVerticalAngle, + zoom: finalZoom, + }, + }); + if (media) { + for (const sourceAsset of sourceAssets) { + await createMediaAssetRelation({ + userId: session.user.id, + fromAssetId: sourceAsset.id, + toAssetId: media.id, + relationType: "edited_from", + metadata: { + horizontalAngle: finalHorizontalAngle, + verticalAngle: finalVerticalAngle, + zoom: finalZoom, + }, + }); + } + } + } catch (assetError) { + console.error("[Angle-Edit] ⚠️ MediaAsset 注册失败:", assetError); + } console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒"); @@ -270,4 +322,3 @@ export async function POST(request: NextRequest) { ); } } - diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts index 5d5a72169..5cb264a85 100644 --- a/app/api/canvas/[id]/route.ts +++ b/app/api/canvas/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/app/(auth)/auth'; import { db } from '@/lib/db'; import { canvasProject } from '@/lib/db/schema'; +import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { eq, and } from 'drizzle-orm'; // 获取单个画布 @@ -52,6 +53,14 @@ export async function PUT( const { id } = await params; const body = await request.json(); const { title, description, thumbnail, canvasData } = body; + const syncedCanvasData = + canvasData !== undefined + ? await syncCanvasNodesForProject({ + projectId: id, + userId: session.user.id, + canvasData, + }) + : undefined; // 先检查画布是否存在 const [existingProject] = await db @@ -70,7 +79,7 @@ export async function PUT( if (title !== undefined) updateData.title = title; if (description !== undefined) updateData.description = description; if (thumbnail !== undefined) updateData.thumbnail = thumbnail; - if (canvasData !== undefined) updateData.canvasData = canvasData; + if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData; const [updatedProject] = await db .update(canvasProject) @@ -93,7 +102,7 @@ export async function PUT( userId: session.user.id, title: title || '未命名画布', description, - canvasData: canvasData || { + canvasData: syncedCanvasData || { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 }, diff --git a/app/api/canvas/route.ts b/app/api/canvas/route.ts index 0cf370c91..d8d793ee7 100644 --- a/app/api/canvas/route.ts +++ b/app/api/canvas/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/app/(auth)/auth'; import { db } from '@/lib/db'; import { canvasProject } from '@/lib/db/schema'; +import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { eq, desc } from 'drizzle-orm'; // 获取画布列表 @@ -36,7 +37,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { title, description, canvasData } = body; - const [newProject] = await db + const [insertedProject] = await db .insert(canvasProject) .values({ userId: session.user.id, @@ -50,6 +51,26 @@ export async function POST(request: NextRequest) { }) .returning(); + let newProject = insertedProject; + if (canvasData) { + const syncedCanvasData = await syncCanvasNodesForProject({ + projectId: insertedProject.id, + userId: session.user.id, + canvasData, + }); + const [updatedProject] = await db + .update(canvasProject) + .set({ + canvasData: syncedCanvasData, + updatedAt: new Date(), + }) + .where(eq(canvasProject.id, insertedProject.id)) + .returning(); + if (updatedProject) { + newProject = updatedProject; + } + } + return NextResponse.json({ success: true, data: newProject }); } catch (error) { console.error('创建画布失败:', error); diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts index 061bb943a..b19d81458 100644 --- a/app/api/enhance/route.ts +++ b/app/api/enhance/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSystemSetting } from "@/lib/db/queries"; +import { + createMediaAssetRelation, + getMediaAssetsByUserAndUrls, + getSystemSetting, + upsertMediaAsset, +} from "@/lib/db/queries"; import { auth } from "@/app/(auth)/auth"; import { uploadFile } from "@/lib/storage/service"; import { db } from "@/lib/db"; @@ -407,6 +412,7 @@ async function finalizeEnhanceTaskResult(params: { chatId?: string | null; messageId?: string | null; startedAt?: number; + sourceImageUrls?: string[]; }) { if (params.taskResult?.status !== 200 || !params.taskResult?.data) { throw new Error(params.taskResult?.message || "未知错误"); @@ -438,9 +444,14 @@ async function finalizeEnhanceTaskResult(params: { let saved = false; let saveError: unknown = null; + let savedImage: + | { + id: string; + } + | null = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { - await db.insert(generatedImage).values({ + ([savedImage] = await db.insert(generatedImage).values({ userId: params.userId, chatId: params.chatId || null, messageId: params.messageId || null, @@ -449,7 +460,7 @@ async function finalizeEnhanceTaskResult(params: { imageSize: "2K", prompt: finalPrompt, modelId: "enhance-1.0", - }); + }).returning()); saved = true; break; } catch (dbError) { @@ -464,6 +475,55 @@ async function finalizeEnhanceTaskResult(params: { throw new Error("图片变清晰成功但保存到相册失败,请稍后重试"); } + if (savedImage) { + try { + const sourceAssets = + Array.isArray(params.sourceImageUrls) && params.sourceImageUrls.length > 0 + ? await getMediaAssetsByUserAndUrls({ + userId: params.userId, + urls: params.sourceImageUrls, + }) + : []; + const media = await upsertMediaAsset({ + userId: params.userId, + chatId: params.chatId || null, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl: uploadResult.original, + previewUrl: uploadResult.original, + thumbnailUrl: uploadResult.thumbnail, + displayName: finalPrompt.slice(0, 80), + prompt: finalPrompt, + modelId: "enhance-1.0", + sourceMessageId: params.messageId || null, + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + primaryParentAssetId: sourceAssets[0]?.id ?? null, + metadata: { + imageSize: "2K", + }, + }); + if (media) { + for (const sourceAsset of sourceAssets) { + await createMediaAssetRelation({ + userId: params.userId, + chatId: params.chatId || null, + fromAssetId: sourceAsset.id, + toAssetId: media.id, + relationType: "edited_from", + sourceMessageId: params.messageId || null, + metadata: { + prompt: finalPrompt, + }, + }); + } + } + } catch (assetError) { + console.error("[Enhance] ⚠️ MediaAsset 注册失败:", assetError); + } + } + const processingTime = typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined; @@ -621,6 +681,7 @@ export async function POST(request: NextRequest) { let prompt = ""; // 用户提示词(可选) let chatId: string | null = null; // 聊天ID(可选) let messageId: string | null = null; // 消息ID(可选) + let sourceImageUrl: string | null = null; if (contentType.includes("application/json")) { console.log("[Enhance API] 解析JSON请求体..."); @@ -631,6 +692,7 @@ export async function POST(request: NextRequest) { // 优先使用 imageUrl,其次使用 image_base64 if (body.imageUrl) { + sourceImageUrl = String(body.imageUrl); // 从 URL 获取图片并转换为 Base64 console.log(`[Enhance API] 从 URL 获取图片: ${body.imageUrl}`); try { @@ -729,9 +791,14 @@ export async function POST(request: NextRequest) { // 步骤4: 保存到数据库 let saved = false; let saveError: unknown = null; + let savedImage: + | { + id: string; + } + | null = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { - const [savedImage] = await db.insert(generatedImage).values({ + ([savedImage] = await db.insert(generatedImage).values({ userId, chatId: chatId || null, messageId: messageId || null, @@ -740,7 +807,7 @@ export async function POST(request: NextRequest) { imageSize: `${config.backupScale}x放大`, prompt: prompt || "Real-ESRGAN图片变清晰处理", modelId: `realesrgan-${config.backupModel}`, - }).returning(); + }).returning()); console.log(`[Enhance-RealESRGAN] ✅ 保存到数据库成功,ID: ${savedImage.id}`); saved = true; break; @@ -755,6 +822,56 @@ export async function POST(request: NextRequest) { console.error("[Enhance-RealESRGAN] ❌ 保存到数据库失败:", saveError); throw new Error("图片变清晰成功但保存到相册失败,请稍后重试"); } + + if (savedImage) { + try { + const sourceAssets = + sourceImageUrl + ? await getMediaAssetsByUserAndUrls({ + userId, + urls: [sourceImageUrl], + }) + : []; + const media = await upsertMediaAsset({ + userId, + chatId, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl, + previewUrl: originalUrl, + thumbnailUrl, + displayName: (prompt || "Real-ESRGAN图片变清晰处理").slice(0, 80), + prompt: prompt || "Real-ESRGAN图片变清晰处理", + modelId: `realesrgan-${config.backupModel}`, + sourceMessageId: messageId, + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + primaryParentAssetId: sourceAssets[0]?.id ?? null, + metadata: { + imageSize: `${config.backupScale}x放大`, + apiType: "realesrgan-url", + }, + }); + if (media) { + for (const sourceAsset of sourceAssets) { + await createMediaAssetRelation({ + userId, + chatId, + fromAssetId: sourceAsset.id, + toAssetId: media.id, + relationType: "edited_from", + sourceMessageId: messageId, + metadata: { + prompt: prompt || "Real-ESRGAN图片变清晰处理", + }, + }); + } + } + } catch (assetError) { + console.error("[Enhance-RealESRGAN] ⚠️ MediaAsset 注册失败:", assetError); + } + } // 返回结果 console.log(`[Enhance-RealESRGAN] ✅ 全部完成,总耗时: ${result.processingTime.toFixed(2)}秒`); @@ -997,6 +1114,7 @@ export async function POST(request: NextRequest) { chatId, messageId, startedAt: startTime, + sourceImageUrls: sourceImageUrl ? [sourceImageUrl] : undefined, }); return NextResponse.json(responsePayload); diff --git a/app/api/resize/route.ts b/app/api/resize/route.ts index bb2e17850..a9fc95782 100644 --- a/app/api/resize/route.ts +++ b/app/api/resize/route.ts @@ -3,6 +3,7 @@ import { auth } from "@/app/(auth)/auth"; import { uploadFile } from "@/lib/storage/service"; import { db } from "@/lib/db"; import { generatedImage } from "@/lib/db/schema"; +import { upsertMediaAsset } from "@/lib/db/queries"; /** * 压缩图片生成缩略图(使用 sharp) @@ -174,6 +175,32 @@ export async function POST(request: NextRequest) { modelId: "resize-1.0", // 标记为尺寸调整处理 }).returning(); console.log(`[Resize] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`); + + try { + await upsertMediaAsset({ + userId, + chatId, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl, + previewUrl: originalUrl, + thumbnailUrl, + displayName: (prompt || "尺寸调整处理").slice(0, 80), + prompt: prompt || "尺寸调整处理", + modelId: "resize-1.0", + sourceMessageId: messageId, + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + metadata: { + imageSize: `${canvasWidth}x${canvasHeight}`, + canvasWidth, + canvasHeight, + }, + }); + } catch (assetError) { + console.error("[Resize] ⚠️ MediaAsset 注册失败:", assetError); + } } catch (dbError) { console.error("[Resize] ❌ 保存到数据库失败:", dbError); // 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问) @@ -196,4 +223,3 @@ export async function POST(request: NextRequest) { ); } } - diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts index f05c40df0..e53d2574d 100644 --- a/app/api/rmbg/route.ts +++ b/app/api/rmbg/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSystemSetting } from "@/lib/db/queries"; +import { + createMediaAssetRelation, + getMediaAssetsByUserAndUrls, + getSystemSetting, + upsertMediaAsset, +} from "@/lib/db/queries"; import { auth } from "@/app/(auth)/auth"; import { uploadFile } from "@/lib/storage/service"; import { db } from "@/lib/db"; @@ -502,6 +507,7 @@ export async function POST(request: NextRequest) { let chatId: string | null = null; let messageId: string | null = null; let skipGallerySave = false; + let sourceImageUrl: string | null = null; if (contentType.includes("application/json")) { const body = await request.json(); @@ -512,6 +518,7 @@ export async function POST(request: NextRequest) { const imageUrl = body.imageUrl || body.image_url; if (imageUrl) { + sourceImageUrl = String(imageUrl); console.log(`[RMBG] 从 URL 获取图片: ${imageUrl}`); try { const imageResponse = await fetch(imageUrl); @@ -650,9 +657,14 @@ export async function POST(request: NextRequest) { if (!skipGallerySave) { let saved = false; let saveError: unknown = null; + let savedImage: + | { + id: string; + } + | null = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { - const [savedImage] = await db.insert(generatedImage).values({ + ([savedImage] = await db.insert(generatedImage).values({ userId, chatId: chatId || null, messageId: messageId || null, @@ -661,7 +673,7 @@ export async function POST(request: NextRequest) { imageSize: "2K", prompt: prompt || "抠图处理", modelId: provider === "photoroom" ? "photoroom-hd" : provider === "aoscdn" ? "aos-segmentation" : "rmbg-2.0", - }).returning(); + }).returning()); console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`); saved = true; break; @@ -676,6 +688,62 @@ export async function POST(request: NextRequest) { console.error("[RMBG] ❌ 保存到数据库失败:", saveError); throw new Error("图片处理成功但保存到相册失败,请稍后重试"); } + if (savedImage) { + try { + const sourceAssets = + sourceImageUrl + ? await getMediaAssetsByUserAndUrls({ + userId, + urls: [sourceImageUrl], + }) + : []; + const modelId = + provider === "photoroom" + ? "photoroom-hd" + : provider === "aoscdn" + ? "aos-segmentation" + : "rmbg-2.0"; + const media = await upsertMediaAsset({ + userId, + chatId, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl, + previewUrl: originalUrl, + thumbnailUrl, + displayName: (prompt || "抠图处理").slice(0, 80), + prompt: prompt || "抠图处理", + modelId, + sourceMessageId: messageId, + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + primaryParentAssetId: sourceAssets[0]?.id ?? null, + metadata: { + provider, + imageSize: "2K", + }, + }); + if (media) { + for (const sourceAsset of sourceAssets) { + await createMediaAssetRelation({ + userId, + chatId, + fromAssetId: sourceAsset.id, + toAssetId: media.id, + relationType: "edited_from", + sourceMessageId: messageId, + metadata: { + prompt: prompt || "抠图处理", + provider, + }, + }); + } + } + } catch (assetError) { + console.error("[RMBG] ⚠️ MediaAsset 注册失败:", assetError); + } + } } else { console.log("[RMBG] Skip gallery save by request flag"); } diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 55d0a8255..01e0c1d08 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -10,7 +10,13 @@ import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { generatedImage } from "@/lib/db/schema"; -import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; +import { + createMediaAssetRelation, + getEnabledAiModelsWithProvider, + getMediaAssetsByUserAndUrls, + getSystemSetting, + upsertMediaAsset, +} from "@/lib/db/queries"; import { uploadFile } from "@/lib/storage/service"; import sharp from "sharp"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; @@ -921,11 +927,17 @@ async function saveSceneFusionImageRecord(params: { imageSize: string; prompt: string; modelId: string; + sourceImageUrls?: string[]; }): Promise { let lastError: unknown = null; + let savedImage: + | { + id: string; + } + | null = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { - await db.insert(generatedImage).values({ + ([savedImage] = await db.insert(generatedImage).values({ userId: params.userId, chatId: null, messageId: null, @@ -934,8 +946,8 @@ async function saveSceneFusionImageRecord(params: { imageSize: params.imageSize, prompt: params.prompt, modelId: params.modelId, - }); - return; + }).returning()); + break; } catch (error) { lastError = error; if (attempt < 3) { @@ -944,7 +956,53 @@ async function saveSceneFusionImageRecord(params: { } } - throw lastError instanceof Error ? lastError : new Error("保存到相册失败"); + if (!savedImage) { + throw lastError instanceof Error ? lastError : new Error("保存到相册失败"); + } + + try { + const sourceAssets = + Array.isArray(params.sourceImageUrls) && params.sourceImageUrls.length > 0 + ? await getMediaAssetsByUserAndUrls({ + userId: params.userId, + urls: params.sourceImageUrls, + }) + : []; + const media = await upsertMediaAsset({ + userId: params.userId, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl: params.originalUrl, + previewUrl: params.originalUrl, + thumbnailUrl: params.thumbnailUrl, + displayName: params.prompt.slice(0, 80), + prompt: params.prompt, + modelId: params.modelId, + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + primaryParentAssetId: sourceAssets[0]?.id ?? null, + metadata: { + imageSize: params.imageSize, + sourceImageUrls: params.sourceImageUrls ?? [], + }, + }); + if (media) { + for (const sourceAsset of sourceAssets) { + await createMediaAssetRelation({ + userId: params.userId, + fromAssetId: sourceAsset.id, + toAssetId: media.id, + relationType: "edited_from", + metadata: { + prompt: params.prompt, + }, + }); + } + } + } catch (assetError) { + console.error("[SceneFusion] ⚠️ MediaAsset 注册失败:", assetError); + } } /** @@ -1856,6 +1914,7 @@ export async function POST(request: Request) { imageSize: dbImageSize, prompt: `[场景裂变] ${result.prompt}`, modelId: candidate.model.modelId, + sourceImageUrls: [fusionProductImageUrl, resolvedScene.imageUrl], }); console.log( `[SceneFusion] ✅ Saved image to gallery: ${result.original.substring(0, 80)}...` diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index a6f5367d7..ea477d911 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -4,6 +4,11 @@ import { db } from "@/lib/db"; import { systemSettings, generatedImage } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { uploadFile } from "@/lib/storage/service"; +import { + createMediaAssetRelation, + getMediaAssetsByUserAndUrls, + upsertMediaAsset, +} from "@/lib/db/queries"; // PicWish API 需要公网可访问的 HTTP/HTTPS URL const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; @@ -112,6 +117,7 @@ async function finalizeWatermarkTaskResult(params: { userId: string; startedAt?: number; apiKey: string; + sourceImageUrls?: string[]; }): Promise<{ success: boolean; original_url: string; @@ -138,16 +144,21 @@ async function finalizeWatermarkTaskResult(params: { let saved = false; let saveError: unknown = null; + let savedImage: + | { + id: string; + } + | null = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { - await db.insert(generatedImage).values({ + ([savedImage] = await db.insert(generatedImage).values({ userId: params.userId, thumbnailUrl: uploadResult.url, originalUrl: uploadResult.url, imageSize: "2K", prompt: "去水印处理", modelId: "watermark-remove", - }); + }).returning()); saved = true; break; } catch (error) { @@ -162,6 +173,51 @@ async function finalizeWatermarkTaskResult(params: { throw new Error("去水印成功但保存到相册失败,请稍后重试"); } + if (savedImage) { + try { + const sourceAssets = + Array.isArray(params.sourceImageUrls) && params.sourceImageUrls.length > 0 + ? await getMediaAssetsByUserAndUrls({ + userId: params.userId, + urls: params.sourceImageUrls, + }) + : []; + const media = await upsertMediaAsset({ + userId: params.userId, + mediaType: "image", + sourceType: "edited", + status: "ready", + originalUrl: uploadResult.url, + previewUrl: uploadResult.url, + thumbnailUrl: uploadResult.url, + displayName: "去水印处理", + prompt: "去水印处理", + modelId: "watermark-remove", + sourceTable: "GeneratedImage", + sourceRowId: savedImage.id, + primaryParentAssetId: sourceAssets[0]?.id ?? null, + metadata: { + imageSize: "2K", + }, + }); + if (media) { + for (const sourceAsset of sourceAssets) { + await createMediaAssetRelation({ + userId: params.userId, + fromAssetId: sourceAsset.id, + toAssetId: media.id, + relationType: "edited_from", + metadata: { + prompt: "去水印处理", + }, + }); + } + } + } catch (assetError) { + console.error("[WatermarkRemove] ⚠️ MediaAsset 注册失败:", assetError); + } + } + return { success: true, original_url: uploadResult.url, @@ -358,6 +414,10 @@ export async function POST(request: NextRequest) { userId: session.user.id, startedAt: startTime, apiKey: config.apiKey, + sourceImageUrls: + typeof image_url === "string" && /^https?:\/\//i.test(image_url) + ? [image_url] + : undefined, }); return NextResponse.json(result); } catch (error) { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1ade4abf9..bfcca5e80 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2024,6 +2024,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF url: src, name: nodeTitle, contentType: "image/jpeg", + assetId: + typeof (node?.data as any)?.assetId === "string" && (node?.data as any)?.assetId.trim() + ? String((node?.data as any)?.assetId).trim() + : undefined, }; }, [] @@ -2146,6 +2150,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF url: String(item.url), name: String(item.name || "参考图"), contentType: String(item.contentType || "image/jpeg"), + assetId: + typeof item.assetId === "string" && item.assetId.trim() + ? item.assetId.trim() + : undefined, })) : []; const normalizedPrompt = String(parsed.prompt || "").trim(); @@ -3432,6 +3440,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF mediaType: attachment.contentType || "image/jpeg", name: attachment.name || "参考图", url: attachment.url, + assetId: attachment.assetId, })); if (textContent) { @@ -4077,11 +4086,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const data = await response.json(); const imageUrl = data.url as string; + const assetId = + typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; updateAttachmentAtVideoSlot(slotIndex, { url: imageUrl, name: file.name, contentType: file.type || "image/jpeg", + assetId, }); const dimensions = await getImageDimensions(imageUrl); @@ -4244,6 +4256,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const data = await response.json(); const imageUrl = String(data.url || "").trim(); + const assetId = + typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; if (!imageUrl) { throw new Error("未获取到图片地址"); } @@ -4252,6 +4266,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF url: imageUrl, name: file.name, contentType: file.type || "image/jpeg", + assetId, }); toast.success(`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}上传成功`); } catch (_error) { @@ -5933,6 +5948,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF mediaType: a.contentType, name: a.name, url: a.url, + assetId: a.assetId, hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注 pickedObject: att.pickedObject, // 🆕 包含物品信息 }); @@ -6142,7 +6158,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const uploadImageFileAndAddToCanvas = useCallback(async ( file: File, nodeIndex: number - ): Promise<{ imageUrl: string; name: string; contentType: string } | null> => { + ): Promise<{ imageUrl: string; name: string; contentType: string; assetId?: string } | null> => { if (!file.type.startsWith('image/')) { toast.error('请选择图片文件'); return null; @@ -6168,6 +6184,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const data = await response.json(); const imageUrl = data.url as string; + const assetId = + typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; const dimensions = await getImageDimensions(imageUrl); const spacing = 20; const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); @@ -6179,6 +6197,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF size: { width: dimensions.width, height: dimensions.height }, data: { src: imageUrl, + assetId, status: 'completed', title: fileName.replace(/\.[^.]+$/, ""), prompt: fileName.replace(/\.[^.]+$/, ""), @@ -6194,6 +6213,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF imageUrl, name: fileName, contentType: file.type || 'image/jpeg', + assetId, }; } catch (_error) { toast.error('图片上传失败'); @@ -6323,6 +6343,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const data = await response.json(); const imageUrl = data.url; + const assetId = + typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; // 记录第一张图片的 URL(用于自动命名和缩略图) if (!firstImageUrl) { @@ -6341,6 +6363,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF url: imageUrl, name: file.name, contentType: file.type, + assetId, }]; }); @@ -6359,6 +6382,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF size: { width: dimensions.width, height: dimensions.height }, data: { src: imageUrl, + assetId, status: 'completed', title: file.name.replace(/\.[^.]+$/, ""), prompt: file.name.replace(/\.[^.]+$/, ""), diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 095cf4d39..7cdd3ba5a 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -647,6 +647,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) { updateNode(placeholderId, { data: { src: result.data.url, + assetId: result.data.assetId, status: "completed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index f82adf218..5d1d2499d 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1270,6 +1270,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { data: { ...data, src: result.data.url, + assetId: result.data.assetId, status: 'completed', title: normalizeImageTitle(data.title) || fileBaseName, }, @@ -1581,7 +1582,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const handleResizeEditorComplete = useCallback( async (resizedImageUrl: string) => { - setResizeAnnotatorOpen(false); let finalUrl = resizedImageUrl; if (resizedImageUrl.startsWith("data:")) { @@ -1613,6 +1613,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { connections: [node.id], }); + setResizeAnnotatorOpen(false); toast.success("尺寸调整结果已发送到画布"); }, [ diff --git a/components/chat.tsx b/components/chat.tsx index 782d38f46..ece54612d 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1248,6 +1248,8 @@ export function Chat({ url: uploadResult.url, name: fileName, contentType: "image/png", + assetId: + typeof uploadResult.assetId === "string" ? uploadResult.assetId : undefined, }, ]); diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index c8c39d69e..7dfbade40 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -845,6 +845,7 @@ export function LovartHome({ url: data.url, name: item.file.name, contentType: item.file.type || "image/jpeg", + assetId: typeof data.assetId === "string" ? data.assetId : undefined, }); } diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 4255cdca0..2831d2abd 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -47,7 +47,7 @@ type DrawingAction = { interface ImageAnnotatorProps { imageUrl?: string; - onConfirm: (annotatedImageUrl: string) => void; + onConfirm: (annotatedImageUrl: string) => void | Promise; onCancel: () => void; onHomeClick?: () => void; initialResizeMode?: boolean; // 是否直接进入尺寸调整模式 @@ -1144,7 +1144,8 @@ export function ImageAnnotator({ const result = await response.json(); if (result.success) { - // 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图) + const finalResultUrl = result.original_url || dataUrl; + if (result.original_url) { console.log("[尺寸调整] 原图URL:", result.original_url); setProcessedImageUrl(result.original_url); @@ -1153,10 +1154,8 @@ export function ImageAnnotator({ } setProcessedImageBase64(base64Data); setProcessType("resize"); - // 不退出尺寸调整模式,保持原图显示 - // setIsResizeMode(false); - - toast.success(`尺寸调整完成!图片已调整为 ${resizeCanvasWidth}x${resizeCanvasHeight} 像素,已保存到相册`); + + await Promise.resolve(onConfirm(finalResultUrl)); } else { throw new Error(result.error || "尺寸调整处理失败"); } diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 616b7c74e..0efc0d2ce 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -430,6 +430,7 @@ function PureMultimodalInput({ url: attachment.url, name: attachment.name, mediaType: attachment.contentType, + assetId: attachment.assetId, })), { type: "text", @@ -594,13 +595,13 @@ function PureMultimodalInput({ }); }, []); - const uploadFile = useCallback(async (file: File) => { + const uploadFile = useCallback(async (file: File): Promise => { try { // 先压缩图片 console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`); const processedFile = await compressImage(file); - const formData = new FormData(); + const formData = new FormData(); formData.append("file", processedFile); const response = await fetchUploadWithRetry(formData); @@ -616,14 +617,17 @@ function PureMultimodalInput({ url, // 完整的可访问 URL name: file.name, // 使用原始文件名,不是 pathname contentType, + assetId: typeof data.assetId === "string" ? data.assetId : undefined, }; } const { error } = await response.json(); console.error(`[上传] 服务器返回错误:`, error); toast.error(error); + return null; } catch (_error) { console.error("[上传] 失败:", _error); toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试"); + return null; } }, [compressImage]); @@ -799,7 +803,7 @@ function PureMultimodalInput({ const uploadPromises = filesToUpload.map((file) => uploadFile(file)); const uploadedAttachments = await Promise.all(uploadPromises); const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => attachment !== undefined + (attachment): attachment is Attachment => attachment !== null ); setAttachments((currentAttachments) => [ @@ -852,15 +856,15 @@ function PureMultimodalInput({ const uploadedAttachments = await Promise.all(uploadPromises); const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => - attachment !== undefined && + (attachment): attachment is Attachment => + attachment !== null && attachment.url !== undefined && attachment.contentType !== undefined ); setAttachments((curr) => [ ...curr, - ...(successfullyUploadedAttachments as Attachment[]), + ...successfullyUploadedAttachments, ]); } catch (error) { console.error("Error uploading pasted images:", error); @@ -952,7 +956,7 @@ function PureMultimodalInput({ ); const validAttachments = uploadedAttachments.filter( - (attachment): attachment is Attachment => attachment !== undefined + (attachment): attachment is Attachment => attachment !== null ); setAttachments((currentAttachments) => [ @@ -1157,6 +1161,7 @@ function PureMultimodalInput({ // 如果仍然是 base64 格式,先上传到服务器 let finalUrl = annotatedImageUrl; + let assetId: string | undefined; if (annotatedImageUrl.startsWith("data:")) { try { toast.info("正在上传图片..."); @@ -1168,6 +1173,10 @@ function PureMultimodalInput({ if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; + assetId = + typeof result.assetId === "string" && result.assetId.trim() + ? result.assetId.trim() + : undefined; } } catch (error) { console.error("上传标注图片失败:", error); @@ -1182,6 +1191,7 @@ function PureMultimodalInput({ url: finalUrl, name: fileName, contentType: "image/png", + assetId, }, ]); // 如果输入框为空,自动填入提示词 diff --git a/lib/ai/context-memory/media-input.ts b/lib/ai/context-memory/media-input.ts index db1d9e3cd..955d16aa1 100644 --- a/lib/ai/context-memory/media-input.ts +++ b/lib/ai/context-memory/media-input.ts @@ -53,42 +53,45 @@ function normalizeImageInputRef(rawValue: unknown, requestOrigin?: string): stri return null; } -function collectImageInputsFromMessageParts( +function collectImageRefsFromMessageParts( parts: Array> | undefined, requestOrigin?: string, -): string[] { +): Array<{ url: string; assetId?: string }> { if (!Array.isArray(parts) || parts.length === 0) return []; - const result: string[] = []; + const result: Array<{ url: string; assetId?: string }> = []; const seen = new Set(); - const pushInput = (rawValue: unknown) => { + const pushInput = (rawValue: unknown, assetId?: unknown) => { const normalized = normalizeImageInputRef(rawValue, requestOrigin); if (!normalized) return; const key = normalizeMediaUrl(normalized); if (!key || seen.has(key)) return; seen.add(key); - result.push(normalized); + result.push({ + url: normalized, + assetId: typeof assetId === "string" && assetId.trim() ? assetId.trim() : undefined, + }); }; for (const part of parts) { if (!part) continue; if (part.type === "file") { - pushInput(part.url); - pushInput(part.data); + pushInput(part.url, part.assetId); + pushInput(part.data, part.assetId); continue; } if (part.type === "image") { - pushInput(part.url); - pushInput(part.image); - pushInput(part.data); + pushInput(part.url, part.assetId); + pushInput(part.image, part.assetId); + pushInput(part.data, part.assetId); continue; } if (part.type === "image_url") { - pushInput(part.image_url?.url); - pushInput(part.url); + pushInput(part.image_url?.url, part.assetId); + pushInput(part.url, part.assetId); continue; } @@ -175,12 +178,12 @@ function extractHistoryImageRefs(params: { const parts = historyMessage.parts as Array> | undefined; if (!Array.isArray(parts) || parts.length === 0) continue; - const urls = collectImageInputsFromMessageParts(parts, params.requestOrigin); - if (!urls.length) continue; + const refs = collectImageRefsFromMessageParts(parts, params.requestOrigin); + if (!refs.length) continue; if (historyMessage.role === "assistant" && latestAssistant.length < params.maxImages) { - for (const url of urls) { - pushRef(latestAssistant, seenAssistant, url, { + for (const ref of refs) { + pushRef(latestAssistant, seenAssistant, ref.url, { source: "history_assistant_result", role: "history_result", messageId: historyMessage.id, @@ -189,8 +192,8 @@ function extractHistoryImageRefs(params: { } if (historyMessage.role === "user" && latestUser.length < params.maxImages) { - for (const url of urls) { - pushRef(latestUser, seenUser, url, { + for (const ref of refs) { + pushRef(latestUser, seenUser, ref.url, { source: "history_user_upload", role: "reference", messageId: historyMessage.id, @@ -214,9 +217,10 @@ export function resolveChatMediaInput( ): ResolvedChatMediaInput { const maxImages = Math.max(1, Math.min(12, Math.round(params.maxImages || 4))); const currentMessageImageRefs = dedupeRefs( - collectImageInputsFromMessageParts(params.messageParts, params.requestOrigin).map((url) => ({ - url, - normalizedUrl: normalizeMediaUrl(url), + collectImageRefsFromMessageParts(params.messageParts, params.requestOrigin).map((ref) => ({ + url: ref.url, + normalizedUrl: normalizeMediaUrl(ref.url), + assetId: ref.assetId, source: "current_message", role: "upload", })), diff --git a/lib/canvas-launch.ts b/lib/canvas-launch.ts index 9475b298c..8d3cba66d 100644 --- a/lib/canvas-launch.ts +++ b/lib/canvas-launch.ts @@ -2,6 +2,7 @@ export interface CanvasLaunchAttachment { url: string; name: string; contentType: string; + assetId?: string; } export interface CanvasLaunchImageParams { diff --git a/lib/db/migrations/0051_add_media_assets.sql b/lib/db/migrations/0051_add_media_assets.sql new file mode 100644 index 000000000..9585289dc --- /dev/null +++ b/lib/db/migrations/0051_add_media_assets.sql @@ -0,0 +1,123 @@ +CREATE TABLE IF NOT EXISTS "MediaAsset" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" uuid NOT NULL, + "chatId" uuid, + "projectId" uuid, + "mediaType" varchar(32) NOT NULL, + "sourceType" varchar(32) NOT NULL, + "status" varchar(32) DEFAULT 'ready' NOT NULL, + "originalUrl" text NOT NULL, + "normalizedUrl" text NOT NULL, + "previewUrl" text, + "thumbnailUrl" text, + "mimeType" varchar(128), + "width" integer, + "height" integer, + "durationMs" integer, + "displayName" varchar(256), + "prompt" text, + "modelId" varchar(128), + "sourceMessageId" uuid, + "sourceTable" varchar(64), + "sourceRowId" varchar(128), + "sourceNodeId" varchar(128), + "rootAssetId" uuid, + "primaryParentAssetId" uuid, + "summary" jsonb, + "metadata" jsonb, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "MediaAssetRelation" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" uuid NOT NULL, + "chatId" uuid, + "projectId" uuid, + "fromAssetId" uuid NOT NULL, + "toAssetId" uuid NOT NULL, + "relationType" varchar(64) NOT NULL, + "sourceMessageId" uuid, + "sourceNodeId" varchar(128), + "confidence" integer DEFAULT 100 NOT NULL, + "metadata" jsonb, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_projectId_CanvasProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."CanvasProject"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_sourceMessageId_Message_v2_id_fk" FOREIGN KEY ("sourceMessageId") REFERENCES "public"."Message_v2"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAssetRelation" ADD CONSTRAINT "MediaAssetRelation_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAssetRelation" ADD CONSTRAINT "MediaAssetRelation_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAssetRelation" ADD CONSTRAINT "MediaAssetRelation_projectId_CanvasProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."CanvasProject"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAssetRelation" ADD CONSTRAINT "MediaAssetRelation_fromAssetId_MediaAsset_id_fk" FOREIGN KEY ("fromAssetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAssetRelation" ADD CONSTRAINT "MediaAssetRelation_toAssetId_MediaAsset_id_fk" FOREIGN KEY ("toAssetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "MediaAssetRelation" ADD CONSTRAINT "MediaAssetRelation_sourceMessageId_Message_v2_id_fk" FOREIGN KEY ("sourceMessageId") REFERENCES "public"."Message_v2"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "MediaAsset_user_normalized_uidx" + ON "MediaAsset"("userId", "normalizedUrl"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "MediaAsset_chat_created_idx" + ON "MediaAsset"("chatId", "createdAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "MediaAsset_project_created_idx" + ON "MediaAsset"("projectId", "createdAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "MediaAsset_source_message_idx" + ON "MediaAsset"("sourceMessageId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "MediaAssetRelation_project_type_idx" + ON "MediaAssetRelation"("projectId", "relationType"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "MediaAssetRelation_message_type_idx" + ON "MediaAssetRelation"("sourceMessageId", "relationType"); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 9fb54e0ec..a59ed7318 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1772788800000, "tag": "0050_add_context_memory_tables", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1772792400000, + "tag": "0051_add_media_assets", + "breakpoints": true } ] } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index dd18d4736..6179b14d8 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -56,6 +56,12 @@ import { type ConversationWorkingState, messageTargetResolution, type MessageTargetResolution, + mediaAsset, + type MediaAsset, + mediaAssetRelation, + type MediaAssetRelation, + canvasNode, + canvasProject, } from "./schema"; import { generateHashedPassword } from "./utils"; import { @@ -69,6 +75,7 @@ import { normalizePointsSystemConfig, POINTS_SYSTEM_CONFIG_KEY, } from "../points/config"; +import { inferOriginalImageUrl } from "../image-url"; // Optionally, if not using email/pass login, you can // use the Drizzle adapter for Auth.js / NextAuth @@ -682,6 +689,23 @@ function normalizeStringArray(value?: string[] | null): string[] { return Array.from(deduped); } +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 async function getConversationWorkingStateByChatId({ chatId, }: { @@ -909,6 +933,316 @@ export async function createMessageTargetResolution(params: { } } +export async function getMediaAssetByUserAndUrl(params: { + userId: string; + url: string; +}): Promise { + const normalizedUrl = normalizeMediaUrl(params.url); + if (!normalizedUrl) return null; + try { + const rows = await db + .select() + .from(mediaAsset) + .where( + and( + eq(mediaAsset.userId, params.userId), + eq(mediaAsset.normalizedUrl, normalizedUrl), + ), + ) + .limit(1); + return rows[0] ?? null; + } catch (_error) { + throw new ChatSDKError( + "bad_request:database", + "Failed to get media asset by url" + ); + } +} + +export async function getMediaAssetsByUserAndUrls(params: { + userId: string; + urls: string[]; +}): Promise { + const normalizedUrls = Array.from( + new Set(params.urls.map((url) => normalizeMediaUrl(url)).filter(Boolean)), + ); + if (!normalizedUrls.length) return []; + try { + return await db + .select() + .from(mediaAsset) + .where( + and( + eq(mediaAsset.userId, params.userId), + inArray(mediaAsset.normalizedUrl, normalizedUrls), + ), + ); + } catch (_error) { + throw new ChatSDKError( + "bad_request:database", + "Failed to get media assets by urls" + ); + } +} + +export async function upsertMediaAsset(params: { + userId: string; + chatId?: string | null; + projectId?: string | null; + mediaType: string; + sourceType: string; + status?: string; + originalUrl: string; + previewUrl?: string | null; + thumbnailUrl?: string | null; + mimeType?: string | null; + width?: number | null; + height?: number | null; + durationMs?: number | null; + displayName?: string | null; + prompt?: string | null; + modelId?: string | null; + sourceMessageId?: string | null; + sourceTable?: string | null; + sourceRowId?: string | null; + sourceNodeId?: string | null; + rootAssetId?: string | null; + primaryParentAssetId?: string | null; + summary?: any; + metadata?: any; +}): Promise { + const originalUrl = String(params.originalUrl || "").trim(); + const normalizedUrl = normalizeMediaUrl(originalUrl); + if (!originalUrl || !normalizedUrl) return null; + + try { + const now = new Date(); + const [result] = await db + .insert(mediaAsset) + .values({ + userId: params.userId, + chatId: params.chatId ?? null, + projectId: params.projectId ?? null, + mediaType: params.mediaType, + sourceType: params.sourceType, + status: params.status || "ready", + originalUrl, + normalizedUrl, + previewUrl: params.previewUrl ?? null, + thumbnailUrl: params.thumbnailUrl ?? null, + mimeType: params.mimeType ?? null, + width: params.width ?? null, + height: params.height ?? null, + durationMs: params.durationMs ?? null, + displayName: params.displayName ?? null, + prompt: params.prompt ?? null, + modelId: params.modelId ?? null, + sourceMessageId: params.sourceMessageId ?? null, + sourceTable: params.sourceTable ?? null, + sourceRowId: params.sourceRowId ?? null, + sourceNodeId: params.sourceNodeId ?? null, + rootAssetId: params.rootAssetId ?? null, + primaryParentAssetId: params.primaryParentAssetId ?? null, + summary: params.summary ?? null, + metadata: params.metadata ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [mediaAsset.userId, mediaAsset.normalizedUrl], + set: { + chatId: params.chatId ?? undefined, + projectId: params.projectId ?? undefined, + mediaType: params.mediaType, + sourceType: params.sourceType, + status: params.status || "ready", + originalUrl, + previewUrl: params.previewUrl ?? null, + thumbnailUrl: params.thumbnailUrl ?? null, + mimeType: params.mimeType ?? null, + width: params.width ?? null, + height: params.height ?? null, + durationMs: params.durationMs ?? null, + displayName: params.displayName ?? null, + prompt: params.prompt ?? null, + modelId: params.modelId ?? null, + sourceMessageId: params.sourceMessageId ?? null, + sourceTable: params.sourceTable ?? null, + sourceRowId: params.sourceRowId ?? null, + sourceNodeId: params.sourceNodeId ?? null, + rootAssetId: params.rootAssetId ?? null, + primaryParentAssetId: params.primaryParentAssetId ?? null, + summary: params.summary ?? null, + metadata: params.metadata ?? null, + updatedAt: now, + }, + }) + .returning(); + + return result ?? null; + } catch (_error) { + throw new ChatSDKError( + "bad_request:database", + "Failed to upsert media asset" + ); + } +} + +export async function createMediaAssetRelation(params: { + userId: string; + fromAssetId: string; + toAssetId: string; + relationType: string; + chatId?: string | null; + projectId?: string | null; + sourceMessageId?: string | null; + sourceNodeId?: string | null; + confidence?: number | null; + metadata?: any; +}): Promise { + try { + const [result] = await db + .insert(mediaAssetRelation) + .values({ + userId: params.userId, + chatId: params.chatId ?? null, + projectId: params.projectId ?? null, + fromAssetId: params.fromAssetId, + toAssetId: params.toAssetId, + relationType: params.relationType, + sourceMessageId: params.sourceMessageId ?? null, + sourceNodeId: params.sourceNodeId ?? null, + confidence: Math.max(0, Math.min(100, Math.round(params.confidence ?? 100))), + metadata: params.metadata ?? null, + createdAt: new Date(), + }) + .returning(); + return result ?? null; + } catch (_error) { + throw new ChatSDKError( + "bad_request:database", + "Failed to create media asset relation" + ); + } +} + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function syncCanvasNodesForProject(params: { + projectId: string; + userId: string; + canvasData: { + nodes?: any[]; + edges?: any[]; + viewport?: { x: number; y: number; zoom: number }; + } | null | undefined; +}) { + const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : []; + const assetUrls = Array.from( + new Set( + nodes + .map((node) => String(node?.data?.src || "").trim()) + .filter(Boolean), + ), + ); + const matchedAssets = assetUrls.length + ? await getMediaAssetsByUserAndUrls({ + userId: params.userId, + urls: assetUrls, + }) + : []; + const assetByUrl = new Map(); + for (const asset of matchedAssets) { + assetByUrl.set(asset.normalizedUrl, asset); + } + + const enrichedNodes = nodes.map((node) => { + const src = String(node?.data?.src || "").trim(); + const normalizedUrl = normalizeMediaUrl(src); + const matchedAsset = normalizedUrl ? assetByUrl.get(normalizedUrl) : null; + const nextData = + matchedAsset && !node?.data?.assetId + ? { + ...(node?.data || {}), + assetId: matchedAsset.id, + } + : node?.data; + return { + ...node, + data: nextData, + }; + }); + + await db.delete(mediaAssetRelation).where( + and( + eq(mediaAssetRelation.projectId, params.projectId), + eq(mediaAssetRelation.relationType, "located_on_canvas"), + ), + ); + await db.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId)); + + const now = new Date(); + const insertableNodes = enrichedNodes.map((node) => { + const nodeId = + typeof node?.id === "string" && UUID_REGEX.test(node.id) + ? node.id + : generateUUID(); + return { + id: nodeId, + projectId: params.projectId, + type: String(node?.type || "text").slice(0, 32), + position: node?.position || null, + size: node?.size || null, + data: node?.data || null, + connections: Array.isArray(node?.connections) + ? node.connections.filter((value: unknown) => typeof value === "string") + : [], + parentId: + typeof node?.parentId === "string" && UUID_REGEX.test(node.parentId) + ? node.parentId + : null, + createdAt: + node?.createdAt instanceof Date + ? node.createdAt + : typeof node?.createdAt === "string" + ? new Date(node.createdAt) + : now, + updatedAt: now, + }; + }); + + if (insertableNodes.length > 0) { + await db.insert(canvasNode).values(insertableNodes); + } + + for (const node of enrichedNodes) { + const assetId = + typeof node?.data?.assetId === "string" && node.data.assetId.trim() + ? node.data.assetId.trim() + : null; + if (!assetId) continue; + await createMediaAssetRelation({ + userId: params.userId, + fromAssetId: assetId, + toAssetId: assetId, + relationType: "located_on_canvas", + projectId: params.projectId, + sourceNodeId: typeof node.id === "string" ? node.id : null, + metadata: { + nodeType: node.type, + position: node.position || null, + size: node.size || null, + }, + }); + } + + return { + ...params.canvasData, + nodes: enrichedNodes, + }; +} + export async function getMessageCountByUserId({ id, differenceInHours, diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 40d710048..a3aa1a8f2 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -742,3 +742,71 @@ export const messageTargetResolution = pgTable("MessageTargetResolution", { }); export type MessageTargetResolution = InferSelectModel; + +// 统一媒体资产表(上传图 / 生成图 / 视频 / 3D 预览的统一索引) +export const mediaAsset = pgTable("MediaAsset", { + id: uuid("id").primaryKey().notNull().defaultRandom(), + userId: uuid("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + chatId: uuid("chatId").references(() => chat.id, { onDelete: "cascade" }), + projectId: uuid("projectId").references(() => canvasProject.id, { + onDelete: "cascade", + }), + mediaType: varchar("mediaType", { length: 32 }).notNull(), + sourceType: varchar("sourceType", { length: 32 }).notNull(), + status: varchar("status", { length: 32 }).notNull().default("ready"), + originalUrl: text("originalUrl").notNull(), + normalizedUrl: text("normalizedUrl").notNull(), + previewUrl: text("previewUrl"), + thumbnailUrl: text("thumbnailUrl"), + mimeType: varchar("mimeType", { length: 128 }), + width: integer("width"), + height: integer("height"), + durationMs: integer("durationMs"), + displayName: varchar("displayName", { length: 256 }), + prompt: text("prompt"), + modelId: varchar("modelId", { length: 128 }), + sourceMessageId: uuid("sourceMessageId").references(() => message.id, { + onDelete: "set null", + }), + sourceTable: varchar("sourceTable", { length: 64 }), + sourceRowId: varchar("sourceRowId", { length: 128 }), + sourceNodeId: varchar("sourceNodeId", { length: 128 }), + rootAssetId: uuid("rootAssetId"), + primaryParentAssetId: uuid("primaryParentAssetId"), + summary: jsonb("summary"), + metadata: jsonb("metadata"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type MediaAsset = InferSelectModel; + +// 媒体资产关系表(版本链 / 画布节点绑定 / 来源关系) +export const mediaAssetRelation = pgTable("MediaAssetRelation", { + id: uuid("id").primaryKey().notNull().defaultRandom(), + userId: uuid("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + chatId: uuid("chatId").references(() => chat.id, { onDelete: "cascade" }), + projectId: uuid("projectId").references(() => canvasProject.id, { + onDelete: "cascade", + }), + fromAssetId: uuid("fromAssetId").notNull().references(() => mediaAsset.id, { + onDelete: "cascade", + }), + toAssetId: uuid("toAssetId").notNull().references(() => mediaAsset.id, { + onDelete: "cascade", + }), + relationType: varchar("relationType", { length: 64 }).notNull(), + sourceMessageId: uuid("sourceMessageId").references(() => message.id, { + onDelete: "set null", + }), + sourceNodeId: varchar("sourceNodeId", { length: 128 }), + confidence: integer("confidence").notNull().default(100), + metadata: jsonb("metadata"), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export type MediaAssetRelation = InferSelectModel; diff --git a/lib/types.ts b/lib/types.ts index 4a9ad92bd..c5995e81b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -55,4 +55,5 @@ export type Attachment = { name: string; url: string; contentType: string; + assetId?: string; }; diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 7b80a4bcd..ca3722c87 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -137,7 +137,7 @@ export const canvasService = { // 图片生成服务 export const imageGenerationService = { // 上传图片到存储桶 - async uploadImage(file: File): Promise> { + async uploadImage(file: File): Promise> { try { const formData = new FormData(); formData.append('file', file); @@ -157,7 +157,8 @@ export const imageGenerationService = { success: true, data: { url: data.url, - pathname: data.pathname + pathname: data.pathname, + assetId: typeof data.assetId === "string" ? data.assetId : undefined, } }; } catch (error) {