feat: sync latest canvas and media updates
This commit is contained in:
parent
5f849bf0b7
commit
12583cdd10
@ -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 图片格式: 
|
||||
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) {
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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,9 +956,55 @@ async function saveSceneFusionImageRecord(params: {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 aspectRatio 和 imageSize 计算实际尺寸
|
||||
* @param aspectRatio - 宽高比,如 "1:1", "4:3", "16:9", "auto"
|
||||
@ -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)}...`
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(/\.[^.]+$/, ""),
|
||||
|
||||
@ -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 || "拖拽上传",
|
||||
|
||||
@ -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("尺寸调整结果已发送到画布");
|
||||
},
|
||||
[
|
||||
|
||||
@ -1248,6 +1248,8 @@ export function Chat({
|
||||
url: uploadResult.url,
|
||||
name: fileName,
|
||||
contentType: "image/png",
|
||||
assetId:
|
||||
typeof uploadResult.assetId === "string" ? uploadResult.assetId : undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 || "尺寸调整处理失败");
|
||||
}
|
||||
|
||||
@ -430,6 +430,7 @@ function PureMultimodalInput({
|
||||
url: attachment.url,
|
||||
name: attachment.name,
|
||||
mediaType: attachment.contentType,
|
||||
assetId: attachment.assetId,
|
||||
})),
|
||||
{
|
||||
type: "text",
|
||||
@ -594,7 +595,7 @@ 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`);
|
||||
@ -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,
|
||||
},
|
||||
]);
|
||||
// 如果输入框为空,自动填入提示词
|
||||
|
||||
@ -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",
|
||||
})),
|
||||
|
||||
@ -2,6 +2,7 @@ export interface CanvasLaunchAttachment {
|
||||
url: string;
|
||||
name: string;
|
||||
contentType: string;
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
export interface CanvasLaunchImageParams {
|
||||
|
||||
123
lib/db/migrations/0051_add_media_assets.sql
Normal file
123
lib/db/migrations/0051_add_media_assets.sql
Normal 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");
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -55,4 +55,5 @@ export type Attachment = {
|
||||
name: string;
|
||||
url: string;
|
||||
contentType: string;
|
||||
assetId?: string;
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user