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