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;
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue