Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ DISABLE_EMAIL_SIGN_UP=
# Set this to 1 to disable OAuth sign-ups (Google, GitHub, Microsoft)
DISABLE_SIGN_UP=

# (Optional) AI rate limits (per user, by role). Requires REDIS_URL.
# Leave empty to disable for that role.
AI_RATE_LIMIT_USER_PER_HOUR=
AI_RATE_LIMIT_USER_PER_DAY=
AI_RATE_LIMIT_EDITOR_PER_HOUR=
AI_RATE_LIMIT_EDITOR_PER_DAY=
AI_RATE_LIMIT_ADMIN_PER_HOUR=
AI_RATE_LIMIT_ADMIN_PER_DAY=

# (Optional) Fallback limits for roles not set above
AI_RATE_LIMIT_PER_HOUR=
AI_RATE_LIMIT_PER_DAY=

# (Optional)
# Set this to 1 to disallow adding MCP servers.
NOT_ALLOW_ADD_MCP_SERVERS=
Expand Down
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@
"Chat": {
"Error": "Chat Error",
"thisMessageWasNotSavedPleaseTryTheChatAgain": "This message was not saved. Please try the chat again.",
"rateLimitedTitle": "You’ve hit the AI limit",
"rateLimitedHourly": "You can send up to {limit} AI messages per hour.",
"rateLimitedDaily": "You can send up to {limit} AI messages per day.",
"rateLimitedRetryAfter": "Try again in {minutes} min",
"rateLimitedSupport": "Need more? Ask your admin.",
"uploadImage": "Upload File",
"generateImage": "Generate Image",
"imageUploadedSuccessfully": "Image uploaded successfully",
Expand Down
8 changes: 5 additions & 3 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
"Chat": {
"Error": "Error de Chat",
"thisMessageWasNotSavedPleaseTryTheChatAgain": "Este mensaje no se guardó. Por favor, intenta el chat nuevamente.",
"rateLimitedTitle": "Has alcanzado el límite de IA",
"rateLimitedHourly": "Puedes enviar hasta {limit} mensajes de IA por hora.",
"rateLimitedDaily": "Puedes enviar hasta {limit} mensajes de IA por día.",
"rateLimitedRetryAfter": "Inténtalo de nuevo en {minutes} min",
"rateLimitedSupport": "¿Necesitas más? Habla con tu administrador.",
"uploadImage": "Subir archivo",
"generateImage": "Generar Imagen",
"imageUploadedSuccessfully": "Imagen subida exitosamente",
Expand Down Expand Up @@ -146,7 +151,6 @@
},
"Thread": {
"chat": "Chat",

"renameChat": "Renombrar",
"deleteChat": "Eliminar Chat",
"deleteUnarchivedChats": "Eliminar Todos los Chats No Archivados",
Expand All @@ -166,7 +170,6 @@
"createLink": "Crear Enlace",
"linkCopied": "Enlace copiado"
},

"ChatPreferences": {
"title": "Preferencias de Chat",
"whatShouldWeCallYou": "¿Cómo deberíamos llamarte?",
Expand Down Expand Up @@ -233,7 +236,6 @@
"theme": "Tema",
"signOut": "Cerrar sesión",
"language": "Idioma",

"showAllChats": "Ver Todos los Chats",
"showLessChats": "Mostrar menos",
"reportAnIssue": "Reportar un problema",
Expand Down
8 changes: 5 additions & 3 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
"Chat": {
"Error": "Erreur de Chat",
"thisMessageWasNotSavedPleaseTryTheChatAgain": "Ce message n'a pas été enregistré. Veuillez réessayer le chat.",
"rateLimitedTitle": "Vous avez atteint la limite IA",
"rateLimitedHourly": "Vous pouvez envoyer jusqu'à {limit} messages IA par heure.",
"rateLimitedDaily": "Vous pouvez envoyer jusqu'à {limit} messages IA par jour.",
"rateLimitedRetryAfter": "Réessayez dans {minutes} min",
"rateLimitedSupport": "Besoin de plus ? Contactez votre administrateur.",
"uploadImage": "Téléverser un fichier",
"generateImage": "Générer une Image",
"imageUploadedSuccessfully": "Image téléchargée avec succès",
Expand Down Expand Up @@ -146,7 +151,6 @@
},
"Thread": {
"chat": "Chat",

"renameChat": "Renommer",
"deleteChat": "Supprimer le Chat",
"deleteUnarchivedChats": "Supprimer Tous les Chats Non Archivés",
Expand All @@ -166,7 +170,6 @@
"createLink": "Créer le Lien",
"linkCopied": "Lien copié"
},

"ChatPreferences": {
"title": "Préférences de Chat",
"whatShouldWeCallYou": "Comment devrions-nous vous appeler ?",
Expand Down Expand Up @@ -233,7 +236,6 @@
"theme": "Thème",
"signOut": "Se déconnecter",
"language": "Langue",

"showAllChats": "Voir Tous les Chats",
"showLessChats": "Afficher moins",
"reportAnIssue": "Signaler un problème",
Expand Down
7 changes: 5 additions & 2 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
"Chat": {
"Error": "チャットエラー",
"thisMessageWasNotSavedPleaseTryTheChatAgain": "このメッセージは保存されませんでした。もう一度チャットをお試しください。",
"rateLimitedTitle": "AI の制限に達しました",
"rateLimitedHourly": "1 時間に {limit} 件まで AI メッセージを送信できます。",
"rateLimitedDaily": "1 日に {limit} 件まで AI メッセージを送信できます。",
"rateLimitedRetryAfter": "{minutes} 分後に再試行してください",
"rateLimitedSupport": "上限を増やすには管理者に連絡してください。",
"uploadImage": "ファイルをアップロード",
"generateImage": "画像を生成",
"imageUploadedSuccessfully": "画像が正常にアップロードされました",
Expand Down Expand Up @@ -146,7 +151,6 @@
},
"Thread": {
"chat": "チャット",

"renameChat": "名前を変更",
"deleteChat": "チャットを削除",
"deleteUnarchivedChats": "アーカイブされていないチャットをすべて削除",
Expand All @@ -166,7 +170,6 @@
"createLink": "リンクを作成",
"linkCopied": "リンクがコピーされました"
},

"ChatPreferences": {
"title": "チャット設定",
"whatShouldWeCallYou": "何とお呼びすればよろしいですか?",
Expand Down
7 changes: 5 additions & 2 deletions messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
"Chat": {
"Error": "채팅 오류",
"thisMessageWasNotSavedPleaseTryTheChatAgain": "이 메시지는 저장되지 않았습니다. 다시 시도해주세요.",
"rateLimitedTitle": "AI 한도에 도달했습니다",
"rateLimitedHourly": "한 시간에 최대 {limit}개의 AI 메시지를 보낼 수 있습니다.",
"rateLimitedDaily": "하루에 최대 {limit}개의 AI 메시지를 보낼 수 있습니다.",
"rateLimitedRetryAfter": "{minutes}분 후에 다시 시도해주세요",
"rateLimitedSupport": "더 필요하면 관리자에게 문의하세요.",
"uploadImage": "파일 업로드",
"generateImage": "이미지 만들기",
"imageUploadedSuccessfully": "이미지가 성공적으로 업로드되었습니다",
Expand Down Expand Up @@ -147,7 +152,6 @@
},
"Thread": {
"chat": "채팅",

"renameChat": "채팅 이름 변경",
"deleteChat": "채팅 삭제",
"deleteUnarchivedChats": "아카이브되지 않은 채팅 모두 삭제",
Expand All @@ -167,7 +171,6 @@
"createLink": "링크 만들기",
"linkCopied": "링크가 복사되었습니다"
},

"ChatPreferences": {
"title": "채팅 환경설정",
"whatShouldWeCallYou": "뭐라고 불러드릴까요?",
Expand Down
5 changes: 5 additions & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
"Chat": {
"Error": "聊天错误",
"thisMessageWasNotSavedPleaseTryTheChatAgain": "此消息未保存。请重试聊天。",
"rateLimitedTitle": "已达到 AI 限制",
"rateLimitedHourly": "每小时最多可发送 {limit} 条 AI 消息。",
"rateLimitedDaily": "每天最多可发送 {limit} 条 AI 消息。",
"rateLimitedRetryAfter": "请在 {minutes} 分钟后重试",
"rateLimitedSupport": "需要更高额度?请联系管理员。",
"uploadImage": "上传文件",
"generateImage": "生成图片",
"imageUploadedSuccessfully": "图片上传成功",
Expand Down
18 changes: 18 additions & 0 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { nanoBananaTool, openaiImageTool } from "lib/ai/tools/image";
import { ImageToolName } from "lib/ai/tools";
import { buildCsvIngestionPreviewParts } from "@/lib/ai/ingest/csv-ingest";
import { serverFileStorage } from "lib/file-storage";
import { getAiRateLimiter } from "lib/ai/rate-limit";
import { buildRateLimitMessage } from "lib/ai/rate-limit-message";

const logger = globalLogger.withDefaults({
message: colorize("blackBright", `Chat API: `),
Expand All @@ -65,6 +67,22 @@ export async function POST(request: Request) {
if (!session?.user.id) {
return new Response("Unauthorized", { status: 401 });
}

const rateLimiter = getAiRateLimiter();
if (rateLimiter) {
const rateLimitResult = await rateLimiter.check(
session.user.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session.user as any).role,
);
if (!rateLimitResult.ok) {
const message = buildRateLimitMessage(rateLimitResult);
return new Response(message, {
status: 429,
headers: { "Retry-After": `${rateLimitResult.retryAfterSeconds}` },
});
}
}
const {
id,
message,
Expand Down
18 changes: 18 additions & 0 deletions src/app/api/chat/temporary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { customModelProvider } from "lib/ai/models";
import globalLogger from "logger";
import { buildUserSystemPrompt } from "lib/ai/prompts";
import { getUserPreferences } from "lib/user/server";
import { getAiRateLimiter } from "lib/ai/rate-limit";
import { buildRateLimitMessage } from "lib/ai/rate-limit-message";

import { colorize } from "consola/utils";

Expand All @@ -25,6 +27,22 @@ export async function POST(request: Request) {
return new Response("Unauthorized", { status: 401 });
}

const rateLimiter = getAiRateLimiter();
if (rateLimiter) {
const rateLimitResult = await rateLimiter.check(
session.user.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session.user as any).role,
);
if (!rateLimitResult.ok) {
const message = buildRateLimitMessage(rateLimitResult);
return new Response(message, {
status: 429,
headers: { "Retry-After": `${rateLimitResult.retryAfterSeconds}` },
});
}
}

const { messages, chatModel, instructions } = json as {
messages: UIMessage[];
chatModel?: {
Expand Down
48 changes: 48 additions & 0 deletions src/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ChevronDown, ChevronUp, TriangleAlertIcon } from "lucide-react";
import { Button } from "ui/button";
import { useTranslations } from "next-intl";
import { ChatMetadata } from "app-types/chat";
import { parseRateLimitMessage } from "lib/ai/rate-limit-message";

interface Props {
message: UIMessage;
Expand Down Expand Up @@ -210,6 +211,53 @@ export const ErrorMessage = ({
const [isExpanded, setIsExpanded] = useState(false);
const maxLength = 200;
const t = useTranslations();
const parsedRateLimit = parseRateLimitMessage(error.message);

if (parsedRateLimit) {
const retryMinutes = Math.max(
1,
Math.ceil(parsedRateLimit.retryAfterSeconds / 60),
);

return (
<div className="w-full mx-auto max-w-3xl px-6 animate-in fade-in mt-4">
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-red-50 via-background to-background dark:from-red-950/40 dark:via-background dark:to-background">
<div className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_20%_20%,rgba(248,113,113,0.15),transparent_35%),radial-gradient(circle_at_80%_0%,rgba(94,234,212,0.1),transparent_25%)]" />
<div className="relative flex flex-col gap-3 p-5">
<div className="flex items-start gap-3">
<div className="p-2 bg-destructive/10 border border-destructive/30 rounded-lg shadow-sm text-destructive">
<TriangleAlertIcon className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="font-semibold text-sm tracking-tight">
{t("Chat.rateLimitedTitle")}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{parsedRateLimit.window === "hour"
? t("Chat.rateLimitedHourly", {
limit: parsedRateLimit.limit,
})
: t("Chat.rateLimitedDaily", {
limit: parsedRateLimit.limit,
})}
</p>
</div>
</div>

<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-1 bg-background/60">
<span className="h-2 w-2 rounded-full bg-destructive" />
{t("Chat.rateLimitedRetryAfter", { minutes: retryMinutes })}
</span>
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-1 bg-background/60">
{t("Chat.rateLimitedSupport")}
</span>
</div>
</div>
</div>
</div>
);
}
return (
<div className="w-full mx-auto max-w-3xl px-6 animate-in fade-in mt-4">
<div className="flex flex-col gap-2">
Expand Down
49 changes: 49 additions & 0 deletions src/lib/ai/rate-limit-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export const RATE_LIMIT_ERROR_PREFIX = "AI_RATE_LIMIT";

export type RateLimitWindow = "hour" | "day";

export interface RateLimitMessagePayload {
window: RateLimitWindow;
retryAfterSeconds: number;
limit: number;
}

export const buildRateLimitMessage = (
payload: RateLimitMessagePayload,
): string => {
return [
RATE_LIMIT_ERROR_PREFIX,
payload.window,
Math.max(0, Math.ceil(payload.retryAfterSeconds)),
payload.limit,
].join("|");
};

export const parseRateLimitMessage = (
message?: string,
): RateLimitMessagePayload | null => {
if (!message) return null;
const parts = message.split("|");
if (parts[0] !== RATE_LIMIT_ERROR_PREFIX) return null;
if (parts.length < 4) return null;

const window = parts[1] as RateLimitWindow;
const retryAfterSeconds = Number(parts[2]);
const limit = Number(parts[3]);

if (!Number.isFinite(retryAfterSeconds) || retryAfterSeconds < 0) {
return null;
}
if (!Number.isFinite(limit) || limit <= 0) {
return null;
}
if (window !== "hour" && window !== "day") {
return null;
}

return {
window,
retryAfterSeconds,
limit,
};
};
Loading
Loading