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, checkUserImageQuota,
consumeUserPoints, consumeUserPoints,
createMessageTargetResolution, createMessageTargetResolution,
createMediaAssetRelation,
getConversationWorkingStateByChatId, getConversationWorkingStateByChatId,
getMediaAssetsByUserAndUrls,
upsertConversationWorkingState, upsertConversationWorkingState,
upsertMediaAsset,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
@ -1628,14 +1631,41 @@ async function saveGeneratedImages(
chatId: string, chatId: string,
messageId: string, messageId: string,
prompt: string, prompt: string,
modelId: string modelId: string,
options?: {
sourceAssetIds?: string[];
sourceImageUrls?: string[];
relationType?: "generated_from" | "edited_from";
sourceType?: string;
projectId?: string | null;
},
): Promise<void> { ): Promise<void> {
// 匹配 Markdown 图片格式: ![alt](url) // 匹配 Markdown 图片格式: ![alt](url)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...imageMarkdown.matchAll(imageRegex)]; const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0; let savedImageCount = 0;
const savedAssetIds: string[] = [];
const savedOriginalUrls: 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) { for (const match of matches) {
const altText = match[1] || ""; const altText = match[1] || "";
@ -1643,7 +1673,7 @@ async function saveGeneratedImages(
const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl); const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl);
try { try {
await db.insert(generatedImage).values({ const [savedImage] = await db.insert(generatedImage).values({
userId, userId,
chatId, chatId,
messageId, messageId,
@ -1652,11 +1682,54 @@ async function saveGeneratedImages(
imageSize, imageSize,
prompt, prompt,
modelId, modelId,
}); }).returning();
savedImageCount += 1; savedImageCount += 1;
if (!savedOriginalUrls.includes(originalUrl)) { if (!savedOriginalUrls.includes(originalUrl)) {
savedOriginalUrls.push(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)}...`); console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`);
} catch (error) { } catch (error) {
console.error("[Gallery] Failed to save image record:", error); console.error("[Gallery] Failed to save image record:", error);
@ -1668,8 +1741,11 @@ async function saveGeneratedImages(
await upsertConversationWorkingState({ await upsertConversationWorkingState({
userId, userId,
chatId, chatId,
currentFocusAssetIds: savedAssetIds,
currentFocusImageUrls: savedOriginalUrls, currentFocusImageUrls: savedOriginalUrls,
lastGeneratedAssetIds: savedAssetIds,
lastGeneratedImageUrls: savedOriginalUrls, lastGeneratedImageUrls: savedOriginalUrls,
lastResolvedTargetAssetIds: savedAssetIds,
lastResolvedTargetImageUrls: savedOriginalUrls, lastResolvedTargetImageUrls: savedOriginalUrls,
lastMessageId: messageId, lastMessageId: messageId,
}); });
@ -3411,7 +3487,14 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, 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) { } catch (saveError) {
console.error("[Chat API] ❌ Failed to save Gemini message to database:", saveError); console.error("[Chat API] ❌ Failed to save Gemini message to database:", saveError);
@ -3515,7 +3598,14 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, 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) { } catch (saveError) {
console.error("[Chat API] Failed to save failover message:", saveError); console.error("[Chat API] Failed to save failover message:", saveError);
@ -3869,7 +3959,14 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, userPrompt,
finalModelId finalModelId,
{
sourceAssetIds: requestTargetResolution?.targetAssetIds,
sourceImageUrls: requestTargetResolution?.targetImageUrls,
relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from",
sourceType: "generated",
projectId: contextPayload?.projectId || null,
},
); );
} catch (saveError) { } catch (saveError) {
console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError); console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError);
@ -4211,7 +4308,14 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, userPrompt,
finalModelId finalModelId,
{
sourceAssetIds: requestTargetResolution?.targetAssetIds,
sourceImageUrls: requestTargetResolution?.targetImageUrls,
relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from",
sourceType: "generated",
projectId: contextPayload?.projectId || null,
},
); );
} catch (saveError) { } catch (saveError) {
console.error("[Chat API] ❌ Failed to save Volcano Vision message to database:", 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, id,
messageId, messageId,
userPrompt, userPrompt,
finalModelId finalModelId,
{
sourceAssetIds: requestTargetResolution?.targetAssetIds,
sourceImageUrls: requestTargetResolution?.targetImageUrls,
relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from",
sourceType: "generated",
projectId: contextPayload?.projectId || null,
},
); );
return; return;
} catch (error) { } 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"]), 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), name: z.string().min(1).max(100),
url: z.string().url(), url: z.string().url(),
assetId: z.string().uuid().optional(),
}); });
const partSchema = z.union([textPartSchema, filePartSchema]); const partSchema = z.union([textPartSchema, filePartSchema]);

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
/** /**
@ -167,8 +168,34 @@ export async function POST(request: Request) {
// 使用优化后的buffer上传 // 使用优化后的buffer上传
const data = await uploadFile(filename, optimizedArrayBuffer); 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); console.log("[Upload API] Upload successful:", data.url);
return NextResponse.json(data); return NextResponse.json({
...data,
assetId,
});
} catch (_error) { } catch (_error) {
console.error("[Upload API] Upload failed:", _error); console.error("[Upload API] Upload failed:", _error);
return NextResponse.json({ return NextResponse.json({

View File

@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; 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 { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -214,14 +219,61 @@ export async function POST(request: NextRequest) {
// 8. 保存到数据库 // 8. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000; const processingTime = (Date.now() - startTime) / 1000;
await db.insert(generatedImage).values({ const [savedImage] = await db.insert(generatedImage).values({
userId: session.user.id, userId: session.user.id,
thumbnailUrl: s3Result.thumbnail, thumbnailUrl: s3Result.thumbnail,
originalUrl: s3Result.original, originalUrl: s3Result.original,
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`, prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles", modelId: "qwen-image-edit-2511-multiple-angles",
createdAt: new Date(), 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), "秒"); 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 { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema'; import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
// 获取单个画布 // 获取单个画布
@ -52,6 +53,14 @@ export async function PUT(
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const { title, description, thumbnail, canvasData } = body; const { title, description, thumbnail, canvasData } = body;
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 先检查画布是否存在 // 先检查画布是否存在
const [existingProject] = await db const [existingProject] = await db
@ -70,7 +79,7 @@ export async function PUT(
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail; if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (canvasData !== undefined) updateData.canvasData = canvasData; if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db const [updatedProject] = await db
.update(canvasProject) .update(canvasProject)
@ -93,7 +102,7 @@ export async function PUT(
userId: session.user.id, userId: session.user.id,
title: title || '未命名画布', title: title || '未命名画布',
description, description,
canvasData: canvasData || { canvasData: syncedCanvasData || {
nodes: [], nodes: [],
edges: [], edges: [],
viewport: { x: 0, y: 0, zoom: 1 }, 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 { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema'; import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
// 获取画布列表 // 获取画布列表
@ -36,7 +37,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { title, description, canvasData } = body; const { title, description, canvasData } = body;
const [newProject] = await db const [insertedProject] = await db
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
userId: session.user.id, userId: session.user.id,
@ -50,6 +51,26 @@ export async function POST(request: NextRequest) {
}) })
.returning(); .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 }); return NextResponse.json({ success: true, data: newProject });
} catch (error) { } catch (error) {
console.error('创建画布失败:', error); console.error('创建画布失败:', error);

View File

@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; 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 { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -407,6 +412,7 @@ async function finalizeEnhanceTaskResult(params: {
chatId?: string | null; chatId?: string | null;
messageId?: string | null; messageId?: string | null;
startedAt?: number; startedAt?: number;
sourceImageUrls?: string[];
}) { }) {
if (params.taskResult?.status !== 200 || !params.taskResult?.data) { if (params.taskResult?.status !== 200 || !params.taskResult?.data) {
throw new Error(params.taskResult?.message || "未知错误"); throw new Error(params.taskResult?.message || "未知错误");
@ -438,9 +444,14 @@ async function finalizeEnhanceTaskResult(params: {
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) { for (let attempt = 1; attempt <= 3; attempt += 1) {
try { try {
await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId: params.userId, userId: params.userId,
chatId: params.chatId || null, chatId: params.chatId || null,
messageId: params.messageId || null, messageId: params.messageId || null,
@ -449,7 +460,7 @@ async function finalizeEnhanceTaskResult(params: {
imageSize: "2K", imageSize: "2K",
prompt: finalPrompt, prompt: finalPrompt,
modelId: "enhance-1.0", modelId: "enhance-1.0",
}); }).returning());
saved = true; saved = true;
break; break;
} catch (dbError) { } catch (dbError) {
@ -464,6 +475,55 @@ async function finalizeEnhanceTaskResult(params: {
throw new Error("图片变清晰成功但保存到相册失败,请稍后重试"); 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 = const processingTime =
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined; typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined;
@ -621,6 +681,7 @@ export async function POST(request: NextRequest) {
let prompt = ""; // 用户提示词(可选) let prompt = ""; // 用户提示词(可选)
let chatId: string | null = null; // 聊天ID(可选) let chatId: string | null = null; // 聊天ID(可选)
let messageId: string | null = null; // 消息ID(可选) let messageId: string | null = null; // 消息ID(可选)
let sourceImageUrl: string | null = null;
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
console.log("[Enhance API] 解析JSON请求体..."); console.log("[Enhance API] 解析JSON请求体...");
@ -631,6 +692,7 @@ export async function POST(request: NextRequest) {
// 优先使用 imageUrl,其次使用 image_base64 // 优先使用 imageUrl,其次使用 image_base64
if (body.imageUrl) { if (body.imageUrl) {
sourceImageUrl = String(body.imageUrl);
// 从 URL 获取图片并转换为 Base64 // 从 URL 获取图片并转换为 Base64
console.log(`[Enhance API] 从 URL 获取图片: ${body.imageUrl}`); console.log(`[Enhance API] 从 URL 获取图片: ${body.imageUrl}`);
try { try {
@ -729,9 +791,14 @@ export async function POST(request: NextRequest) {
// 步骤4: 保存到数据库 // 步骤4: 保存到数据库
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) { for (let attempt = 1; attempt <= 3; attempt += 1) {
try { try {
const [savedImage] = await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId, userId,
chatId: chatId || null, chatId: chatId || null,
messageId: messageId || null, messageId: messageId || null,
@ -740,7 +807,7 @@ export async function POST(request: NextRequest) {
imageSize: `${config.backupScale}x放大`, imageSize: `${config.backupScale}x放大`,
prompt: prompt || "Real-ESRGAN图片变清晰处理", prompt: prompt || "Real-ESRGAN图片变清晰处理",
modelId: `realesrgan-${config.backupModel}`, modelId: `realesrgan-${config.backupModel}`,
}).returning(); }).returning());
console.log(`[Enhance-RealESRGAN] ✅ 保存到数据库成功,ID: ${savedImage.id}`); console.log(`[Enhance-RealESRGAN] ✅ 保存到数据库成功,ID: ${savedImage.id}`);
saved = true; saved = true;
break; break;
@ -756,6 +823,56 @@ export async function POST(request: NextRequest) {
throw new Error("图片变清晰成功但保存到相册失败,请稍后重试"); 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)}秒`); console.log(`[Enhance-RealESRGAN] ✅ 全部完成,总耗时: ${result.processingTime.toFixed(2)}秒`);
return NextResponse.json({ return NextResponse.json({
@ -997,6 +1114,7 @@ export async function POST(request: NextRequest) {
chatId, chatId,
messageId, messageId,
startedAt: startTime, startedAt: startTime,
sourceImageUrls: sourceImageUrl ? [sourceImageUrl] : undefined,
}); });
return NextResponse.json(responsePayload); return NextResponse.json(responsePayload);

View File

@ -3,6 +3,7 @@ import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { upsertMediaAsset } from "@/lib/db/queries";
/** /**
* 压缩图片生成缩略图(使用 sharp) * 压缩图片生成缩略图(使用 sharp)
@ -174,6 +175,32 @@ export async function POST(request: NextRequest) {
modelId: "resize-1.0", // 标记为尺寸调整处理 modelId: "resize-1.0", // 标记为尺寸调整处理
}).returning(); }).returning();
console.log(`[Resize] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`); 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) { } catch (dbError) {
console.error("[Resize] ❌ 保存到数据库失败:", dbError); console.error("[Resize] ❌ 保存到数据库失败:", dbError);
// 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问) // 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问)
@ -196,4 +223,3 @@ export async function POST(request: NextRequest) {
); );
} }
} }

View File

@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; 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 { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -502,6 +507,7 @@ export async function POST(request: NextRequest) {
let chatId: string | null = null; let chatId: string | null = null;
let messageId: string | null = null; let messageId: string | null = null;
let skipGallerySave = false; let skipGallerySave = false;
let sourceImageUrl: string | null = null;
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const body = await request.json(); const body = await request.json();
@ -512,6 +518,7 @@ export async function POST(request: NextRequest) {
const imageUrl = body.imageUrl || body.image_url; const imageUrl = body.imageUrl || body.image_url;
if (imageUrl) { if (imageUrl) {
sourceImageUrl = String(imageUrl);
console.log(`[RMBG] 从 URL 获取图片: ${imageUrl}`); console.log(`[RMBG] 从 URL 获取图片: ${imageUrl}`);
try { try {
const imageResponse = await fetch(imageUrl); const imageResponse = await fetch(imageUrl);
@ -650,9 +657,14 @@ export async function POST(request: NextRequest) {
if (!skipGallerySave) { if (!skipGallerySave) {
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) { for (let attempt = 1; attempt <= 3; attempt += 1) {
try { try {
const [savedImage] = await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId, userId,
chatId: chatId || null, chatId: chatId || null,
messageId: messageId || null, messageId: messageId || null,
@ -661,7 +673,7 @@ export async function POST(request: NextRequest) {
imageSize: "2K", imageSize: "2K",
prompt: prompt || "抠图处理", prompt: prompt || "抠图处理",
modelId: provider === "photoroom" ? "photoroom-hd" : provider === "aoscdn" ? "aos-segmentation" : "rmbg-2.0", modelId: provider === "photoroom" ? "photoroom-hd" : provider === "aoscdn" ? "aos-segmentation" : "rmbg-2.0",
}).returning(); }).returning());
console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`); console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`);
saved = true; saved = true;
break; break;
@ -676,6 +688,62 @@ export async function POST(request: NextRequest) {
console.error("[RMBG] ❌ 保存到数据库失败:", saveError); console.error("[RMBG] ❌ 保存到数据库失败:", saveError);
throw new Error("图片处理成功但保存到相册失败,请稍后重试"); 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 { } else {
console.log("[RMBG] Skip gallery save by request flag"); 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 { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; 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 { uploadFile } from "@/lib/storage/service";
import sharp from "sharp"; import sharp from "sharp";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
@ -921,11 +927,17 @@ async function saveSceneFusionImageRecord(params: {
imageSize: string; imageSize: string;
prompt: string; prompt: string;
modelId: string; modelId: string;
sourceImageUrls?: string[];
}): Promise<void> { }): Promise<void> {
let lastError: unknown = null; let lastError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) { for (let attempt = 1; attempt <= 3; attempt += 1) {
try { try {
await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId: params.userId, userId: params.userId,
chatId: null, chatId: null,
messageId: null, messageId: null,
@ -934,8 +946,8 @@ async function saveSceneFusionImageRecord(params: {
imageSize: params.imageSize, imageSize: params.imageSize,
prompt: params.prompt, prompt: params.prompt,
modelId: params.modelId, modelId: params.modelId,
}); }).returning());
return; break;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (attempt < 3) { 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, imageSize: dbImageSize,
prompt: `[场景裂变] ${result.prompt}`, prompt: `[场景裂变] ${result.prompt}`,
modelId: candidate.model.modelId, modelId: candidate.model.modelId,
sourceImageUrls: [fusionProductImageUrl, resolvedScene.imageUrl],
}); });
console.log( console.log(
`[SceneFusion] ✅ Saved image to gallery: ${result.original.substring(0, 80)}...` `[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 { systemSettings, generatedImage } from "@/lib/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
upsertMediaAsset,
} from "@/lib/db/queries";
// PicWish API 需要公网可访问的 HTTP/HTTPS URL // PicWish API 需要公网可访问的 HTTP/HTTPS URL
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
@ -112,6 +117,7 @@ async function finalizeWatermarkTaskResult(params: {
userId: string; userId: string;
startedAt?: number; startedAt?: number;
apiKey: string; apiKey: string;
sourceImageUrls?: string[];
}): Promise<{ }): Promise<{
success: boolean; success: boolean;
original_url: string; original_url: string;
@ -138,16 +144,21 @@ async function finalizeWatermarkTaskResult(params: {
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) { for (let attempt = 1; attempt <= 3; attempt += 1) {
try { try {
await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId: params.userId, userId: params.userId,
thumbnailUrl: uploadResult.url, thumbnailUrl: uploadResult.url,
originalUrl: uploadResult.url, originalUrl: uploadResult.url,
imageSize: "2K", imageSize: "2K",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
}); }).returning());
saved = true; saved = true;
break; break;
} catch (error) { } catch (error) {
@ -162,6 +173,51 @@ async function finalizeWatermarkTaskResult(params: {
throw new Error("去水印成功但保存到相册失败,请稍后重试"); 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 { return {
success: true, success: true,
original_url: uploadResult.url, original_url: uploadResult.url,
@ -358,6 +414,10 @@ export async function POST(request: NextRequest) {
userId: session.user.id, userId: session.user.id,
startedAt: startTime, startedAt: startTime,
apiKey: config.apiKey, apiKey: config.apiKey,
sourceImageUrls:
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
? [image_url]
: undefined,
}); });
return NextResponse.json(result); return NextResponse.json(result);
} catch (error) { } catch (error) {

View File

@ -2024,6 +2024,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
url: src, url: src,
name: nodeTitle, name: nodeTitle,
contentType: "image/jpeg", 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), url: String(item.url),
name: String(item.name || "参考图"), name: String(item.name || "参考图"),
contentType: String(item.contentType || "image/jpeg"), contentType: String(item.contentType || "image/jpeg"),
assetId:
typeof item.assetId === "string" && item.assetId.trim()
? item.assetId.trim()
: undefined,
})) }))
: []; : [];
const normalizedPrompt = String(parsed.prompt || "").trim(); const normalizedPrompt = String(parsed.prompt || "").trim();
@ -3432,6 +3440,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
mediaType: attachment.contentType || "image/jpeg", mediaType: attachment.contentType || "image/jpeg",
name: attachment.name || "参考图", name: attachment.name || "参考图",
url: attachment.url, url: attachment.url,
assetId: attachment.assetId,
})); }));
if (textContent) { if (textContent) {
@ -4077,11 +4086,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const data = await response.json(); const data = await response.json();
const imageUrl = data.url as string; const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
updateAttachmentAtVideoSlot(slotIndex, { updateAttachmentAtVideoSlot(slotIndex, {
url: imageUrl, url: imageUrl,
name: file.name, name: file.name,
contentType: file.type || "image/jpeg", contentType: file.type || "image/jpeg",
assetId,
}); });
const dimensions = await getImageDimensions(imageUrl); const dimensions = await getImageDimensions(imageUrl);
@ -4244,6 +4256,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const data = await response.json(); const data = await response.json();
const imageUrl = String(data.url || "").trim(); const imageUrl = String(data.url || "").trim();
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
if (!imageUrl) { if (!imageUrl) {
throw new Error("未获取到图片地址"); throw new Error("未获取到图片地址");
} }
@ -4252,6 +4266,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
url: imageUrl, url: imageUrl,
name: file.name, name: file.name,
contentType: file.type || "image/jpeg", contentType: file.type || "image/jpeg",
assetId,
}); });
toast.success(`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}上传成功`); toast.success(`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}上传成功`);
} catch (_error) { } catch (_error) {
@ -5933,6 +5948,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
mediaType: a.contentType, mediaType: a.contentType,
name: a.name, name: a.name,
url: a.url, url: a.url,
assetId: a.assetId,
hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注 hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注
pickedObject: att.pickedObject, // 🆕 包含物品信息 pickedObject: att.pickedObject, // 🆕 包含物品信息
}); });
@ -6142,7 +6158,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const uploadImageFileAndAddToCanvas = useCallback(async ( const uploadImageFileAndAddToCanvas = useCallback(async (
file: File, file: File,
nodeIndex: number 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/')) { if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件'); toast.error('请选择图片文件');
return null; return null;
@ -6168,6 +6184,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const data = await response.json(); const data = await response.json();
const imageUrl = data.url as string; 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 dimensions = await getImageDimensions(imageUrl);
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); 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 }, size: { width: dimensions.width, height: dimensions.height },
data: { data: {
src: imageUrl, src: imageUrl,
assetId,
status: 'completed', status: 'completed',
title: fileName.replace(/\.[^.]+$/, ""), title: fileName.replace(/\.[^.]+$/, ""),
prompt: fileName.replace(/\.[^.]+$/, ""), prompt: fileName.replace(/\.[^.]+$/, ""),
@ -6194,6 +6213,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageUrl, imageUrl,
name: fileName, name: fileName,
contentType: file.type || 'image/jpeg', contentType: file.type || 'image/jpeg',
assetId,
}; };
} catch (_error) { } catch (_error) {
toast.error('图片上传失败'); toast.error('图片上传失败');
@ -6323,6 +6343,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const data = await response.json(); const data = await response.json();
const imageUrl = data.url; const imageUrl = data.url;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
// 记录第一张图片的 URL(用于自动命名和缩略图) // 记录第一张图片的 URL(用于自动命名和缩略图)
if (!firstImageUrl) { if (!firstImageUrl) {
@ -6341,6 +6363,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
url: imageUrl, url: imageUrl,
name: file.name, name: file.name,
contentType: file.type, contentType: file.type,
assetId,
}]; }];
}); });
@ -6359,6 +6382,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
size: { width: dimensions.width, height: dimensions.height }, size: { width: dimensions.width, height: dimensions.height },
data: { data: {
src: imageUrl, src: imageUrl,
assetId,
status: 'completed', status: 'completed',
title: file.name.replace(/\.[^.]+$/, ""), title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""), prompt: file.name.replace(/\.[^.]+$/, ""),

View File

@ -647,6 +647,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
updateNode(placeholderId, { updateNode(placeholderId, {
data: { data: {
src: result.data.url, src: result.data.url,
assetId: result.data.assetId,
status: "completed", status: "completed",
prompt: cleanName || "拖拽上传", prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传", title: cleanName || "拖拽上传",

View File

@ -1270,6 +1270,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
data: { data: {
...data, ...data,
src: result.data.url, src: result.data.url,
assetId: result.data.assetId,
status: 'completed', status: 'completed',
title: normalizeImageTitle(data.title) || fileBaseName, title: normalizeImageTitle(data.title) || fileBaseName,
}, },
@ -1581,7 +1582,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const handleResizeEditorComplete = useCallback( const handleResizeEditorComplete = useCallback(
async (resizedImageUrl: string) => { async (resizedImageUrl: string) => {
setResizeAnnotatorOpen(false);
let finalUrl = resizedImageUrl; let finalUrl = resizedImageUrl;
if (resizedImageUrl.startsWith("data:")) { if (resizedImageUrl.startsWith("data:")) {
@ -1613,6 +1613,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
connections: [node.id], connections: [node.id],
}); });
setResizeAnnotatorOpen(false);
toast.success("尺寸调整结果已发送到画布"); toast.success("尺寸调整结果已发送到画布");
}, },
[ [

View File

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

View File

@ -845,6 +845,7 @@ export function LovartHome({
url: data.url, url: data.url,
name: item.file.name, name: item.file.name,
contentType: item.file.type || "image/jpeg", contentType: item.file.type || "image/jpeg",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
}); });
} }

View File

@ -47,7 +47,7 @@ type DrawingAction = {
interface ImageAnnotatorProps { interface ImageAnnotatorProps {
imageUrl?: string; imageUrl?: string;
onConfirm: (annotatedImageUrl: string) => void; onConfirm: (annotatedImageUrl: string) => void | Promise<void>;
onCancel: () => void; onCancel: () => void;
onHomeClick?: () => void; onHomeClick?: () => void;
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式 initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
@ -1144,7 +1144,8 @@ export function ImageAnnotator({
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图) const finalResultUrl = result.original_url || dataUrl;
if (result.original_url) { if (result.original_url) {
console.log("[尺寸调整] 原图URL:", result.original_url); console.log("[尺寸调整] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url); setProcessedImageUrl(result.original_url);
@ -1153,10 +1154,8 @@ export function ImageAnnotator({
} }
setProcessedImageBase64(base64Data); setProcessedImageBase64(base64Data);
setProcessType("resize"); setProcessType("resize");
// 不退出尺寸调整模式,保持原图显示
// setIsResizeMode(false);
toast.success(`尺寸调整完成!图片已调整为 ${resizeCanvasWidth}x${resizeCanvasHeight} 像素,已保存到相册`); await Promise.resolve(onConfirm(finalResultUrl));
} else { } else {
throw new Error(result.error || "尺寸调整处理失败"); throw new Error(result.error || "尺寸调整处理失败");
} }

View File

@ -430,6 +430,7 @@ function PureMultimodalInput({
url: attachment.url, url: attachment.url,
name: attachment.name, name: attachment.name,
mediaType: attachment.contentType, mediaType: attachment.contentType,
assetId: attachment.assetId,
})), })),
{ {
type: "text", type: "text",
@ -594,13 +595,13 @@ function PureMultimodalInput({
}); });
}, []); }, []);
const uploadFile = useCallback(async (file: File) => { const uploadFile = useCallback(async (file: File): Promise<Attachment | null> => {
try { try {
// 先压缩图片 // 先压缩图片
console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`); console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
const processedFile = await compressImage(file); const processedFile = await compressImage(file);
const formData = new FormData(); const formData = new FormData();
formData.append("file", processedFile); formData.append("file", processedFile);
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
@ -616,14 +617,17 @@ function PureMultimodalInput({
url, // 完整的可访问 URL url, // 完整的可访问 URL
name: file.name, // 使用原始文件名,不是 pathname name: file.name, // 使用原始文件名,不是 pathname
contentType, contentType,
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
}; };
} }
const { error } = await response.json(); const { error } = await response.json();
console.error(`[上传] 服务器返回错误:`, error); console.error(`[上传] 服务器返回错误:`, error);
toast.error(error); toast.error(error);
return null;
} catch (_error) { } catch (_error) {
console.error("[上传] 失败:", _error); console.error("[上传] 失败:", _error);
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试"); toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
return null;
} }
}, [compressImage]); }, [compressImage]);
@ -799,7 +803,7 @@ function PureMultimodalInput({
const uploadPromises = filesToUpload.map((file) => uploadFile(file)); const uploadPromises = filesToUpload.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises); const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter( const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => attachment !== undefined (attachment): attachment is Attachment => attachment !== null
); );
setAttachments((currentAttachments) => [ setAttachments((currentAttachments) => [
@ -852,15 +856,15 @@ function PureMultimodalInput({
const uploadedAttachments = await Promise.all(uploadPromises); const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter( const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => (attachment): attachment is Attachment =>
attachment !== undefined && attachment !== null &&
attachment.url !== undefined && attachment.url !== undefined &&
attachment.contentType !== undefined attachment.contentType !== undefined
); );
setAttachments((curr) => [ setAttachments((curr) => [
...curr, ...curr,
...(successfullyUploadedAttachments as Attachment[]), ...successfullyUploadedAttachments,
]); ]);
} catch (error) { } catch (error) {
console.error("Error uploading pasted images:", error); console.error("Error uploading pasted images:", error);
@ -952,7 +956,7 @@ function PureMultimodalInput({
); );
const validAttachments = uploadedAttachments.filter( const validAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment => attachment !== undefined (attachment): attachment is Attachment => attachment !== null
); );
setAttachments((currentAttachments) => [ setAttachments((currentAttachments) => [
@ -1157,6 +1161,7 @@ function PureMultimodalInput({
// 如果仍然是 base64 格式,先上传到服务器 // 如果仍然是 base64 格式,先上传到服务器
let finalUrl = annotatedImageUrl; let finalUrl = annotatedImageUrl;
let assetId: string | undefined;
if (annotatedImageUrl.startsWith("data:")) { if (annotatedImageUrl.startsWith("data:")) {
try { try {
toast.info("正在上传图片..."); toast.info("正在上传图片...");
@ -1168,6 +1173,10 @@ function PureMultimodalInput({
if (uploadResponse.ok) { if (uploadResponse.ok) {
const result = await uploadResponse.json(); const result = await uploadResponse.json();
finalUrl = result.url; finalUrl = result.url;
assetId =
typeof result.assetId === "string" && result.assetId.trim()
? result.assetId.trim()
: undefined;
} }
} catch (error) { } catch (error) {
console.error("上传标注图片失败:", error); console.error("上传标注图片失败:", error);
@ -1182,6 +1191,7 @@ function PureMultimodalInput({
url: finalUrl, url: finalUrl,
name: fileName, name: fileName,
contentType: "image/png", contentType: "image/png",
assetId,
}, },
]); ]);
// 如果输入框为空,自动填入提示词 // 如果输入框为空,自动填入提示词

View File

@ -53,42 +53,45 @@ function normalizeImageInputRef(rawValue: unknown, requestOrigin?: string): stri
return null; return null;
} }
function collectImageInputsFromMessageParts( function collectImageRefsFromMessageParts(
parts: Array<Record<string, any>> | undefined, parts: Array<Record<string, any>> | undefined,
requestOrigin?: string, requestOrigin?: string,
): string[] { ): Array<{ url: string; assetId?: string }> {
if (!Array.isArray(parts) || parts.length === 0) return []; if (!Array.isArray(parts) || parts.length === 0) return [];
const result: string[] = []; const result: Array<{ url: string; assetId?: string }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
const pushInput = (rawValue: unknown) => { const pushInput = (rawValue: unknown, assetId?: unknown) => {
const normalized = normalizeImageInputRef(rawValue, requestOrigin); const normalized = normalizeImageInputRef(rawValue, requestOrigin);
if (!normalized) return; if (!normalized) return;
const key = normalizeMediaUrl(normalized); const key = normalizeMediaUrl(normalized);
if (!key || seen.has(key)) return; if (!key || seen.has(key)) return;
seen.add(key); seen.add(key);
result.push(normalized); result.push({
url: normalized,
assetId: typeof assetId === "string" && assetId.trim() ? assetId.trim() : undefined,
});
}; };
for (const part of parts) { for (const part of parts) {
if (!part) continue; if (!part) continue;
if (part.type === "file") { if (part.type === "file") {
pushInput(part.url); pushInput(part.url, part.assetId);
pushInput(part.data); pushInput(part.data, part.assetId);
continue; continue;
} }
if (part.type === "image") { if (part.type === "image") {
pushInput(part.url); pushInput(part.url, part.assetId);
pushInput(part.image); pushInput(part.image, part.assetId);
pushInput(part.data); pushInput(part.data, part.assetId);
continue; continue;
} }
if (part.type === "image_url") { if (part.type === "image_url") {
pushInput(part.image_url?.url); pushInput(part.image_url?.url, part.assetId);
pushInput(part.url); pushInput(part.url, part.assetId);
continue; continue;
} }
@ -175,12 +178,12 @@ function extractHistoryImageRefs(params: {
const parts = historyMessage.parts as Array<Record<string, any>> | undefined; const parts = historyMessage.parts as Array<Record<string, any>> | undefined;
if (!Array.isArray(parts) || parts.length === 0) continue; if (!Array.isArray(parts) || parts.length === 0) continue;
const urls = collectImageInputsFromMessageParts(parts, params.requestOrigin); const refs = collectImageRefsFromMessageParts(parts, params.requestOrigin);
if (!urls.length) continue; if (!refs.length) continue;
if (historyMessage.role === "assistant" && latestAssistant.length < params.maxImages) { if (historyMessage.role === "assistant" && latestAssistant.length < params.maxImages) {
for (const url of urls) { for (const ref of refs) {
pushRef(latestAssistant, seenAssistant, url, { pushRef(latestAssistant, seenAssistant, ref.url, {
source: "history_assistant_result", source: "history_assistant_result",
role: "history_result", role: "history_result",
messageId: historyMessage.id, messageId: historyMessage.id,
@ -189,8 +192,8 @@ function extractHistoryImageRefs(params: {
} }
if (historyMessage.role === "user" && latestUser.length < params.maxImages) { if (historyMessage.role === "user" && latestUser.length < params.maxImages) {
for (const url of urls) { for (const ref of refs) {
pushRef(latestUser, seenUser, url, { pushRef(latestUser, seenUser, ref.url, {
source: "history_user_upload", source: "history_user_upload",
role: "reference", role: "reference",
messageId: historyMessage.id, messageId: historyMessage.id,
@ -214,9 +217,10 @@ export function resolveChatMediaInput(
): ResolvedChatMediaInput { ): ResolvedChatMediaInput {
const maxImages = Math.max(1, Math.min(12, Math.round(params.maxImages || 4))); const maxImages = Math.max(1, Math.min(12, Math.round(params.maxImages || 4)));
const currentMessageImageRefs = dedupeRefs( const currentMessageImageRefs = dedupeRefs(
collectImageInputsFromMessageParts(params.messageParts, params.requestOrigin).map((url) => ({ collectImageRefsFromMessageParts(params.messageParts, params.requestOrigin).map((ref) => ({
url, url: ref.url,
normalizedUrl: normalizeMediaUrl(url), normalizedUrl: normalizeMediaUrl(ref.url),
assetId: ref.assetId,
source: "current_message", source: "current_message",
role: "upload", role: "upload",
})), })),

View File

@ -2,6 +2,7 @@ export interface CanvasLaunchAttachment {
url: string; url: string;
name: string; name: string;
contentType: string; contentType: string;
assetId?: string;
} }
export interface CanvasLaunchImageParams { 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, "when": 1772788800000,
"tag": "0050_add_context_memory_tables", "tag": "0050_add_context_memory_tables",
"breakpoints": true "breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1772792400000,
"tag": "0051_add_media_assets",
"breakpoints": true
} }
] ]
} }

View File

@ -56,6 +56,12 @@ import {
type ConversationWorkingState, type ConversationWorkingState,
messageTargetResolution, messageTargetResolution,
type MessageTargetResolution, type MessageTargetResolution,
mediaAsset,
type MediaAsset,
mediaAssetRelation,
type MediaAssetRelation,
canvasNode,
canvasProject,
} from "./schema"; } from "./schema";
import { generateHashedPassword } from "./utils"; import { generateHashedPassword } from "./utils";
import { import {
@ -69,6 +75,7 @@ import {
normalizePointsSystemConfig, normalizePointsSystemConfig,
POINTS_SYSTEM_CONFIG_KEY, POINTS_SYSTEM_CONFIG_KEY,
} from "../points/config"; } from "../points/config";
import { inferOriginalImageUrl } from "../image-url";
// Optionally, if not using email/pass login, you can // Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth // use the Drizzle adapter for Auth.js / NextAuth
@ -682,6 +689,23 @@ function normalizeStringArray(value?: string[] | null): string[] {
return Array.from(deduped); 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({ export async function getConversationWorkingStateByChatId({
chatId, 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({ export async function getMessageCountByUserId({
id, id,
differenceInHours, differenceInHours,

View File

@ -742,3 +742,71 @@ export const messageTargetResolution = pgTable("MessageTargetResolution", {
}); });
export type MessageTargetResolution = InferSelectModel<typeof 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; name: string;
url: string; url: string;
contentType: string; contentType: string;
assetId?: string;
}; };

View File

@ -137,7 +137,7 @@ export const canvasService = {
// 图片生成服务 // 图片生成服务
export const imageGenerationService = { 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 { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@ -157,7 +157,8 @@ export const imageGenerationService = {
success: true, success: true,
data: { data: {
url: data.url, url: data.url,
pathname: data.pathname pathname: data.pathname,
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
} }
}; };
} catch (error) { } catch (error) {