Phase 10: Per-User Rate Limits — messages + tokens, quota UI, admin usage
Backend:
- max_ai_messages_per_day + max_ai_tokens_per_day on User model (nullable, override)
- Migration 008: add columns + seed default settings (100 msgs, 500K tokens)
- usage_service: count today's messages + tokens, check quota, get limits
- GET /chats/quota returns usage vs limits + reset time
- POST /chats/{id}/messages checks quota before streaming (429 if exceeded)
- Admin user schemas expose both limit fields
- GET /admin/usage returns per-user daily message + token counts
- admin_user_service allows updating both limit fields
Frontend:
- Chat header shows "X/Y messages · XK/YK tokens" with red highlight at limit
- Quota refreshes every 30s via TanStack Query
- Admin usage page with table: user, messages today, tokens today
- Route + sidebar entry for admin usage
- English + Russian translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,8 @@
|
||||
"skills": "Skills",
|
||||
"personal_context": "My Context",
|
||||
"pdf": "PDF Reports",
|
||||
"pdf_templates": "Templates"
|
||||
"pdf_templates": "Templates",
|
||||
"usage": "Usage"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome, {{name}}",
|
||||
@@ -56,7 +57,9 @@
|
||||
"unarchive": "Unarchive",
|
||||
"delete_confirm": "Are you sure you want to delete this chat?",
|
||||
"limit_reached": "Chat limit reached",
|
||||
"streaming": "AI is thinking..."
|
||||
"streaming": "AI is thinking...",
|
||||
"quota_messages": "{{used}}/{{limit}} messages",
|
||||
"quota_tokens": "{{used}}K/{{limit}}K tokens"
|
||||
},
|
||||
"admin": {
|
||||
"context_editor": "Primary Context Editor",
|
||||
@@ -197,6 +200,13 @@
|
||||
"default_max_chats": "Default Max Chats",
|
||||
"default_max_chats_desc": "Default chat limit for new users"
|
||||
},
|
||||
"admin_usage": {
|
||||
"title": "Usage Statistics",
|
||||
"user": "User",
|
||||
"messages": "Messages Today",
|
||||
"tokens": "Tokens Today",
|
||||
"no_data": "No usage data available."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"skills": "Навыки",
|
||||
"personal_context": "Мой контекст",
|
||||
"pdf": "PDF отчёты",
|
||||
"pdf_templates": "Шаблоны"
|
||||
"pdf_templates": "Шаблоны",
|
||||
"usage": "Использование"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Добро пожаловать, {{name}}",
|
||||
@@ -56,7 +57,9 @@
|
||||
"unarchive": "Разархивировать",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить этот чат?",
|
||||
"limit_reached": "Достигнут лимит чатов",
|
||||
"streaming": "ИИ думает..."
|
||||
"streaming": "ИИ думает...",
|
||||
"quota_messages": "{{used}}/{{limit}} сообщений",
|
||||
"quota_tokens": "{{used}}K/{{limit}}K токенов"
|
||||
},
|
||||
"admin": {
|
||||
"context_editor": "Редактор основного контекста",
|
||||
@@ -197,6 +200,13 @@
|
||||
"default_max_chats": "Лимит чатов по умолчанию",
|
||||
"default_max_chats_desc": "Лимит чатов для новых пользователей"
|
||||
},
|
||||
"admin_usage": {
|
||||
"title": "Статистика использования",
|
||||
"user": "Пользователь",
|
||||
"messages": "Сообщения сегодня",
|
||||
"tokens": "Токены сегодня",
|
||||
"no_data": "Данных пока нет."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"error": "Произошла ошибка",
|
||||
|
||||
@@ -56,6 +56,21 @@ export async function deleteChat(chatId: string): Promise<void> {
|
||||
await api.delete(`/chats/${chatId}`);
|
||||
}
|
||||
|
||||
export interface QuotaInfo {
|
||||
messages_used: number;
|
||||
message_limit: number;
|
||||
tokens_used: number;
|
||||
token_limit: number;
|
||||
messages_exceeded: boolean;
|
||||
tokens_exceeded: boolean;
|
||||
resets_at: string;
|
||||
}
|
||||
|
||||
export async function getQuota(): Promise<QuotaInfo> {
|
||||
const { data } = await api.get<QuotaInfo>("/chats/quota");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMessages(
|
||||
chatId: string,
|
||||
limit = 50,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { MessageInput } from "./message-input";
|
||||
import { SkillSelector } from "./skill-selector";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { getQuota } from "@/api/chats";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface ChatWindowProps {
|
||||
@@ -16,6 +18,11 @@ export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: Cha
|
||||
const { t } = useTranslation();
|
||||
const { messages, isStreaming, streamingContent, currentChatId } = useChatStore();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { data: quota } = useQuery({
|
||||
queryKey: ["chat-quota"],
|
||||
queryFn: getQuota,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -32,15 +39,25 @@ export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: Cha
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{onChangeSkill && (
|
||||
{(onChangeSkill || quota) && (
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<div className="w-48">
|
||||
<SkillSelector
|
||||
value={currentSkillId ?? null}
|
||||
onChange={onChangeSkill}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
{quota && (
|
||||
<span className={`text-xs ${quota.messages_exceeded || quota.tokens_exceeded ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{t("chat.quota_messages", { used: quota.messages_used, limit: quota.message_limit })}
|
||||
{" · "}
|
||||
{t("chat.quota_tokens", { used: Math.round(quota.tokens_used / 1000), limit: Math.round(quota.token_limit / 1000) })}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{onChangeSkill && (
|
||||
<div className="w-48">
|
||||
<SkillSelector
|
||||
value={currentSkillId ?? null}
|
||||
onChange={onChangeSkill}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
|
||||
@@ -32,6 +32,7 @@ const adminItems = [
|
||||
{ key: "admin_users", to: "/admin/users", label: "layout.users" },
|
||||
{ key: "admin_settings", to: "/admin/settings", label: "layout.settings" },
|
||||
{ key: "admin_templates", to: "/admin/pdf-templates", label: "layout.pdf_templates" },
|
||||
{ key: "admin_usage", to: "/admin/usage", label: "layout.usage" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
71
frontend/src/pages/admin/usage.tsx
Normal file
71
frontend/src/pages/admin/usage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import api from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UserUsage {
|
||||
user_id: string;
|
||||
username: string;
|
||||
messages_today: number;
|
||||
message_limit: number;
|
||||
tokens_today: number;
|
||||
token_limit: number;
|
||||
}
|
||||
|
||||
export function AdminUsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["admin-usage"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ users: UserUsage[] }>("/admin/usage");
|
||||
return data.users;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") return <Navigate to="/" replace />;
|
||||
|
||||
const users = data || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">{t("admin_usage.title")}</h1>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("admin_usage.user")}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t("admin_usage.messages")}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t("admin_usage.tokens")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.user_id} className="border-b last:border-0">
|
||||
<td className="px-4 py-3">{u.username}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={cn(u.messages_today >= u.message_limit && "text-destructive font-medium")}>
|
||||
{u.messages_today} / {u.message_limit}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={cn(u.tokens_today >= u.token_limit && "text-destructive font-medium")}>
|
||||
{Math.round(u.tokens_today / 1000)}K / {Math.round(u.token_limit / 1000)}K
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">{t("admin_usage.no_data")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { PdfPage } from "@/pages/pdf";
|
||||
import { AdminUsersPage } from "@/pages/admin/users";
|
||||
import { AdminSettingsPage } from "@/pages/admin/settings";
|
||||
import { AdminPdfTemplatesPage } from "@/pages/admin/pdf-templates";
|
||||
import { AdminUsagePage } from "@/pages/admin/usage";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -47,6 +48,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "admin/users", element: <AdminUsersPage /> },
|
||||
{ path: "admin/settings", element: <AdminSettingsPage /> },
|
||||
{ path: "admin/pdf-templates", element: <AdminPdfTemplatesPage /> },
|
||||
{ path: "admin/usage", element: <AdminUsagePage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user