Initial commit: EGBE chatbot template
Stripped from vercel/chatbot (Apache 2.0): - Dropped @vercel/* packages and AI Gateway - Removed artifacts feature (code/text/sheet/image side panel) - Switched AI provider to @ai-sdk/openai-compatible -> EGBE LiteLLM - Replaced Vercel Blob upload with data URLs - Dropped Redis resumable streams and rate limiter (in-memory now) - Added Dockerfile (Next.js standalone) + entrypoint that runs migrations - Wired DATABASE_URL, EGBE_AI_API_URL/KEY, NEXT_PUBLIC_BASE_URL for app-deploy.sh
This commit is contained in:
commit
3e21c2334c
129 changed files with 21913 additions and 0 deletions
272
app/(chat)/api/chat/route.ts
Normal file
272
app/(chat)/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai";
|
||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
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 {
|
||||
deleteChatById,
|
||||
getChatById,
|
||||
getMessageCountByUserId,
|
||||
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 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 userType: UserType = session.user.type;
|
||||
|
||||
const messageCount = await getMessageCountByUserId({
|
||||
id: session.user.id,
|
||||
differenceInHours: 1,
|
||||
});
|
||||
|
||||
if (messageCount > entitlementsByUserType[userType].maxMessagesPerHour) {
|
||||
return new ChatbotError("rate_limit:chat").toResponse();
|
||||
}
|
||||
|
||||
const isToolApprovalFlow = Boolean(messages);
|
||||
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue