359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { prependImageModelBadge } from "@/lib/ai/runtime/agent-media-markdown";
|
|
import {
|
|
isPendingFalImageEditTaskMetadata,
|
|
type FalImageEditTaskMetadata,
|
|
} from "@/lib/ai/runtime/fal-image-edit-task";
|
|
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
|
import {
|
|
getChatById,
|
|
getMessagesByChatId,
|
|
getAiProviderById,
|
|
saveMessages,
|
|
updateMessageById,
|
|
} from "@/lib/db/queries";
|
|
import type { DBMessage } from "@/lib/db/schema";
|
|
import {
|
|
getFalImageTaskStatus,
|
|
resolveFalImageTaskToMarkdown,
|
|
} from "@/lib/ai/providers/fal-image";
|
|
import {
|
|
isAgentModeScopedChatId,
|
|
isScopedEmptyHistoryRequest,
|
|
isValidMessagesChatId,
|
|
normalizeMessagesChatId,
|
|
} from "@/lib/chat/messages-route-access";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const revalidate = 0;
|
|
|
|
const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
|
const falTaskLastPolledAt = new Map<string, number>();
|
|
const falTaskSyncInFlight = new Set<string>();
|
|
|
|
function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
const metadata = value as Record<string, unknown>;
|
|
if (metadata.taskKind !== "clawd_chat") return value;
|
|
const publicMetadata = { ...metadata };
|
|
delete publicMetadata.upstreamTaskKey;
|
|
return publicMetadata;
|
|
}
|
|
|
|
function createEmptyMessagesResponse() {
|
|
return NextResponse.json(
|
|
{ messages: [] },
|
|
{
|
|
headers: {
|
|
"Cache-Control": "no-store, max-age=0",
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
async function markFalTaskFailed(params: {
|
|
message: DBMessage;
|
|
metadata: FalImageEditTaskMetadata;
|
|
errorText: string;
|
|
}) {
|
|
await updateMessageById({
|
|
messageId: params.message.id,
|
|
chatId: params.message.chatId,
|
|
parts: [{ type: "text", text: params.errorText }],
|
|
videoMetadata: {
|
|
...params.metadata,
|
|
status: "failed",
|
|
updatedAt: Date.now(),
|
|
error: params.errorText,
|
|
} as any,
|
|
});
|
|
}
|
|
|
|
async function syncPendingFalImageTask(params: {
|
|
userId: string;
|
|
message: DBMessage;
|
|
}): Promise<boolean> {
|
|
const metadata = params.message.videoMetadata;
|
|
if (!isPendingFalImageEditTaskMetadata(metadata)) return false;
|
|
|
|
const taskKey = `${params.message.id}:${metadata.requestId}`;
|
|
const now = Date.now();
|
|
const lastPolledAt = falTaskLastPolledAt.get(taskKey) || 0;
|
|
if (now - lastPolledAt < FAL_IMAGE_TASK_POLL_INTERVAL_MS) {
|
|
return false;
|
|
}
|
|
if (falTaskSyncInFlight.has(taskKey)) {
|
|
return false;
|
|
}
|
|
falTaskSyncInFlight.add(taskKey);
|
|
falTaskLastPolledAt.set(taskKey, now);
|
|
|
|
try {
|
|
const provider = await getAiProviderById({ id: metadata.providerId }).catch(() => undefined);
|
|
const apiKey = String(provider?.apiKey || "").trim();
|
|
if (!apiKey) {
|
|
await markFalTaskFailed({
|
|
message: params.message,
|
|
metadata,
|
|
errorText: "FAL 图片编辑失败:未找到可用的服务商配置",
|
|
});
|
|
falTaskLastPolledAt.delete(taskKey);
|
|
return true;
|
|
}
|
|
|
|
let queueStatus: Awaited<ReturnType<typeof getFalImageTaskStatus>>;
|
|
try {
|
|
queueStatus = await getFalImageTaskStatus({
|
|
apiKey,
|
|
endpointId: metadata.endpointId,
|
|
requestId: metadata.requestId,
|
|
});
|
|
} catch (error) {
|
|
console.error("[Messages API] Failed to poll FAL task status:", {
|
|
messageId: params.message.id,
|
|
requestId: metadata.requestId,
|
|
error,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (queueStatus.status === "COMPLETED") {
|
|
try {
|
|
const imageMarkdown = await resolveFalImageTaskToMarkdown({
|
|
apiKey,
|
|
endpointId: metadata.endpointId,
|
|
requestId: metadata.requestId,
|
|
imageCount: metadata.requestedImageCount,
|
|
outputSizeLabel: metadata.outputSizeLabel,
|
|
sourceImageUrls: metadata.sourceImageUrls || [],
|
|
});
|
|
const finalText = prependImageModelBadge(
|
|
imageMarkdown,
|
|
metadata.modelDisplayName || metadata.modelId,
|
|
);
|
|
|
|
await updateMessageById({
|
|
messageId: params.message.id,
|
|
chatId: params.message.chatId,
|
|
parts: [{ type: "text", text: finalText }],
|
|
videoMetadata: {
|
|
...metadata,
|
|
status: "completed",
|
|
updatedAt: Date.now(),
|
|
queuePosition: null,
|
|
lastLogs: Array.isArray((queueStatus as any)?.logs) ? (queueStatus as any).logs : [],
|
|
error: null,
|
|
} as any,
|
|
});
|
|
|
|
await saveGeneratedImages(
|
|
finalText,
|
|
params.userId,
|
|
params.message.chatId,
|
|
params.message.id,
|
|
metadata.userPrompt || metadata.prompt,
|
|
metadata.modelRef || metadata.modelId,
|
|
{
|
|
sourceAssetIds: metadata.sourceAssetIds || [],
|
|
sourceImageUrls: metadata.sourceImageUrls || [],
|
|
relationType: metadata.relationType || "edited_from",
|
|
sourceType: metadata.sourceType || "generated",
|
|
projectId: metadata.projectId || null,
|
|
optimizedPrompt: metadata.optimizedPrompt || metadata.prompt,
|
|
userPrompt: metadata.userPrompt || metadata.prompt,
|
|
requestedImageCount: metadata.requestedImageCount,
|
|
aspectRatio: metadata.aspectRatio,
|
|
imageSize: metadata.imageSize,
|
|
},
|
|
);
|
|
|
|
falTaskLastPolledAt.delete(taskKey);
|
|
return true;
|
|
} catch (error) {
|
|
console.error("[Messages API] Failed to finalize completed FAL task:", {
|
|
messageId: params.message.id,
|
|
requestId: metadata.requestId,
|
|
error,
|
|
});
|
|
await markFalTaskFailed({
|
|
message: params.message,
|
|
metadata,
|
|
errorText: "FAL 图片编辑结果处理失败,请重新提交任务",
|
|
});
|
|
falTaskLastPolledAt.delete(taskKey);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const nextStatus = queueStatus.status === "IN_QUEUE" ? "queued" : "running";
|
|
const nextQueuePosition =
|
|
queueStatus.status === "IN_QUEUE" && typeof (queueStatus as any).queue_position === "number"
|
|
? (queueStatus as any).queue_position
|
|
: null;
|
|
const hasMeaningfulChange =
|
|
metadata.status !== nextStatus ||
|
|
(metadata.queuePosition ?? null) !== nextQueuePosition;
|
|
|
|
if (hasMeaningfulChange) {
|
|
await updateMessageById({
|
|
messageId: params.message.id,
|
|
chatId: params.message.chatId,
|
|
videoMetadata: {
|
|
...metadata,
|
|
status: nextStatus,
|
|
updatedAt: Date.now(),
|
|
queuePosition: nextQueuePosition,
|
|
lastLogs: Array.isArray((queueStatus as any)?.logs) ? (queueStatus as any).logs : [],
|
|
} as any,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} finally {
|
|
falTaskSyncInFlight.delete(taskKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取聊天消息
|
|
* 用于从数据库重新加载消息
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const session = await auth();
|
|
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const requestedChatId = request.nextUrl.searchParams.get("chatId");
|
|
|
|
if (!requestedChatId) {
|
|
return NextResponse.json(
|
|
{ error: "Missing required parameter: chatId" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const chatId = normalizeMessagesChatId(requestedChatId);
|
|
if (!isValidMessagesChatId(chatId)) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
const scope = request.nextUrl.searchParams.get("scope");
|
|
const selectedChat = await getChatById({ id: chatId });
|
|
if (!selectedChat) {
|
|
if (
|
|
isAgentModeScopedChatId(chatId) ||
|
|
isScopedEmptyHistoryRequest({ chatId, scope })
|
|
) {
|
|
return createEmptyMessagesResponse();
|
|
}
|
|
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
if (selectedChat.userId !== session.user.id) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
let messages = await getMessagesByChatId({ id: chatId });
|
|
let mutated = false;
|
|
for (const message of messages) {
|
|
if (message.role !== "assistant") continue;
|
|
if (!isPendingFalImageEditTaskMetadata(message.videoMetadata)) continue;
|
|
const changed = await syncPendingFalImageTask({
|
|
userId: session.user.id,
|
|
message,
|
|
});
|
|
mutated = mutated || changed;
|
|
}
|
|
|
|
if (mutated) {
|
|
messages = await getMessagesByChatId({ id: chatId });
|
|
}
|
|
|
|
// 转换为前端格式
|
|
const formattedMessages = messages.map((msg: DBMessage) => {
|
|
const parts = msg.parts as any[] || [];
|
|
const firstPart = parts[0] as any;
|
|
return {
|
|
id: msg.id,
|
|
role: msg.role,
|
|
content: firstPart?.text || "",
|
|
createdAt: msg.createdAt,
|
|
parts: parts,
|
|
attachments: msg.attachments || [],
|
|
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
|
|
};
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{ messages: formattedMessages },
|
|
{
|
|
headers: {
|
|
"Cache-Control": "no-store, max-age=0",
|
|
},
|
|
}
|
|
);
|
|
} catch (error) {
|
|
console.error("[Messages API] GET Error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Failed to get messages" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存消息到数据库的API
|
|
* 用于图片处理等场景中保存助手回复消息
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await auth();
|
|
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { chatId, message } = body;
|
|
|
|
if (!chatId || !message) {
|
|
return NextResponse.json(
|
|
{ error: "Missing required fields: chatId and message" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// 构造DBMessage对象
|
|
const dbMessage: DBMessage = {
|
|
id: message.id,
|
|
chatId,
|
|
role: message.role || "assistant",
|
|
parts: message.parts || [],
|
|
attachments: message.attachments || [],
|
|
createdAt: message.createdAt ? new Date(message.createdAt) : new Date(),
|
|
videoStorageStatus: null,
|
|
temporaryVideoUrl: null,
|
|
permanentVideoUrl: null,
|
|
archivedAt: null,
|
|
videoMetadata: null,
|
|
};
|
|
|
|
// 保存消息到数据库
|
|
await saveMessages({ messages: [dbMessage] });
|
|
|
|
return NextResponse.json({ success: true });
|
|
} catch (error) {
|
|
console.error("[Messages API] Error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Failed to save message" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|