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
14
lib/ai/entitlements.ts
Normal file
14
lib/ai/entitlements.ts
Normal 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
123
lib/ai/models.mock.ts
Normal 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
67
lib/ai/models.test.ts
Normal 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
88
lib/ai/models.ts
Normal 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
37
lib/ai/prompts.ts
Normal 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
36
lib/ai/providers.ts
Normal 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);
|
||||
}
|
||||
78
lib/ai/tools/get-weather.ts
Normal file
78
lib/ai/tools/get-weather.ts
Normal 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}¤t=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
20
lib/constants.ts
Normal 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
33
lib/db/migrate.ts
Normal 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);
|
||||
});
|
||||
59
lib/db/migrations/0000_bouncy_payback.sql
Normal file
59
lib/db/migrations/0000_bouncy_payback.sql
Normal 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 $$;
|
||||
265
lib/db/migrations/meta/0000_snapshot.json
Normal file
265
lib/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
lib/db/migrations/meta/_journal.json
Normal file
13
lib/db/migrations/meta/_journal.json
Normal 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
426
lib/db/queries.ts
Normal 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
70
lib/db/schema.ts
Normal 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
16
lib/db/utils.ts
Normal 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
119
lib/errors.ts
Normal 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
27
lib/ratelimit.ts
Normal 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
32
lib/types.ts
Normal 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
77
lib/utils.ts
Normal 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('');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue