Daily message limit per user ()

* set message limit per user

* remove old limit user messages + unused admin page

* fix daily message validation

* refactor message limit input
refactor canSendChat on user to a method on user model

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-10-15 14:01:29 -07:00 committed by GitHub
parent 15ca5e8103
commit be6289d141
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 172 additions and 345 deletions
frontend/src
App.jsx
components
SettingsButton
SettingsSidebar
UserMenu/AccountModal
pages/Admin
System
Users
NewUserModal
UserRow/EditUserModal
index.jsx
utils
server

View file

@ -22,7 +22,6 @@ const WorkspaceChat = lazy(() => import("@/pages/WorkspaceChat"));
const AdminUsers = lazy(() => import("@/pages/Admin/Users"));
const AdminInvites = lazy(() => import("@/pages/Admin/Invitations"));
const AdminWorkspaces = lazy(() => import("@/pages/Admin/Workspaces"));
const AdminSystem = lazy(() => import("@/pages/Admin/System"));
const AdminLogs = lazy(() => import("@/pages/Admin/Logging"));
const AdminAgents = lazy(() => import("@/pages/Admin/Agents"));
const GeneralChats = lazy(() => import("@/pages/GeneralSettings/Chats"));
@ -168,10 +167,6 @@ export default function App() {
path="/settings/workspace-chats"
element={<ManagerRoute Component={GeneralChats} />}
/>
<Route
path="/settings/system-preferences"
element={<ManagerRoute Component={AdminSystem} />}
/>
<Route
path="/settings/invites"
element={<ManagerRoute Component={AdminInvites} />}

View file

@ -29,9 +29,7 @@ export default function SettingsButton() {
return (
<ToolTipWrapper id="open-settings">
<Link
to={
!!user?.role ? paths.settings.system() : paths.settings.appearance()
}
to={paths.settings.appearance()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
aria-label="Settings"
data-tooltip-id="open-settings"

View file

@ -278,11 +278,6 @@ const SidebarOptions = ({ user = null, t }) => (
href: paths.settings.invites(),
roles: ["admin", "manager"],
},
{
btnText: t("settings.system"),
href: paths.settings.system(),
roles: ["admin", "manager"],
},
]}
/>
<Option

View file

@ -135,7 +135,7 @@ export default function AccountModal({ user, hideModal }) {
autoComplete="off"
/>
<p className="mt-2 text-xs text-white/60">
Username must be only contain lowercase letters, numbers,
Username must only contain lowercase letters, numbers,
underscores, and hyphens with no spaces
</p>
</div>

View file

@ -1,128 +0,0 @@
import { useEffect, useState } from "react";
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import Admin from "@/models/admin";
import showToast from "@/utils/toast";
import CTAButton from "@/components/lib/CTAButton";
export default function AdminSystem() {
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [messageLimit, setMessageLimit] = useState({
enabled: false,
limit: 10,
});
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
await Admin.updateSystemPreferences({
limit_user_messages: messageLimit.enabled,
message_limit: messageLimit.limit,
});
setSaving(false);
setHasChanges(false);
showToast("System preferences updated successfully.", "success");
};
useEffect(() => {
async function fetchSettings() {
const settings = (await Admin.systemPreferences())?.settings;
if (!settings) return;
setMessageLimit({
enabled: settings.limit_user_messages,
limit: settings.message_limit,
});
}
fetchSettings();
}, []);
return (
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-main-gradient w-full h-full overflow-y-scroll"
>
<form
onSubmit={handleSubmit}
onChange={() => setHasChanges(true)}
className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16"
>
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
<div className="items-center">
<p className="text-lg leading-6 font-bold text-white">
System Preferences
</p>
</div>
<p className="text-xs leading-[18px] font-base text-white text-opacity-60">
These are the overall settings and configurations of your
instance.
</p>
</div>
{hasChanges && (
<div className="flex justify-end">
<CTAButton onClick={handleSubmit} className="mt-3 mr-0">
{saving ? "Saving..." : "Save changes"}
</CTAButton>
</div>
)}
<div className="mt-4 mb-8">
<div className="flex flex-col gap-y-1">
<h2 className="text-base leading-6 font-bold text-white">
Limit messages per user per day
</h2>
<p className="text-xs leading-[18px] font-base text-white/60">
Restrict non-admin users to a number of successful queries or
chats within a 24 hour window. Enable this to prevent users from
running up OpenAI costs.
</p>
<div className="mt-2">
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
name="limit_user_messages"
value="yes"
checked={messageLimit.enabled}
onChange={(e) => {
setMessageLimit({
...messageLimit,
enabled: e.target.checked,
});
}}
className="peer sr-only"
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label>
</div>
</div>
{messageLimit.enabled && (
<div className="mt-4">
<label className="text-white text-sm font-semibold block mb-4">
Message limit per day
</label>
<div className="relative mt-2">
<input
type="number"
name="message_limit"
onScroll={(e) => e.target.blur()}
onChange={(e) => {
setMessageLimit({
enabled: true,
limit: Number(e?.target?.value || 0),
});
}}
value={messageLimit.limit}
min={1}
className="bg-zinc-900 text-white placeholder:text-white/20 text-sm rounded-lg focus:border-white block w-60 p-2.5"
/>
</div>
</div>
)}
</div>
</form>
</div>
</div>
);
}

View file

@ -2,11 +2,15 @@ import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "@/models/admin";
import { userFromStorage } from "@/utils/request";
import { RoleHintDisplay } from "..";
import { MessageLimitInput, RoleHintDisplay } from "..";
export default function NewUserModal({ closeModal }) {
const [error, setError] = useState(null);
const [role, setRole] = useState("default");
const [messageLimit, setMessageLimit] = useState({
enabled: false,
limit: 10,
});
const handleCreate = async (e) => {
setError(null);
@ -14,6 +18,8 @@ export default function NewUserModal({ closeModal }) {
const data = {};
const form = new FormData(e.target);
for (var [key, value] of form.entries()) data[key] = value;
data.dailyMessageLimit = messageLimit.enabled ? messageLimit.limit : null;
const { user, error } = await Admin.newUser(data);
if (!!user) window.location.reload();
setError(error);
@ -58,13 +64,13 @@ export default function NewUserModal({ closeModal }) {
pattern="^[a-z0-9_-]+$"
onInvalid={(e) =>
e.target.setCustomValidity(
"Username must be only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
"Username must only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
)
}
onChange={(e) => e.target.setCustomValidity("")}
/>
<p className="mt-2 text-xs text-white/60">
Username must be only contain lowercase letters, numbers,
Username must only contain lowercase letters, numbers,
underscores, and hyphens with no spaces
</p>
</div>
@ -110,6 +116,12 @@ export default function NewUserModal({ closeModal }) {
</select>
<RoleHintDisplay role={role} />
</div>
<MessageLimitInput
role={role}
enabled={messageLimit.enabled}
limit={messageLimit.limit}
updateState={setMessageLimit}
/>
{error && <p className="text-red-400 text-sm">Error: {error}</p>}
<p className="text-white text-xs md:text-sm">
After creating a user they will need to login with their initial

View file

@ -1,11 +1,15 @@
import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "@/models/admin";
import { RoleHintDisplay } from "../..";
import { MessageLimitInput, RoleHintDisplay } from "../..";
export default function EditUserModal({ currentUser, user, closeModal }) {
const [role, setRole] = useState(user.role);
const [error, setError] = useState(null);
const [messageLimit, setMessageLimit] = useState({
enabled: user.dailyMessageLimit !== null,
limit: user.dailyMessageLimit || 10,
});
const handleUpdate = async (e) => {
setError(null);
@ -16,6 +20,12 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
if (!value || value === null) continue;
data[key] = value;
}
if (messageLimit.enabled) {
data.dailyMessageLimit = messageLimit.limit;
} else {
data.dailyMessageLimit = null;
}
const { success, error } = await Admin.updateUser(user.id, data);
if (success) window.location.reload();
setError(error);
@ -58,7 +68,7 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
autoComplete="off"
/>
<p className="mt-2 text-xs text-white/60">
Username must be only contain lowercase letters, numbers,
Username must only contain lowercase letters, numbers,
underscores, and hyphens with no spaces
</p>
</div>
@ -103,6 +113,12 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
</select>
<RoleHintDisplay role={role} />
</div>
<MessageLimitInput
role={role}
enabled={messageLimit.enabled}
limit={messageLimit.limit}
updateState={setMessageLimit}
/>
{error && <p className="text-red-400 text-sm">Error: {error}</p>}
</div>
</div>

View file

@ -135,3 +135,58 @@ export function RoleHintDisplay({ role }) {
</div>
);
}
export function MessageLimitInput({ enabled, limit, updateState, role }) {
if (role === "admin") return null;
return (
<div className="mt-4 mb-8">
<div className="flex flex-col gap-y-1">
<div className="flex items-center gap-x-2">
<h2 className="text-base leading-6 font-bold text-white">
Limit messages per day
</h2>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
updateState((prev) => ({
...prev,
enabled: e.target.checked,
}));
}}
className="peer sr-only"
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
</label>
</div>
<p className="text-xs leading-[18px] font-base text-white/60">
Restrict this user to a number of successful queries or chats within a
24 hour window.
</p>
</div>
{enabled && (
<div className="mt-4">
<label className="text-white text-sm font-semibold block mb-4">
Message limit per day
</label>
<div className="relative mt-2">
<input
type="number"
onScroll={(e) => e.target.blur()}
onChange={(e) => {
updateState({
enabled: true,
limit: Number(e?.target?.value || 0),
});
}}
value={limit}
min={1}
className="bg-zinc-900 text-white placeholder:text-white/20 text-sm rounded-lg focus:border-white block w-60 p-2.5"
/>
</div>
</div>
)}
</div>
);
}

View file

@ -80,9 +80,6 @@ export default {
return `/fine-tuning`;
},
settings: {
system: () => {
return `/settings/system-preferences`;
},
users: () => {
return `/settings/users`;
},

View file

@ -347,14 +347,6 @@ function adminEndpoints(app) {
: await SystemSettings.get({ label });
switch (label) {
case "limit_user_messages":
requestedSettings[label] = setting?.value === "true";
break;
case "message_limit":
requestedSettings[label] = setting?.value
? Number(setting.value)
: 10;
break;
case "footer_data":
requestedSettings[label] = setting?.value ?? JSON.stringify([]);
break;
@ -422,13 +414,6 @@ function adminEndpoints(app) {
try {
const embedder = getEmbeddingEngineSelection();
const settings = {
limit_user_messages:
(await SystemSettings.get({ label: "limit_user_messages" }))
?.value === "true",
message_limit:
Number(
(await SystemSettings.get({ label: "message_limit" }))?.value
) || 10,
footer_data:
(await SystemSettings.get({ label: "footer_data" }))?.value ||
JSON.stringify([]),

View file

@ -595,56 +595,6 @@ function apiAdminEndpoints(app) {
}
);
app.get("/v1/admin/preferences", [validApiKey], async (request, response) => {
/*
#swagger.tags = ['Admin']
#swagger.description = 'Show all multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.'
#swagger.responses[200] = {
content: {
"application/json": {
schema: {
type: 'object',
example: {
settings: {
limit_user_messages: false,
message_limit: 10,
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
}
}
#swagger.responses[401] = {
description: "Instance is not in Multi-User mode. Method denied",
}
*/
try {
if (!multiUserMode(response)) {
response.sendStatus(401).end();
return;
}
const settings = {
limit_user_messages:
(await SystemSettings.get({ label: "limit_user_messages" }))
?.value === "true",
message_limit:
Number(
(await SystemSettings.get({ label: "message_limit" }))?.value
) || 10,
};
response.status(200).json({ settings });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
});
app.post(
"/v1/admin/preferences",
[validApiKey],
@ -658,8 +608,7 @@ function apiAdminEndpoints(app) {
content: {
"application/json": {
example: {
limit_user_messages: true,
message_limit: 5,
support_email: "support@example.com",
}
}
}

View file

@ -1,8 +1,6 @@
const { v4: uuidv4 } = require("uuid");
const { reqBody, userFromSession, multiUserMode } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { WorkspaceChats } = require("../models/workspaceChats");
const { SystemSettings } = require("../models/systemSettings");
const { Telemetry } = require("../models/telemetry");
const { streamChatWithWorkspace } = require("../utils/chats/stream");
const {
@ -16,6 +14,7 @@ const {
} = require("../utils/middleware/validWorkspace");
const { writeResponseChunk } = require("../utils/helpers/chat/responses");
const { WorkspaceThread } = require("../models/workspaceThread");
const { User } = require("../models/user");
const truncate = require("truncate");
function chatEndpoints(app) {
@ -48,39 +47,16 @@ function chatEndpoints(app) {
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
if (multiUserMode(response) && user.role !== ROLES.admin) {
const limitMessagesSetting = await SystemSettings.get({
label: "limit_user_messages",
if (multiUserMode(response) && !(await User.canSendChat(user))) {
writeResponseChunk(response, {
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `You have met your maximum 24 hour chat quota of ${user.dailyMessageLimit} chats. Try again later.`,
});
const limitMessages = limitMessagesSetting?.value === "true";
if (limitMessages) {
const messageLimitSetting = await SystemSettings.get({
label: "message_limit",
});
const systemLimit = Number(messageLimitSetting?.value);
if (!!systemLimit) {
const currentChatCount = await WorkspaceChats.count({
user_id: user.id,
createdAt: {
gte: new Date(new Date() - 24 * 60 * 60 * 1000),
},
});
if (currentChatCount >= systemLimit) {
writeResponseChunk(response, {
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`,
});
return;
}
}
}
return;
}
await streamChatWithWorkspace(
@ -157,41 +133,16 @@ function chatEndpoints(app) {
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
if (multiUserMode(response) && user.role !== ROLES.admin) {
const limitMessagesSetting = await SystemSettings.get({
label: "limit_user_messages",
if (multiUserMode(response) && !(await User.canSendChat(user))) {
writeResponseChunk(response, {
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `You have met your maximum 24 hour chat quota of ${user.dailyMessageLimit} chats. Try again later.`,
});
const limitMessages = limitMessagesSetting?.value === "true";
if (limitMessages) {
const messageLimitSetting = await SystemSettings.get({
label: "message_limit",
});
const systemLimit = Number(messageLimitSetting?.value);
if (!!systemLimit) {
// Chat qty includes all threads because any user can freely
// create threads and would bypass this rule.
const currentChatCount = await WorkspaceChats.count({
user_id: user.id,
createdAt: {
gte: new Date(new Date() - 24 * 60 * 60 * 1000),
},
});
if (currentChatCount >= systemLimit) {
writeResponseChunk(response, {
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`,
});
return;
}
}
}
return;
}
await streamChatWithWorkspace(

View file

@ -490,8 +490,6 @@ function systemEndpoints(app) {
await SystemSettings._updateSettings({
multi_user_mode: true,
limit_user_messages: false,
message_limit: 25,
});
await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);

View file

@ -16,8 +16,6 @@ function isNullOrNaN(value) {
const SystemSettings = {
protectedFields: ["multi_user_mode"],
publicFields: [
"limit_user_messages",
"message_limit",
"footer_data",
"support_email",
"text_splitter_chunk_size",
@ -33,8 +31,6 @@ const SystemSettings = {
"meta_page_favicon",
],
supportedFields: [
"limit_user_messages",
"message_limit",
"logo_filename",
"telemetry_id",
"footer_data",

View file

@ -1,6 +1,17 @@
const prisma = require("../utils/prisma");
const { EventLogs } = require("./eventLogs");
/**
* @typedef {Object} User
* @property {number} id
* @property {string} username
* @property {string} password
* @property {string} pfpFilename
* @property {string} role
* @property {boolean} suspended
* @property {number|null} dailyMessageLimit
*/
const User = {
usernameRegex: new RegExp(/^[a-z0-9_-]+$/),
writable: [
@ -10,6 +21,7 @@ const User = {
"pfpFilename",
"role",
"suspended",
"dailyMessageLimit",
],
validations: {
username: (newValue = "") => {
@ -32,12 +44,24 @@ const User = {
}
return String(role);
},
dailyMessageLimit: (dailyMessageLimit = null) => {
if (dailyMessageLimit === null) return null;
const limit = Number(dailyMessageLimit);
if (isNaN(limit) || limit < 1) {
throw new Error(
"Daily message limit must be null or a number greater than or equal to 1"
);
}
return limit;
},
},
// validations for the above writable fields.
castColumnValue: function (key, value) {
switch (key) {
case "suspended":
return Number(Boolean(value));
case "dailyMessageLimit":
return value === null ? null : Number(value);
default:
return String(value);
}
@ -48,7 +72,12 @@ const User = {
return { ...rest };
},
create: async function ({ username, password, role = "default" }) {
create: async function ({
username,
password,
role = "default",
dailyMessageLimit = null,
}) {
const passwordCheck = this.checkPasswordComplexity(password);
if (!passwordCheck.checkedOK) {
return { user: null, error: passwordCheck.error };
@ -58,7 +87,7 @@ const User = {
// Do not allow new users to bypass validation
if (!this.usernameRegex.test(username))
throw new Error(
"Username must be only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
"Username must only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
);
const bcrypt = require("bcrypt");
@ -68,6 +97,8 @@ const User = {
username: this.validations.username(username),
password: hashedPassword,
role: this.validations.role(role),
dailyMessageLimit:
this.validations.dailyMessageLimit(dailyMessageLimit),
},
});
return { user: this.filterFields(user), error: null };
@ -135,7 +166,7 @@ const User = {
return {
success: false,
error:
"Username must be only contain lowercase letters, numbers, underscores, and hyphens with no spaces",
"Username must only contain lowercase letters, numbers, underscores, and hyphens with no spaces",
};
const user = await prisma.users.update({
@ -260,6 +291,29 @@ const User = {
return { checkedOK: true, error: "No error." };
},
/**
* Check if a user can send a chat based on their daily message limit.
* This limit is system wide and not per workspace and only applies to
* multi-user mode AND non-admin users.
* @param {User} user The user object record.
* @returns {Promise<boolean>} True if the user can send a chat, false otherwise.
*/
canSendChat: async function (user) {
const { ROLES } = require("../utils/middleware/multiUserProtected");
if (!user || user.dailyMessageLimit === null || user.role === ROLES.admin)
return true;
const { WorkspaceChats } = require("./workspaceChats");
const currentChatCount = await WorkspaceChats.count({
user_id: user.id,
createdAt: {
gte: new Date(new Date() - 24 * 60 * 60 * 1000), // 24 hours
},
});
return currentChatCount < user.dailyMessageLimit;
},
};
module.exports = { User };

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "dailyMessageLimit" INTEGER;

View file

@ -67,6 +67,7 @@ model users {
seen_recovery_codes Boolean? @default(false)
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
dailyMessageLimit Int?
workspace_chats workspace_chats[]
workspace_users workspace_users[]
embed_configs embed_configs[]
@ -309,4 +310,4 @@ model browser_extension_api_keys {
user users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
}
}

View file

@ -4,8 +4,6 @@ const prisma = new PrismaClient();
async function main() {
const settings = [
{ label: "multi_user_mode", value: "false" },
{ label: "limit_user_messages", value: "false" },
{ label: "message_limit", value: "25" },
{ label: "logo_filename", value: "anything-llm.png" },
];

View file

@ -693,52 +693,6 @@
}
},
"/v1/admin/preferences": {
"get": {
"tags": [
"Admin"
],
"description": "Show all multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"example": {
"settings": {
"limit_user_messages": false,
"message_limit": 10
}
}
}
}
}
},
"401": {
"description": "Instance is not in Multi-User mode. Method denied"
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidAPIKey"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/InvalidAPIKey"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
},
"post": {
"tags": [
"Admin"
@ -788,8 +742,7 @@
"content": {
"application/json": {
"example": {
"limit_user_messages": true,
"message_limit": 5
"support_email": "support@example.com"
}
}
}