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