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:
dmitry.galkin 2026-05-25 14:54:04 +04:00
commit 3e21c2334c
129 changed files with 21913 additions and 0 deletions

78
app/(chat)/actions.ts Normal file
View file

@ -0,0 +1,78 @@
"use server";
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import { auth } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/chat/visibility-selector";
import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getChatById,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
import { getTextFromMessage } from "@/lib/utils";
export async function saveChatModelAsCookie(model: string) {
const cookieStore = await cookies();
cookieStore.set("chat-model", model);
}
export async function generateTitleFromUserMessage({
message,
}: {
message: UIMessage;
}) {
const { text } = await generateText({
model: getTitleModel(),
system: titlePrompt,
prompt: getTextFromMessage(message),
});
return text
.replace(/^[#*"\s]+/, "")
.replace(/["]+$/, "")
.trim();
}
export async function deleteTrailingMessages({ id }: { id: string }) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const [message] = await getMessageById({ id });
if (!message) {
throw new Error("Message not found");
}
const chat = await getChatById({ id: message.chatId });
if (!chat || chat.userId !== session.user.id) {
throw new Error("Unauthorized");
}
await deleteMessagesByChatIdAfterTimestamp({
chatId: message.chatId,
timestamp: message.createdAt,
});
}
export async function updateChatVisibility({
chatId,
visibility,
}: {
chatId: string;
visibility: VisibilityType;
}) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const chat = await getChatById({ id: chatId });
if (!chat || chat.userId !== session.user.id) {
throw new Error("Unauthorized");
}
await updateChatVisibilityById({ chatId, visibility });
}

View file

@ -0,0 +1,3 @@
export function GET() {
return new Response(null, { status: 204 });
}

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

View file

@ -0,0 +1,37 @@
import { z } from "zod";
const textPartSchema = z.object({
type: z.enum(["text"]),
text: z.string().min(1).max(2000),
});
const filePartSchema = z.object({
type: z.enum(["file"]),
mediaType: z.enum(["image/jpeg", "image/png", "image/webp", "image/gif"]),
name: z.string().min(1).max(100),
url: z.string(),
});
const partSchema = z.union([textPartSchema, filePartSchema]);
const userMessageSchema = z.object({
id: z.string().uuid(),
role: z.enum(["user"]),
parts: z.array(partSchema),
});
const toolApprovalMessageSchema = z.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(z.record(z.unknown())),
});
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
message: userMessageSchema.optional(),
messages: z.array(toolApprovalMessageSchema).optional(),
selectedChatModel: z.string(),
selectedVisibilityType: z.enum(["public", "private"]),
});
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

View file

@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const FileSchema = z.object({
file: z
.instanceof(Blob)
.refine((file) => file.size <= MAX_FILE_SIZE, {
message: "File size should be less than 5MB",
})
.refine((file) => ALLOWED_TYPES.includes(file.type), {
message: "File type should be JPEG, PNG, WebP, or GIF",
}),
});
export async function POST(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (request.body === null) {
return new Response("Request body is empty", { status: 400 });
}
try {
const formData = await request.formData();
const file = formData.get("file") as Blob;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const validatedFile = FileSchema.safeParse({ file });
if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
.map((error) => error.message)
.join(", ");
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
const filename = (formData.get("file") as File).name;
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const buffer = Buffer.from(await file.arrayBuffer());
const dataUrl = `data:${file.type};base64,${buffer.toString("base64")}`;
return NextResponse.json({
url: dataUrl,
pathname: safeName,
contentType: file.type,
});
} catch (_error) {
return NextResponse.json(
{ error: "Failed to process request" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,49 @@
import type { NextRequest } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const limit = Math.min(
Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1),
50
);
const startingAfter = searchParams.get("starting_after");
const endingBefore = searchParams.get("ending_before");
if (startingAfter && endingBefore) {
return new ChatbotError(
"bad_request:api",
"Only one of starting_after or ending_before can be provided."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
const chats = await getChatsByUserId({
id: session.user.id,
limit,
startingAfter,
endingBefore,
});
return Response.json(chats);
}
export async function DELETE() {
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
const result = await deleteAllChatsByUserId({ userId: session.user.id });
return Response.json(result, { status: 200 });
}

View file

@ -0,0 +1,43 @@
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
if (!chatId) {
return Response.json({ error: "chatId required" }, { status: 400 });
}
const [session, chat, messages] = await Promise.all([
auth(),
getChatById({ id: chatId }),
getMessagesByChatId({ id: chatId }),
]);
if (!chat) {
return Response.json({
messages: [],
visibility: "private",
userId: null,
isReadonly: false,
});
}
if (
chat.visibility === "private" &&
(!session?.user || session.user.id !== chat.userId)
) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const isReadonly = !session?.user || session.user.id !== chat.userId;
return Response.json({
messages: convertToUIMessages(messages),
visibility: chat.visibility,
userId: chat.userId,
isReadonly,
});
}

View file

@ -0,0 +1,9 @@
import { modelCapabilities } from "@/lib/ai/models";
export function GET() {
return Response.json(modelCapabilities, {
headers: {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
},
});
}

View file

@ -0,0 +1,84 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
const voteSchema = z.object({
chatId: z.string(),
messageId: z.string(),
type: z.enum(["up", "down"]),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
if (!chatId) {
return new ChatbotError(
"bad_request:api",
"Parameter chatId is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();
}
const chat = await getChatById({ id: chatId });
if (!chat) {
return new ChatbotError("not_found:chat").toResponse();
}
if (chat.userId !== session.user.id) {
return new ChatbotError("forbidden:vote").toResponse();
}
const votes = await getVotesByChatId({ id: chatId });
return Response.json(votes, { status: 200 });
}
export async function PATCH(request: Request) {
let chatId: string;
let messageId: string;
let type: "up" | "down";
try {
const parsed = voteSchema.parse(await request.json());
chatId = parsed.chatId;
messageId = parsed.messageId;
type = parsed.type;
} catch {
return new ChatbotError(
"bad_request:api",
"Parameters chatId, messageId, and type are required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();
}
const chat = await getChatById({ id: chatId });
if (!chat) {
return new ChatbotError("not_found:vote").toResponse();
}
if (chat.userId !== session.user.id) {
return new ChatbotError("forbidden:vote").toResponse();
}
await voteMessage({
chatId,
messageId,
type,
});
return new Response("Message voted", { status: 200 });
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

46
app/(chat)/layout.tsx Normal file
View file

@ -0,0 +1,46 @@
import { cookies } from "next/headers";
import { Suspense } from "react";
import { Toaster } from "sonner";
import { AppSidebar } from "@/components/chat/app-sidebar";
import { DataStreamProvider } from "@/components/chat/data-stream-provider";
import { ChatShell } from "@/components/chat/shell";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { ActiveChatProvider } from "@/hooks/use-active-chat";
import { auth } from "../(auth)/auth";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
<SidebarShell>{children}</SidebarShell>
</Suspense>
</DataStreamProvider>
);
}
async function SidebarShell({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<AppSidebar user={session?.user} />
<SidebarInset>
<Toaster
position="top-center"
theme="system"
toastOptions={{
className:
"!bg-card !text-foreground !border-border/50 !shadow-[var(--shadow-float)]",
}}
/>
<Suspense fallback={<div className="flex h-dvh" />}>
<ActiveChatProvider>
<ChatShell />
</ActiveChatProvider>
</Suspense>
{children}
</SidebarInset>
</SidebarProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

3
app/(chat)/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB