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

14
lib/ai/entitlements.ts Normal file
View file

@ -0,0 +1,14 @@
import type { UserType } from "@/app/(auth)/auth";
type Entitlements = {
maxMessagesPerHour: number;
};
export const entitlementsByUserType: Record<UserType, Entitlements> = {
guest: {
maxMessagesPerHour: 10,
},
regular: {
maxMessagesPerHour: 10,
},
};

123
lib/ai/models.mock.ts Normal file
View file

@ -0,0 +1,123 @@
import type { LanguageModel } from "ai";
const mockResponses: Record<string, string> = {
default: "This is a mock response for testing.",
weather: "The weather in San Francisco is sunny and 72°F.",
greeting: "Hello! How can I help you today?",
};
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
function getResponseForPrompt(prompt: unknown): string {
const promptStr = JSON.stringify(prompt).toLowerCase();
if (promptStr.includes("weather") || promptStr.includes("temperature")) {
return mockResponses.weather;
}
if (
promptStr.includes("hello") ||
promptStr.includes("hi") ||
promptStr.includes("hey")
) {
return mockResponses.greeting;
}
return mockResponses.default;
}
const createMockModel = (): LanguageModel => {
return {
specificationVersion: "v3",
provider: "mock",
modelId: "mock-model",
defaultObjectGenerationMode: "tool",
supportedUrls: {},
doGenerate: async ({ prompt }: { prompt: unknown }) => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: getResponseForPrompt(prompt) }],
warnings: [],
}),
doStream: ({ prompt }: { prompt: unknown }) => {
const response = getResponseForPrompt(prompt);
const words = response.split(" ");
return {
stream: new ReadableStream({
async start(controller) {
controller.enqueue({ type: "text-start", id: "t1" });
for (const word of words) {
controller.enqueue({
type: "text-delta",
id: "t1",
delta: `${word} `,
});
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({
type: "finish",
finishReason: "stop",
usage: mockUsage,
});
controller.close();
},
}),
};
},
} as unknown as LanguageModel;
};
const createMockTitleModel = (): LanguageModel => {
return {
specificationVersion: "v3",
provider: "mock",
modelId: "mock-title-model",
defaultObjectGenerationMode: "tool",
supportedUrls: {},
doGenerate: async () => ({
finishReason: "stop",
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
content: [{ type: "text", text: "Test Conversation" }],
warnings: [],
}),
doStream: () => ({
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "t1" });
controller.enqueue({
type: "text-delta",
id: "t1",
delta: "Test Conversation",
});
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({
type: "finish",
finishReason: "stop",
usage: {
inputTokens: {
total: 5,
noCache: 5,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
});
controller.close();
},
}),
}),
} as unknown as LanguageModel;
};
export const chatModel = createMockModel();
export const titleModel = createMockTitleModel();

67
lib/ai/models.test.ts Normal file
View file

@ -0,0 +1,67 @@
import type { LanguageModelV3GenerateResult } from "@ai-sdk/provider";
import { simulateReadableStream } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { getResponseChunksByPrompt } from "@/tests/prompts/utils";
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
const mockFinishReason = { unified: "stop" as const, raw: undefined };
const mockGenerateResult: LanguageModelV3GenerateResult = {
finishReason: mockFinishReason,
usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }],
warnings: [],
};
const titleGenerateResult: LanguageModelV3GenerateResult = {
finishReason: mockFinishReason,
usage: mockUsage,
content: [{ type: "text", text: "This is a test title" }],
warnings: [],
};
export const chatModel = new MockLanguageModelV3({
doGenerate: mockGenerateResult,
doStream: async ({ prompt }) => ({
stream: simulateReadableStream({
chunkDelayInMs: 500,
initialDelayInMs: 1000,
chunks: getResponseChunksByPrompt(prompt),
}),
}),
});
export const reasoningModel = new MockLanguageModelV3({
doGenerate: mockGenerateResult,
doStream: async ({ prompt }) => ({
stream: simulateReadableStream({
chunkDelayInMs: 500,
initialDelayInMs: 1000,
chunks: getResponseChunksByPrompt(prompt, true),
}),
}),
});
export const titleModel = new MockLanguageModelV3({
doGenerate: titleGenerateResult,
doStream: async () => ({
stream: simulateReadableStream({
chunkDelayInMs: 500,
initialDelayInMs: 1000,
chunks: [
{ id: "1", type: "text-start" as const },
{ id: "1", type: "text-delta" as const, delta: "This is a test title" },
{ id: "1", type: "text-end" as const },
{
type: "finish" as const,
finishReason: mockFinishReason,
usage: mockUsage,
},
],
}),
}),
});

88
lib/ai/models.ts Normal file
View file

@ -0,0 +1,88 @@
export const DEFAULT_CHAT_MODEL = "claude-sonnet";
export type ModelCapabilities = {
tools: boolean;
vision: boolean;
reasoning: boolean;
};
export type ChatModel = {
id: string;
name: string;
provider: string;
description: string;
};
export const chatModels: ChatModel[] = [
{
id: "claude-sonnet",
name: "Claude Sonnet 4.6",
provider: "anthropic",
description: "Anthropic's flagship model — balanced reasoning and speed",
},
{
id: "claude-haiku",
name: "Claude Haiku 4.5",
provider: "anthropic",
description: "Fast and cheap Anthropic model",
},
{
id: "gpt-5.4",
name: "GPT-5.4",
provider: "openai",
description: "OpenAI's flagship",
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
provider: "openai",
description: "Fast and cheap OpenAI model",
},
{
id: "kimi-k2.6",
name: "Kimi K2.6",
provider: "moonshotai",
description: "Moonshot AI flagship",
},
{
id: "minimax-m2.7",
name: "MiniMax M2.7",
provider: "minimax",
description: "MiniMax open-source model",
},
{
id: "qwen3-coder",
name: "Qwen3 Coder",
provider: "alibaba",
description: "Code-specialized model",
},
];
export const titleModel = chatModels.find((m) => m.id === "claude-haiku")!;
export const allowedModelIds = new Set(chatModels.map((m) => m.id));
export const modelsByProvider = chatModels.reduce(
(acc, model) => {
if (!acc[model.provider]) {
acc[model.provider] = [];
}
acc[model.provider].push(model);
return acc;
},
{} as Record<string, ChatModel[]>
);
export const modelCapabilities: Record<string, ModelCapabilities> = {
"claude-sonnet": { tools: true, vision: true, reasoning: true },
"claude-haiku": { tools: true, vision: true, reasoning: false },
"gpt-5.4": { tools: true, vision: true, reasoning: true },
"gpt-5.4-mini": { tools: true, vision: true, reasoning: false },
"kimi-k2.6": { tools: true, vision: false, reasoning: false },
"minimax-m2.7": { tools: true, vision: false, reasoning: false },
"qwen3-coder": { tools: true, vision: false, reasoning: false },
};
export function getActiveModels(): ChatModel[] {
return chatModels;
}

37
lib/ai/prompts.ts Normal file
View file

@ -0,0 +1,37 @@
export const regularPrompt = `You are a helpful assistant. Keep responses concise and direct.
When asked to write, create, or build something, do it immediately. Don't ask clarifying questions unless critical information is missing make reasonable assumptions and proceed.`;
export type RequestHints = {
city?: string;
country?: string;
};
export const getRequestPromptFromHints = (requestHints: RequestHints) => `\
About the origin of user's request:
- city: ${requestHints.city ?? "unknown"}
- country: ${requestHints.country ?? "unknown"}
`;
export const systemPrompt = ({
requestHints,
supportsTools: _supportsTools,
}: {
requestHints: RequestHints;
supportsTools: boolean;
}) => {
const requestPrompt = getRequestPromptFromHints(requestHints);
return `${regularPrompt}\n\n${requestPrompt}`;
};
export const titlePrompt = `Generate a short chat title (2-5 words) summarizing the user's message.
Output ONLY the title text. No prefixes, no formatting.
Examples:
- "what's the weather in nyc" Weather in NYC
- "help me write an essay about space" Space Essay Help
- "hi" New Conversation
- "debug my python code" Python Debugging
Never output hashtags, prefixes like "Title:", or quotes.`;

36
lib/ai/providers.ts Normal file
View file

@ -0,0 +1,36 @@
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { customProvider } from "ai";
import { isTestEnvironment } from "../constants";
import { titleModel } from "./models";
const litellm = createOpenAICompatible({
name: "egbe-litellm",
baseURL: process.env.EGBE_AI_API_URL ?? "https://proxy.gcp.egbe.dev/v1",
apiKey: process.env.EGBE_AI_API_KEY ?? "",
});
export const myProvider = isTestEnvironment
? (() => {
const { chatModel, titleModel: mockTitle } = require("./models.mock");
return customProvider({
languageModels: {
"chat-model": chatModel,
"title-model": mockTitle,
},
});
})()
: null;
export function getLanguageModel(modelId: string) {
if (isTestEnvironment && myProvider) {
return myProvider.languageModel(modelId);
}
return litellm.chatModel(modelId);
}
export function getTitleModel() {
if (isTestEnvironment && myProvider) {
return myProvider.languageModel("title-model");
}
return litellm.chatModel(titleModel.id);
}

View file

@ -0,0 +1,78 @@
import { tool } from "ai";
import { z } from "zod";
async function geocodeCity(
city: string
): Promise<{ latitude: number; longitude: number } | null> {
try {
const response = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`
);
if (!response.ok) {
return null;
}
const data = await response.json();
if (!data.results || data.results.length === 0) {
return null;
}
const result = data.results[0];
return {
latitude: result.latitude,
longitude: result.longitude,
};
} catch {
return null;
}
}
export const getWeather = tool({
description:
"Get the current weather at a location. You can provide either coordinates or a city name.",
inputSchema: z.object({
latitude: z.number().optional(),
longitude: z.number().optional(),
city: z
.string()
.describe("City name (e.g., 'San Francisco', 'New York', 'London')")
.optional(),
}),
execute: async (input) => {
let latitude: number;
let longitude: number;
if (input.city) {
const coords = await geocodeCity(input.city);
if (!coords) {
return {
error: `Could not find coordinates for "${input.city}". Please check the city name.`,
};
}
latitude = coords.latitude;
longitude = coords.longitude;
} else if (input.latitude !== undefined && input.longitude !== undefined) {
latitude = input.latitude;
longitude = input.longitude;
} else {
return {
error:
"Please provide either a city name or both latitude and longitude coordinates.",
};
}
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`
);
const weatherData = await response.json();
if ("city" in input) {
weatherData.cityName = input.city;
}
return weatherData;
},
});

20
lib/constants.ts Normal file
View file

@ -0,0 +1,20 @@
import { generateDummyPassword } from "./db/utils";
export const isProductionEnvironment = process.env.NODE_ENV === "production";
export const isDevelopmentEnvironment = process.env.NODE_ENV === "development";
export const isTestEnvironment = Boolean(
process.env.PLAYWRIGHT_TEST_BASE_URL ||
process.env.PLAYWRIGHT ||
process.env.CI_PLAYWRIGHT
);
export const guestRegex = /^guest-\d+$/;
export const DUMMY_PASSWORD = generateDummyPassword();
export const suggestions = [
"What are the advantages of using Next.js?",
"Write code to demonstrate Dijkstra's algorithm",
"Help me write an essay about Silicon Valley",
"What is the weather in San Francisco?",
];

33
lib/db/migrate.ts Normal file
View file

@ -0,0 +1,33 @@
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
config({
path: ".env.local",
});
const runMigrate = async () => {
if (!process.env.DATABASE_URL) {
console.log("DATABASE_URL not defined, skipping migrations");
process.exit(0);
}
const connection = postgres(process.env.DATABASE_URL, { max: 1 });
const db = drizzle(connection);
console.log("Running migrations...");
const start = Date.now();
await migrate(db, { migrationsFolder: "./lib/db/migrations" });
const end = Date.now();
console.log("Migrations completed in", end - start, "ms");
process.exit(0);
};
runMigrate().catch((err) => {
console.error("Migration failed");
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS "Chat" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"createdAt" timestamp NOT NULL,
"title" text NOT NULL,
"userId" uuid NOT NULL,
"visibility" varchar DEFAULT 'private' NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "Message_v2" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"chatId" uuid NOT NULL,
"role" varchar NOT NULL,
"parts" json NOT NULL,
"attachments" json NOT NULL,
"createdAt" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "User" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(64) NOT NULL,
"password" varchar(64),
"name" text,
"emailVerified" boolean DEFAULT false NOT NULL,
"image" text,
"isAnonymous" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "Vote_v2" (
"chatId" uuid NOT NULL,
"messageId" uuid NOT NULL,
"isUpvoted" boolean NOT NULL,
CONSTRAINT "Vote_v2_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Message_v2" ADD CONSTRAINT "Message_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,265 @@
{
"id": "e4157317-da3b-4af3-b3e6-dbddfc2e1ced",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Chat": {
"name": "Chat",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"visibility": {
"name": "visibility",
"type": "varchar",
"primaryKey": false,
"notNull": true,
"default": "'private'"
}
},
"indexes": {},
"foreignKeys": {
"Chat_userId_User_id_fk": {
"name": "Chat_userId_User_id_fk",
"tableFrom": "Chat",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Message_v2": {
"name": "Message_v2",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"parts": {
"name": "parts",
"type": "json",
"primaryKey": false,
"notNull": true
},
"attachments": {
"name": "attachments",
"type": "json",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Message_v2_chatId_Chat_id_fk": {
"name": "Message_v2_chatId_Chat_id_fk",
"tableFrom": "Message_v2",
"tableTo": "Chat",
"columnsFrom": [
"chatId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isAnonymous": {
"name": "isAnonymous",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Vote_v2": {
"name": "Vote_v2",
"schema": "",
"columns": {
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"messageId": {
"name": "messageId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"isUpvoted": {
"name": "isUpvoted",
"type": "boolean",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Vote_v2_chatId_Chat_id_fk": {
"name": "Vote_v2_chatId_Chat_id_fk",
"tableFrom": "Vote_v2",
"tableTo": "Chat",
"columnsFrom": [
"chatId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Vote_v2_messageId_Message_v2_id_fk": {
"name": "Vote_v2_messageId_Message_v2_id_fk",
"tableFrom": "Vote_v2",
"tableTo": "Message_v2",
"columnsFrom": [
"messageId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Vote_v2_chatId_messageId_pk": {
"name": "Vote_v2_chatId_messageId_pk",
"columns": [
"chatId",
"messageId"
]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779705963902,
"tag": "0000_bouncy_payback",
"breakpoints": true
}
]
}

426
lib/db/queries.ts Normal file
View file

@ -0,0 +1,426 @@
import "server-only";
import {
and,
asc,
count,
desc,
eq,
gt,
gte,
inArray,
lt,
type SQL,
} from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import type { VisibilityType } from "@/components/chat/visibility-selector";
import { ChatbotError } from "../errors";
import { generateUUID } from "../utils";
import {
type Chat,
chat,
type DBMessage,
message,
type User,
user,
vote,
} from "./schema";
import { generateHashedPassword } from "./utils";
const client = postgres(process.env.DATABASE_URL ?? "");
const db = drizzle(client);
export async function getUser(email: string): Promise<User[]> {
try {
return await db.select().from(user).where(eq(user.email, email));
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to get user by email"
);
}
}
export async function createUser(email: string, password: string) {
const hashedPassword = generateHashedPassword(password);
try {
return await db.insert(user).values({ email, password: hashedPassword });
} catch (_error) {
throw new ChatbotError("bad_request:database", "Failed to create user");
}
}
export async function createGuestUser() {
const email = `guest-${Date.now()}`;
const password = generateHashedPassword(generateUUID());
try {
return await db.insert(user).values({ email, password }).returning({
id: user.id,
email: user.email,
});
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to create guest user"
);
}
}
export async function saveChat({
id,
userId,
title,
visibility,
}: {
id: string;
userId: string;
title: string;
visibility: VisibilityType;
}) {
try {
return await db.insert(chat).values({
id,
createdAt: new Date(),
userId,
title,
visibility,
});
} catch (_error) {
throw new ChatbotError("bad_request:database", "Failed to save chat");
}
}
export async function deleteChatById({ id }: { id: string }) {
try {
await db.delete(vote).where(eq(vote.chatId, id));
await db.delete(message).where(eq(message.chatId, id));
const [chatsDeleted] = await db
.delete(chat)
.where(eq(chat.id, id))
.returning();
return chatsDeleted;
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to delete chat by id"
);
}
}
export async function deleteAllChatsByUserId({ userId }: { userId: string }) {
try {
const userChats = await db
.select({ id: chat.id })
.from(chat)
.where(eq(chat.userId, userId));
if (userChats.length === 0) {
return { deletedCount: 0 };
}
const chatIds = userChats.map((c) => c.id);
await db.delete(vote).where(inArray(vote.chatId, chatIds));
await db.delete(message).where(inArray(message.chatId, chatIds));
const deletedChats = await db
.delete(chat)
.where(eq(chat.userId, userId))
.returning();
return { deletedCount: deletedChats.length };
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to delete all chats by user id"
);
}
}
export async function getChatsByUserId({
id,
limit,
startingAfter,
endingBefore,
}: {
id: string;
limit: number;
startingAfter: string | null;
endingBefore: string | null;
}) {
try {
const extendedLimit = limit + 1;
const query = (whereCondition?: SQL<unknown>) =>
db
.select()
.from(chat)
.where(
whereCondition
? and(whereCondition, eq(chat.userId, id))
: eq(chat.userId, id)
)
.orderBy(desc(chat.createdAt))
.limit(extendedLimit);
let filteredChats: Chat[] = [];
if (startingAfter) {
const [selectedChat] = await db
.select()
.from(chat)
.where(eq(chat.id, startingAfter))
.limit(1);
if (!selectedChat) {
throw new ChatbotError(
"not_found:database",
`Chat with id ${startingAfter} not found`
);
}
filteredChats = await query(gt(chat.createdAt, selectedChat.createdAt));
} else if (endingBefore) {
const [selectedChat] = await db
.select()
.from(chat)
.where(eq(chat.id, endingBefore))
.limit(1);
if (!selectedChat) {
throw new ChatbotError(
"not_found:database",
`Chat with id ${endingBefore} not found`
);
}
filteredChats = await query(lt(chat.createdAt, selectedChat.createdAt));
} else {
filteredChats = await query();
}
const hasMore = filteredChats.length > limit;
return {
chats: hasMore ? filteredChats.slice(0, limit) : filteredChats,
hasMore,
};
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to get chats by user id"
);
}
}
export async function getChatById({ id }: { id: string }) {
try {
const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id));
if (!selectedChat) {
return null;
}
return selectedChat;
} catch (_error) {
throw new ChatbotError("bad_request:database", "Failed to get chat by id");
}
}
export async function saveMessages({ messages }: { messages: DBMessage[] }) {
try {
return await db.insert(message).values(messages);
} catch (_error) {
throw new ChatbotError("bad_request:database", "Failed to save messages");
}
}
export async function updateMessage({
id,
parts,
}: {
id: string;
parts: DBMessage["parts"];
}) {
try {
return await db.update(message).set({ parts }).where(eq(message.id, id));
} catch (_error) {
throw new ChatbotError("bad_request:database", "Failed to update message");
}
}
export async function getMessagesByChatId({ id }: { id: string }) {
try {
return await db
.select()
.from(message)
.where(eq(message.chatId, id))
.orderBy(asc(message.createdAt));
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to get messages by chat id"
);
}
}
export async function voteMessage({
chatId,
messageId,
type,
}: {
chatId: string;
messageId: string;
type: "up" | "down";
}) {
try {
const [existingVote] = await db
.select()
.from(vote)
.where(and(eq(vote.messageId, messageId)));
if (existingVote) {
return await db
.update(vote)
.set({ isUpvoted: type === "up" })
.where(and(eq(vote.messageId, messageId), eq(vote.chatId, chatId)));
}
return await db.insert(vote).values({
chatId,
messageId,
isUpvoted: type === "up",
});
} catch (_error) {
throw new ChatbotError("bad_request:database", "Failed to vote message");
}
}
export async function getVotesByChatId({ id }: { id: string }) {
try {
return await db.select().from(vote).where(eq(vote.chatId, id));
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to get votes by chat id"
);
}
}
export async function getMessageById({ id }: { id: string }) {
try {
return await db.select().from(message).where(eq(message.id, id));
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to get message by id"
);
}
}
export async function deleteMessagesByChatIdAfterTimestamp({
chatId,
timestamp,
}: {
chatId: string;
timestamp: Date;
}) {
try {
const messagesToDelete = await db
.select({ id: message.id })
.from(message)
.where(
and(eq(message.chatId, chatId), gte(message.createdAt, timestamp))
);
const messageIds = messagesToDelete.map(
(currentMessage) => currentMessage.id
);
if (messageIds.length > 0) {
await db
.delete(vote)
.where(
and(eq(vote.chatId, chatId), inArray(vote.messageId, messageIds))
);
return await db
.delete(message)
.where(
and(eq(message.chatId, chatId), inArray(message.id, messageIds))
);
}
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to delete messages by chat id after timestamp"
);
}
}
export async function updateChatVisibilityById({
chatId,
visibility,
}: {
chatId: string;
visibility: "private" | "public";
}) {
try {
return await db.update(chat).set({ visibility }).where(eq(chat.id, chatId));
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to update chat visibility by id"
);
}
}
export async function updateChatTitleById({
chatId,
title,
}: {
chatId: string;
title: string;
}) {
try {
return await db.update(chat).set({ title }).where(eq(chat.id, chatId));
} catch (_error) {
return;
}
}
export async function getMessageCountByUserId({
id,
differenceInHours,
}: {
id: string;
differenceInHours: number;
}) {
try {
const cutoffTime = new Date(
Date.now() - differenceInHours * 60 * 60 * 1000
);
const [stats] = await db
.select({ count: count(message.id) })
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.where(
and(
eq(chat.userId, id),
gte(message.createdAt, cutoffTime),
eq(message.role, "user")
)
)
.execute();
return stats?.count ?? 0;
} catch (_error) {
throw new ChatbotError(
"bad_request:database",
"Failed to get message count by user id"
);
}
}

70
lib/db/schema.ts Normal file
View file

@ -0,0 +1,70 @@
import type { InferSelectModel } from "drizzle-orm";
import {
boolean,
json,
pgTable,
primaryKey,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
export const user = pgTable("User", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
email: varchar("email", { length: 64 }).notNull(),
password: varchar("password", { length: 64 }),
name: text("name"),
emailVerified: boolean("emailVerified").notNull().default(false),
image: text("image"),
isAnonymous: boolean("isAnonymous").notNull().default(false),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type User = InferSelectModel<typeof user>;
export const chat = pgTable("Chat", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
createdAt: timestamp("createdAt").notNull(),
title: text("title").notNull(),
userId: uuid("userId")
.notNull()
.references(() => user.id),
visibility: varchar("visibility", { enum: ["public", "private"] })
.notNull()
.default("private"),
});
export type Chat = InferSelectModel<typeof chat>;
export const message = pgTable("Message_v2", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
chatId: uuid("chatId")
.notNull()
.references(() => chat.id),
role: varchar("role").notNull(),
parts: json("parts").notNull(),
attachments: json("attachments").notNull(),
createdAt: timestamp("createdAt").notNull(),
});
export type DBMessage = InferSelectModel<typeof message>;
export const vote = pgTable(
"Vote_v2",
{
chatId: uuid("chatId")
.notNull()
.references(() => chat.id),
messageId: uuid("messageId")
.notNull()
.references(() => message.id),
isUpvoted: boolean("isUpvoted").notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.chatId, table.messageId] }),
})
);
export type Vote = InferSelectModel<typeof vote>;

