feat: sync latest canvas and media updates
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 17:31:32 +08:00
parent 5f849bf0b7
commit 12583cdd10
26 changed files with 1198 additions and 70 deletions

View File

@ -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<void> {
// 匹配 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) {

View File

@ -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]);

View File

@ -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({

View File

@ -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) {
);
}
}

View File

@ -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 },

View File

@ -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);

View File

@ -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;
@ -756,6 +823,56 @@ export async function POST(request: NextRequest) {
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)}秒`);
return NextResponse.json({
@ -997,6 +1114,7 @@ export async function POST(request: NextRequest) {
chatId,
messageId,
startedAt: startTime,
sourceImageUrls: sourceImageUrl ? [sourceImageUrl] : undefined,
});
return NextResponse.json(responsePayload);

View File

@ -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) {
);
}
}

View File

@ -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");
}

View File

@ -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<void> {
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)}...`

View File

@ -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) {

View File

@ -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(/\.[^.]+$/, ""),

View File

@ -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 || "拖拽上传",

View File

@ -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("尺寸调整结果已发送到画布");
},
[

View File

@ -1248,6 +1248,8 @@ export function Chat({
url: uploadResult.url,
name: fileName,
contentType: "image/png",
assetId:
typeof uploadResult.assetId === "string" ? uploadResult.assetId : undefined,
},
]);

View File

@ -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,
});
}

View File

@ -47,7 +47,7 @@ type DrawingAction = {
interface ImageAnnotatorProps {
imageUrl?: string;
onConfirm: (annotatedImageUrl: string) => void;
onConfirm: (annotatedImageUrl: string) => void | Promise<void>;
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 || "尺寸调整处理失败");
}

View File

@ -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<Attachment | null> => {
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,
},
]);
// 如果输入框为空,自动填入提示词

View File

@ -53,42 +53,45 @@ function normalizeImageInputRef(rawValue: unknown, requestOrigin?: string): stri
return null;
}
function collectImageInputsFromMessageParts(
function collectImageRefsFromMessageParts(
parts: Array<Record<string, any>> | 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<string>();
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<Record<string, any>> | 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",
})),

View File

@ -2,6 +2,7 @@ export interface CanvasLaunchAttachment {
url: string;
name: string;
contentType: string;
assetId?: string;
}
export interface CanvasLaunchImageParams {

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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<MediaAsset | null> {
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<MediaAsset[]> {
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<MediaAsset | null> {
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<MediaAssetRelation | null> {
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<string, MediaAsset>();
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,

View File

@ -742,3 +742,71 @@ export const messageTargetResolution = pgTable("MessageTargetResolution", {
});
export type MessageTargetResolution = InferSelectModel<typeof messageTargetResolution>;
// 统一媒体资产表(上传图 / 生成图 / 视频 / 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<typeof mediaAsset>;
// 媒体资产关系表(版本链 / 画布节点绑定 / 来源关系)
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<typeof mediaAssetRelation>;

View File

@ -55,4 +55,5 @@ export type Attachment = {
name: string;
url: string;
contentType: string;
assetId?: string;
};

View File

@ -137,7 +137,7 @@ export const canvasService = {
// 图片生成服务
export const imageGenerationService = {
// 上传图片到存储桶
async uploadImage(file: File): Promise<ApiResponse<{ url: string; pathname: string }>> {
async uploadImage(file: File): Promise<ApiResponse<{ url: string; pathname: string; assetId?: string }>> {
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) {