diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ca66bdc9..30168aaf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,8 @@ const AdminInvites = lazy(() => import("./pages/Admin/Invitations")); const AdminWorkspaces = lazy(() => import("./pages/Admin/Workspaces")); const AdminChats = lazy(() => import("./pages/Admin/Chats")); const AdminSystem = lazy(() => import("./pages/Admin/System")); +const AdminAppearance = lazy(() => import("./pages/Admin/Appearance")); +const Appearance = lazy(() => import("./pages/System/Appearance")); export default function App() { return ( @@ -18,6 +20,7 @@ export default function App() { } /> + } /> } @@ -45,6 +48,10 @@ export default function App() { path="/admin/workspace-chats" element={} /> + } + /> diff --git a/frontend/src/components/AdminSidebar/index.jsx b/frontend/src/components/AdminSidebar/index.jsx index 6c8b8f8c..61402d66 100644 --- a/frontend/src/components/AdminSidebar/index.jsx +++ b/frontend/src/components/AdminSidebar/index.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { BookOpen, - Database, + Eye, GitHub, Mail, Menu, @@ -14,9 +14,12 @@ import IndexCount from "../Sidebar/IndexCount"; import LLMStatus from "../Sidebar/LLMStatus"; import paths from "../../utils/paths"; import Discord from "../Icons/Discord"; +import useLogo from "../../hooks/useLogo"; export default function AdminSidebar() { + const { logo } = useLogo(); const sidebarRef = useRef(null); + return ( <>
{/* Header Information */}
-

- AnythingLLM Admin -

+
+ Logo +
} /> +
@@ -117,6 +130,7 @@ export default function AdminSidebar() { } export function SidebarMobileHeader() { + const { logo } = useLogo(); const sidebarRef = useRef(null); const [showSidebar, setShowSidebar] = useState(false); const [showBgOverlay, setShowBgOverlay] = useState(false); @@ -143,9 +157,14 @@ export function SidebarMobileHeader() { > -

- AnythingLLM -

+
+ Logo +
{/* Header Information */}
-

- AnythingLLM Admin -

+
+ Logo +
+
diff --git a/frontend/src/components/Modals/Settings/index.jsx b/frontend/src/components/Modals/Settings/index.jsx index f644c5e1..0814b1d4 100644 --- a/frontend/src/components/Modals/Settings/index.jsx +++ b/frontend/src/components/Modals/Settings/index.jsx @@ -6,6 +6,7 @@ import { Users, Database, MessageSquare, + Eye, } from "react-feather"; import ExportOrImportData from "./ExportImport"; import PasswordProtection from "./PasswordProtection"; @@ -14,6 +15,7 @@ import MultiUserMode from "./MultiUserMode"; import useUser from "../../../hooks/useUser"; import VectorDBSelection from "./VectorDbs"; import LLMSelection from "./LLMSelection"; +import paths from "../../../utils/paths"; const TABS = { llm: LLMSelection, @@ -130,6 +132,12 @@ function SettingTabs({ selectedTab, changeTab, settings, user }) { icon={} onClick={changeTab} /> + } + onClick={() => window.open(paths.appearance())} + /> )} diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 4a074a34..9b3068dd 100644 --- a/frontend/src/components/Sidebar/index.jsx +++ b/frontend/src/components/Sidebar/index.jsx @@ -26,8 +26,10 @@ import Discord from "../Icons/Discord"; import useUser from "../../hooks/useUser"; import { userFromStorage } from "../../utils/request"; import { AUTH_TOKEN, AUTH_USER } from "../../utils/constants"; +import useLogo from "../../hooks/useLogo"; export default function Sidebar() { + const { logo } = useLogo(); const sidebarRef = useRef(null); const { showing: showingSystemSettingsModal, @@ -50,9 +52,14 @@ export default function Sidebar() {
{/* Header Information */}
-

- AnythingLLM -

+
+ Logo +
-

- AnythingLLM -

+
+ Logo +
{/* Header Information */}
-

- AnythingLLM -

+
+ Logo +
+
+
+ Upload your logo. Recommended size: 800x200. +
+
+
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/System/Appearance.jsx b/frontend/src/pages/System/Appearance.jsx new file mode 100644 index 00000000..f840e414 --- /dev/null +++ b/frontend/src/pages/System/Appearance.jsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect } from "react"; +import AnythingLLMLight from "../../media/logo/anything-llm-light.png"; +import AnythingLLMDark from "../../media/logo/anything-llm-dark.png"; +import System from "../../models/system"; +import usePrefersDarkMode from "../../hooks/usePrefersDarkMode"; +import useLogo from "../../hooks/useLogo"; + +export default function Appearance() { + const { logo: _initLogo } = useLogo(); + const prefersDarkMode = usePrefersDarkMode(); + const [logo, setLogo] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [successMsg, setSuccessMsg] = useState(""); + + useEffect(() => { + async function setInitLogo() { + setLogo(_initLogo || ""); + } + setInitLogo(); + }, [_initLogo]); + + useEffect(() => { + if (!!successMsg) { + setTimeout(() => { + setSuccessMsg(""); + }, 3_500); + } + + if (!!errorMsg) { + setTimeout(() => { + setErrorMsg(""); + }, 3_500); + } + }, [successMsg, errorMsg]); + + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return false; + + const formData = new FormData(); + formData.append("logo", file); + const { success, error } = await System.uploadLogo(formData); + if (!success) { + console.error("Failed to upload logo:", error); + setErrorMsg(error); + setSuccessMsg(""); + return; + } + + const logoURL = await System.fetchLogo(); + setLogo(logoURL); + setSuccessMsg("Image uploaded successfully"); + setErrorMsg(""); + }; + + const handleRemoveLogo = async () => { + const { success, error } = await System.removeCustomLogo(); + if (!success) { + console.error("Failed to remove logo:", error); + setErrorMsg(error); + setSuccessMsg(""); + return; + } + + const logoURL = await System.fetchLogo(); + setLogo(logoURL); + setSuccessMsg("Image successfully removed"); + setErrorMsg(""); + }; + + return ( +
+
+

+ Customize Appearance +

+

+ Customize the logo you see on the sidebar +

+ +
+ Uploaded Logo + (e.target.src = prefersDarkMode + ? AnythingLLMLight + : AnythingLLMDark) + } + /> +
+
+ Upload your logo +
+
+ Recommended size at least 800x200 +
+
+
+ +
+ + +
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {successMsg && ( +
+ {successMsg} +
+ )} +
+
+ ); +} diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index d9748a3b..7e729bb6 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -22,6 +22,9 @@ export default { feedback: () => { return "https://mintplexlabs.typeform.com/to/i0KE3aEW"; }, + appearance: () => { + return "/system/appearance"; + }, workspace: { chat: (slug) => { return `/workspace/${slug}`; @@ -46,5 +49,8 @@ export default { chats: () => { return "/admin/workspace-chats"; }, + appearance: () => { + return "/admin/appearance"; + }, }, }; diff --git a/server/.gitignore b/server/.gitignore index bfff4bb2..5f304ff2 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,5 +1,8 @@ .env.production .env.development +storage/assets/* +!storage/assets/anything-llm-dark.png +!storage/assets/anything-llm-light.png storage/documents/* storage/vector-cache/*.json storage/exports diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index b58dc0c1..d27ccf23 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -8,6 +8,8 @@ const { WorkspaceChats } = require("../models/workspaceChats"); const { getVectorDbClass } = require("../utils/helpers"); const { userFromSession, reqBody } = require("../utils/http"); const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { setupLogoUploads } = require("../utils/files/multer"); +const { handleLogoUploads } = setupLogoUploads(); function adminEndpoints(app) { if (!app) return; diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 01a367c7..9cb0ee92 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -17,12 +17,23 @@ const { userFromSession, multiUserMode, } = require("../utils/http"); -const { setupDataImports } = require("../utils/files/multer"); +const { setupDataImports, setupLogoUploads } = require("../utils/files/multer"); const { v4 } = require("uuid"); const { SystemSettings } = require("../models/systemSettings"); const { User } = require("../models/user"); const { validatedRequest } = require("../utils/middleware/validatedRequest"); const { handleImports } = setupDataImports(); +const { handleLogoUploads } = setupLogoUploads(); +const path = require("path"); +const { + getDefaultFilename, + determineLogoFilepath, + fetchLogo, + validFilename, + renameLogoFile, + removeCustomLogo, + DARK_LOGO_FILENAME, +} = require("../utils/files/logo"); function systemEndpoints(app) { if (!app) return; @@ -358,6 +369,99 @@ function systemEndpoints(app) { response.status(200).json({ success, error }); } ); + + app.get("/system/logo/:mode?", async function (request, response) { + try { + const defaultFilename = getDefaultFilename(request.params.mode); + const logoPath = await determineLogoFilepath(defaultFilename); + const { buffer, size, mime } = fetchLogo(logoPath); + response.writeHead(200, { + "Content-Type": mime || "image/png", + "Content-Disposition": `attachment; filename=${path.basename( + logoPath + )}`, + "Content-Length": size, + }); + response.end(Buffer.from(buffer, "base64")); + return; + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + app.post( + "/system/upload-logo", + [validatedRequest], + handleLogoUploads.single("logo"), + async (request, response) => { + if (!request.file || !request.file.originalname) { + return response.status(400).json({ message: "No logo file provided." }); + } + + if (!validFilename(request.file.originalname)) { + return response.status(400).json({ + message: "Invalid file name. Please choose a different file.", + }); + } + + try { + if ( + response.locals.multiUserMode && + response.locals.user?.role !== "admin" + ) { + return response.sendStatus(401).end(); + } + + const newFilename = await renameLogoFile(request.file.originalname); + const existingLogoFilename = await SystemSettings.currentLogoFilename(); + await removeCustomLogo(existingLogoFilename); + + const { success, error } = await SystemSettings.updateSettings({ + logo_filename: newFilename, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Logo uploaded successfully." + : error || "Failed to update with new logo.", + }); + } catch (error) { + console.error("Error processing the logo upload:", error); + response.status(500).json({ message: "Error uploading the logo." }); + } + } + ); + + app.get( + "/system/remove-logo", + [validatedRequest], + async (request, response) => { + try { + if ( + response.locals.multiUserMode && + response.locals.user?.role !== "admin" + ) { + return response.sendStatus(401).end(); + } + + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + await removeCustomLogo(currentLogoFilename); + const { success, error } = await SystemSettings.updateSettings({ + logo_filename: DARK_LOGO_FILENAME, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Logo removed successfully." + : error || "Failed to update with new logo.", + }); + } catch (error) { + console.error("Error processing the logo removal:", error); + response.status(500).json({ message: "Error removing the logo." }); + } + } + ); } module.exports = { systemEndpoints }; diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index 1df81204..e0d58d45 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -4,6 +4,7 @@ const SystemSettings = { "users_can_delete_workspaces", "limit_user_messages", "message_limit", + "logo_filename", ], privateField: [], tablename: "system_settings", @@ -117,6 +118,10 @@ const SystemSettings = { isMultiUserMode: async function () { return (await this.get(`label = 'multi_user_mode'`))?.value === "true"; }, + currentLogoFilename: async function () { + const result = await this.get(`label = 'logo_filename'`); + return result ? result.value : null; + }, }; module.exports.SystemSettings = SystemSettings; diff --git a/server/package.json b/server/package.json index 24a04b9f..f2714838 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "graphql": "^16.7.1", "jsonwebtoken": "^8.5.1", "langchain": "^0.0.90", + "mime": "^3.0.0", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "openai": "^3.2.1", diff --git a/server/storage/assets/anything-llm-dark.png b/server/storage/assets/anything-llm-dark.png new file mode 100644 index 00000000..a2948438 Binary files /dev/null and b/server/storage/assets/anything-llm-dark.png differ diff --git a/server/storage/assets/anything-llm-light.png b/server/storage/assets/anything-llm-light.png new file mode 100644 index 00000000..341d21b6 Binary files /dev/null and b/server/storage/assets/anything-llm-light.png differ diff --git a/server/utils/files/logo.js b/server/utils/files/logo.js new file mode 100644 index 00000000..cf509d9a --- /dev/null +++ b/server/utils/files/logo.js @@ -0,0 +1,72 @@ +const path = require("path"); +const fs = require("fs"); +const { getType } = require("mime"); +const { v4 } = require("uuid"); +const { SystemSettings } = require("../../models/systemSettings"); +const LIGHT_LOGO_FILENAME = "anything-llm-light.png"; +const DARK_LOGO_FILENAME = "anything-llm-dark.png"; + +function validFilename(newFilename = "") { + return ![DARK_LOGO_FILENAME, LIGHT_LOGO_FILENAME].includes(newFilename); +} + +function getDefaultFilename(mode = "dark") { + return mode === "light" ? DARK_LOGO_FILENAME : LIGHT_LOGO_FILENAME; +} + +async function determineLogoFilepath(defaultFilename = DARK_LOGO_FILENAME) { + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + const basePath = path.join(__dirname, "../../storage/assets"); + const defaultFilepath = path.join(basePath, defaultFilename); + + if (currentLogoFilename && validFilename(currentLogoFilename)) { + customLogoPath = path.join(basePath, currentLogoFilename); + return fs.existsSync(customLogoPath) ? customLogoPath : defaultFilepath; + } + + return defaultFilepath; +} + +function fetchLogo(logoPath) { + const mime = getType(logoPath); + const buffer = fs.readFileSync(logoPath); + return { + buffer, + size: buffer.length, + mime, + }; +} + +async function renameLogoFile(originalFilename = null) { + const extname = path.extname(originalFilename) || ".png"; + const newFilename = `${v4()}${extname}`; + const originalFilepath = path.join( + __dirname, + `../../storage/assets/${originalFilename}` + ); + const outputFilepath = path.join( + __dirname, + `../../storage/assets/${newFilename}` + ); + + fs.renameSync(originalFilepath, outputFilepath); + return newFilename; +} + +async function removeCustomLogo(logoFilename = DARK_LOGO_FILENAME) { + if (!logoFilename || !validFilename(logoFilename)) return false; + const logoPath = path.join(__dirname, `../../storage/assets/${logoFilename}`); + if (fs.existsSync(logoPath)) fs.unlinkSync(logoPath); + return true; +} + +module.exports = { + fetchLogo, + renameLogoFile, + removeCustomLogo, + validFilename, + getDefaultFilename, + determineLogoFilepath, + LIGHT_LOGO_FILENAME, + DARK_LOGO_FILENAME, +}; diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js index 49484dd7..cc12ac9f 100644 --- a/server/utils/files/multer.js +++ b/server/utils/files/multer.js @@ -1,9 +1,11 @@ +const multer = require("multer"); +const path = require("path"); +const fs = require("fs"); + function setupMulter() { - const multer = require("multer"); // Handle File uploads for auto-uploading. const storage = multer.diskStorage({ destination: function (_, _, cb) { - const path = require("path"); const uploadOutput = process.env.NODE_ENV === "development" ? path.resolve(__dirname, `../../../collector/hotdir`) @@ -14,19 +16,14 @@ function setupMulter() { cb(null, file.originalname); }, }); - const upload = multer({ - storage, - }); - return { handleUploads: upload }; + + return { handleUploads: multer({ storage }) }; } function setupDataImports() { - const multer = require("multer"); // Handle File uploads for auto-uploading. const storage = multer.diskStorage({ destination: function (_, _, cb) { - const path = require("path"); - const fs = require("fs"); const uploadOutput = path.resolve(__dirname, `../../storage/imports`); fs.mkdirSync(uploadOutput, { recursive: true }); return cb(null, uploadOutput); @@ -35,13 +32,28 @@ function setupDataImports() { cb(null, file.originalname); }, }); - const upload = multer({ - storage, + + return { handleImports: multer({ storage }) }; +} + +function setupLogoUploads() { + // Handle Logo uploads. + const storage = multer.diskStorage({ + destination: function (_, _, cb) { + const uploadOutput = path.resolve(__dirname, `../../storage/assets`); + fs.mkdirSync(uploadOutput, { recursive: true }); + return cb(null, uploadOutput); + }, + filename: function (_, file, cb) { + cb(null, file.originalname); + }, }); - return { handleImports: upload }; + + return { handleLogoUploads: multer({ storage }) }; } module.exports = { setupMulter, setupDataImports, + setupLogoUploads, }; diff --git a/server/yarn.lock b/server/yarn.lock index c5a6a75c..51300358 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1648,6 +1648,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"