16
lib/db/utils.ts Normal file
View file

@ -0,0 +1,16 @@
import { generateId } from "ai";
import { genSaltSync, hashSync } from "bcrypt-ts";
export function generateHashedPassword(password: string) {
const salt = genSaltSync(10);
const hash = hashSync(password, salt);
return hash;
}
export function generateDummyPassword() {
const password = generateId();
const hashedPassword = generateHashedPassword(password);
return hashedPassword;
}

119
lib/errors.ts Normal file
View file

@ -0,0 +1,119 @@
export type ErrorType =
| "bad_request"
| "unauthorized"
| "forbidden"
| "not_found"
| "rate_limit"
| "offline";
export type Surface =
| "chat"
| "auth"
| "api"
| "stream"
| "database"
| "history"
| "vote";
export type ErrorCode = `${ErrorType}:${Surface}`;
export type ErrorVisibility = "response" | "log" | "none";
export const visibilityBySurface: Record<Surface, ErrorVisibility> = {
database: "log",
chat: "response",
auth: "response",
stream: "response",
api: "response",
history: "response",
vote: "response",
};
export class ChatbotError extends Error {
type: ErrorType;
surface: Surface;
statusCode: number;
constructor(errorCode: ErrorCode, cause?: string) {
super();
const [type, surface] = errorCode.split(":");
this.type = type as ErrorType;
this.cause = cause;
this.surface = surface as Surface;
this.message = getMessageByErrorCode(errorCode);
this.statusCode = getStatusCodeByType(this.type);
}
toResponse() {
const code: ErrorCode = `${this.type}:${this.surface}`;
const visibility = visibilityBySurface[this.surface];
const { message, cause, statusCode } = this;
if (visibility === "log") {
console.error({
code,
message,
cause,
});
return Response.json(
{ code: "", message: "Something went wrong. Please try again later." },
{ status: statusCode }
);
}
return Response.json({ code, message, cause }, { status: statusCode });
}
}
export function getMessageByErrorCode(errorCode: ErrorCode): string {
if (errorCode.includes("database")) {
return "An error occurred while executing a database query.";
}
switch (errorCode) {
case "bad_request:api":
return "The request couldn't be processed. Please check your input and try again.";
case "unauthorized:auth":
return "You need to sign in before continuing.";
case "forbidden:auth":
return "Your account does not have access to this feature.";
case "rate_limit:chat":
return "You've reached the message limit. Come back in 1 hour to continue chatting.";
case "not_found:chat":
return "The requested chat was not found. Please check the chat ID and try again.";
case "forbidden:chat":
return "This chat belongs to another user. Please check the chat ID and try again.";
case "unauthorized:chat":
return "You need to sign in to view this chat. Please sign in and try again.";
case "offline:chat":
return "We're having trouble sending your message. Please check your internet connection and try again.";
default:
return "Something went wrong. Please try again later.";
}
}
function getStatusCodeByType(type: ErrorType) {
switch (type) {
case "bad_request":
return 400;
case "unauthorized":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "rate_limit":
return 429;
case "offline":
return 503;
default:
return 500;
}
}

