chatbot-template/app/(chat)/api/chat/route.ts

284 lines
8.1 KiB
TypeScript
Raw Normal View History

import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
stepCountIs,
streamText,
} from "ai";
import { auth } from "@/app/(auth)/auth";
import {
allowedModelIds,
DEFAULT_CHAT_MODEL,
modelCapabilities,
} from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { getWeather } from "@/lib/ai/tools/get-weather";
import { withLimit } from "@/lib/ai/tools/with-limit";
import {
deleteChatById,
getChatById,
getMessagesByChatId,
saveChat,
saveMessages,
updateChatTitleById,
updateMessage,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import { ChatbotError } from "@/lib/errors";
import { checkIpRateLimit } from "@/lib/ratelimit";
import {
checkAndConsume,
LimitExceededError,
} from "@/lib/subscription/service";
import type { ChatMessage } from "@/lib/types";
import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema";
export const maxDuration = 60;
function getClientIp(request: Request): string | undefined {
const fwd = request.headers.get("x-forwarded-for");
if (fwd) {
return fwd.split(",")[0]?.trim();
}
return request.headers.get("x-real-ip") ?? undefined;
}
export async function POST(request: Request) {
let requestBody: PostRequestBody;
try {
const json = await request.json();
requestBody = postRequestBodySchema.parse(json);
} catch (_) {
return new ChatbotError("bad_request:api").toResponse();
}
try {
const { id, message, messages, selectedChatModel, selectedVisibilityType } =
requestBody;
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
const chatModel = allowedModelIds.has(selectedChatModel)
? selectedChatModel
: DEFAULT_CHAT_MODEL;
await checkIpRateLimit(getClientIp(request));
const isToolApprovalFlow = Boolean(messages);
if (!isToolApprovalFlow) {
try {
await checkAndConsume(session.user.id, "chat_message");
} catch (error) {
if (error instanceof LimitExceededError) {
return Response.json(
{
code: "rate_limit:chat",
message: `Daily message limit reached (${error.used}/${error.limit} on ${error.tier} tier). Upgrade to Pro at /pricing.`,
},
{ status: 429 }
);
}
throw error;
}
}
const chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = [];
let titlePromise: Promise<string> | null = null;
if (chat) {
if (chat.userId !== session.user.id) {
return new ChatbotError("forbidden:chat").toResponse();
}
messagesFromDb = await getMessagesByChatId({ id });
} else if (message?.role === "user") {
await saveChat({
id,
userId: session.user.id,
title: "New chat",
visibility: selectedVisibilityType,
});
titlePromise = generateTitleFromUserMessage({ message });
}
let uiMessages: ChatMessage[];
if (isToolApprovalFlow && messages) {
const dbMessages = convertToUIMessages(messagesFromDb);
const approvalStates = new Map(
messages.flatMap(
(m) =>
m.parts
?.filter(
(p: Record<string, unknown>) =>
p.state === "approval-responded" ||
p.state === "output-denied"
)
.map((p: Record<string, unknown>) => [
String(p.toolCallId ?? ""),
p,
]) ?? []
)
);
uiMessages = dbMessages.map((msg) => ({
...msg,
parts: msg.parts.map((part) => {
if (
"toolCallId" in part &&
approvalStates.has(String(part.toolCallId))
) {
return { ...part, ...approvalStates.get(String(part.toolCallId)) };
}
return part;
}),
})) as ChatMessage[];
} else {
uiMessages = [
...convertToUIMessages(messagesFromDb),
message as ChatMessage,
];
}
const requestHints: RequestHints = {
city: request.headers.get("x-vercel-ip-city") ?? undefined,
country: request.headers.get("x-vercel-ip-country") ?? undefined,
};
if (message?.role === "user") {
await saveMessages({
messages: [
{
chatId: id,
id: message.id,
role: "user",
parts: message.parts,
attachments: [],
createdAt: new Date(),
},
],
});
}
const capabilities = modelCapabilities[chatModel];
const isReasoningModel = capabilities?.reasoning === true;
const supportsTools = capabilities?.tools === true;
const modelMessages = await convertToModelMessages(uiMessages);
const meteredWeather = withLimit(getWeather, "tool_call", session.user.id);
const stream = createUIMessageStream({
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => {
const result = streamText({
model: getLanguageModel(chatModel),
system: systemPrompt({ requestHints, supportsTools }),
messages: modelMessages,
stopWhen: stepCountIs(5),
experimental_activeTools:
isReasoningModel && !supportsTools ? [] : ["getWeather"],
tools: {
getWeather: meteredWeather,
},
});
dataStream.merge(
result.toUIMessageStream({ sendReasoning: isReasoningModel })
);
if (titlePromise) {
try {
const title = await titlePromise;
dataStream.write({ type: "data-chat-title", data: title });
updateChatTitleById({ chatId: id, title });
} catch (_) {
/* non-fatal */
}
}
},
generateId: generateUUID,
onFinish: async ({ messages: finishedMessages }) => {
if (isToolApprovalFlow) {
for (const finishedMsg of finishedMessages) {
const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id);
if (existingMsg) {
await updateMessage({
id: finishedMsg.id,
parts: finishedMsg.parts,
});
} else {
await saveMessages({
messages: [
{
id: finishedMsg.id,
role: finishedMsg.role,
parts: finishedMsg.parts,
createdAt: new Date(),
attachments: [],
chatId: id,
},
],
});
}
}
} else if (finishedMessages.length > 0) {
await saveMessages({
messages: finishedMessages.map((currentMessage) => ({
id: currentMessage.id,
role: currentMessage.role,
parts: currentMessage.parts,
createdAt: new Date(),
attachments: [],
chatId: id,
})),
});
}
},
onError: () => "Oops, an error occurred!",
});
return createUIMessageStreamResponse({ stream });
} catch (error) {
if (error instanceof ChatbotError) {
return error.toResponse();
}
console.error("Unhandled error in chat API:", error);
return new ChatbotError("offline:chat").toResponse();
}
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatbotError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
const chat = await getChatById({ id });
if (chat?.userId !== session.user.id) {
return new ChatbotError("forbidden:chat").toResponse();
}
const deletedChat = await deleteChatById({ id });
return Response.json(deletedChat, { status: 200 });
}