27
lib/ratelimit.ts Normal file
View file

@ -0,0 +1,27 @@
import { isProductionEnvironment } from "@/lib/constants";
import { ChatbotError } from "@/lib/errors";
const MAX_MESSAGES = 30;
const TTL_MS = 60 * 60 * 1000;
const ipCounts = new Map<string, { count: number; resetAt: number }>();
export async function checkIpRateLimit(ip: string | undefined) {
if (!isProductionEnvironment || !ip) {
return;
}
const now = Date.now();
const entry = ipCounts.get(ip);
if (!entry || entry.resetAt < now) {
ipCounts.set(ip, { count: 1, resetAt: now + TTL_MS });
return;
}
entry.count += 1;
if (entry.count > MAX_MESSAGES) {
throw new ChatbotError("rate_limit:chat");
}
}

32
lib/types.ts Normal file
View file

@ -0,0 +1,32 @@
import type { InferUITool, UIMessage } from "ai";
import { z } from "zod";
import type { getWeather } from "./ai/tools/get-weather";
export const messageMetadataSchema = z.object({
createdAt: z.string(),
});
export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
type weatherTool = InferUITool<typeof getWeather>;
export type ChatTools = {
getWeather: weatherTool;
};
export type CustomUIDataTypes = {
appendMessage: string;
"chat-title": string;
};
export type ChatMessage = UIMessage<
MessageMetadata,
CustomUIDataTypes,
ChatTools
>;
export type Attachment = {
name: string;
url: string;
contentType: string;
};

77
lib/utils.ts Normal file
View file

@ -0,0 +1,77 @@
import type {
UIMessage,
UIMessagePart,
} from 'ai';
import { type ClassValue, clsx } from 'clsx';
import { formatISO } from 'date-fns';
import { twMerge } from 'tailwind-merge';
import type { DBMessage } from '@/lib/db/schema';
import { ChatbotError, type ErrorCode } from './errors';
import type { ChatMessage, ChatTools, CustomUIDataTypes } from './types';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const fetcher = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
const { code, cause } = await response.json();
throw new ChatbotError(code as ErrorCode, cause);
}
return response.json();
};
export async function fetchWithErrorHandlers(
input: RequestInfo | URL,
init?: RequestInit,
) {
try {
const response = await fetch(input, init);
if (!response.ok) {
const { code, cause } = await response.json();
throw new ChatbotError(code as ErrorCode, cause);
}
return response;
} catch (error: unknown) {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new ChatbotError('offline:chat');
}
throw error;
}
}
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function sanitizeText(text: string) {
return text.replace('<has_function_call>', '');
}
export function convertToUIMessages(messages: DBMessage[]): ChatMessage[] {
return messages.map((message) => ({
id: message.id,
role: message.role as 'user' | 'assistant' | 'system',
parts: message.parts as UIMessagePart<CustomUIDataTypes, ChatTools>[],
metadata: {
createdAt: formatISO(message.createdAt),
},
}));
}
export function getTextFromMessage(message: ChatMessage | UIMessage): string {
return message.parts
.filter((part) => part.type === 'text')
.map((part) => (part as { type: 'text'; text: string}).text)
.join('');
}