diff --git a/frontend/index.html b/frontend/index.html index 5a7f4d6db21c7194e334243fadfb42241524e331..387fb00d45527d90846ea8e111d48d0d7cfe8a03 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,38 +1,38 @@ <!DOCTYPE html> <html lang="en"> -<head> - <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/favicon.png" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>AnythingLLM | Your personal LLM trained on anything</title> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>AnythingLLM | Your personal LLM trained on anything</title> - <meta name="title" content="AnythingLLM | Your personal LLM trained on anything"> - <meta name="description" content="AnythingLLM | Your personal LLM trained on anything"> + <meta name="title" content="AnythingLLM | Your personal LLM trained on anything"> + <meta name="description" content="AnythingLLM | Your personal LLM trained on anything"> - <!-- Facebook --> - <meta property="og:type" content="website"> - <meta property="og:url" content="https://useanything.com"> - <meta property="og:title" content="AnythingLLM | Your personal LLM trained on anything"> - <meta property="og:description" content="AnythingLLM | Your personal LLM trained on anything"> - <meta property="og:image" - content="https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png"> + <!-- Facebook --> + <meta property="og:type" content="website"> + <meta property="og:url" content="https://useanything.com"> + <meta property="og:title" content="AnythingLLM | Your personal LLM trained on anything"> + <meta property="og:description" content="AnythingLLM | Your personal LLM trained on anything"> + <meta property="og:image" + content="https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png"> - <!-- Twitter --> - <meta property="twitter:card" content="summary_large_image"> - <meta property="twitter:url" content="https://useanything.com"> - <meta property="twitter:title" content="AnythingLLM | Your personal LLM trained on anything"> - <meta property="twitter:description" content="AnythingLLM | Your personal LLM trained on anything"> - <meta property="twitter:image" - content="https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png"> + <!-- Twitter --> + <meta property="twitter:card" content="summary_large_image"> + <meta property="twitter:url" content="https://useanything.com"> + <meta property="twitter:title" content="AnythingLLM | Your personal LLM trained on anything"> + <meta property="twitter:description" content="AnythingLLM | Your personal LLM trained on anything"> + <meta property="twitter:image" + content="https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png"> - <link rel="icon" href="/favicon.png" /> - <link rel="apple-touch-icon" href="/favicon.png" /> -</head> + <link rel="icon" href="/favicon.png" /> + <link rel="apple-touch-icon" href="/favicon.png" /> + </head> -<body> - <div id="root" class="h-screen"></div> - <script type="module" src="/src/main.jsx"></script> -</body> + <body> + <div id="root" class="h-screen"></div> + <script type="module" src="/src/main.jsx"></script> + </body> </html> \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 3aa23b20cbd6c425251b63adb69d8fcfe56a74dd..a5f754a3c6f36190179705bf16853cbbc9c0061b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "vite --open", "dev": "NODE_ENV=development vite --debug --host=0.0.0.0", - "build": "vite build", + "build": "vite build && node scripts/postbuild.js", "lint": "yarn prettier --ignore-path ../.prettierignore --write ./src", "preview": "vite preview" }, diff --git a/frontend/scripts/postbuild.js b/frontend/scripts/postbuild.js new file mode 100644 index 0000000000000000000000000000000000000000..bcba17bae273a3a36beb87fe5f402b850be2db40 --- /dev/null +++ b/frontend/scripts/postbuild.js @@ -0,0 +1,8 @@ +import { renameSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +console.log(`Running frontend post build script...`) +renameSync(path.resolve(__dirname, '../dist/index.html'), path.resolve(__dirname, '../dist/_index.html')); +console.log(`index.html renamed to _index.html so SSR of the index page can be assumed.`); \ No newline at end of file diff --git a/frontend/src/pages/GeneralSettings/Appearance/CustomSiteSettings/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/CustomSiteSettings/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c68d53681fdb07a450b53d1b8292f73a34ea5d0f --- /dev/null +++ b/frontend/src/pages/GeneralSettings/Appearance/CustomSiteSettings/index.jsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import Admin from "@/models/admin"; +import showToast from "@/utils/toast"; + +export default function CustomSiteSettings() { + const [hasChanges, setHasChanges] = useState(false); + const [settings, setSettings] = useState({ + title: null, + faviconUrl: null, + }); + + useEffect(() => { + Admin.systemPreferences().then(({ settings }) => { + setSettings({ + title: settings?.meta_page_title, + faviconUrl: settings?.meta_page_favicon, + }); + }); + }, []); + + async function handleSiteSettingUpdate(e) { + e.preventDefault(); + await Admin.updateSystemPreferences({ + meta_page_title: settings.title ?? null, + meta_page_favicon: settings.faviconUrl ?? null, + }); + showToast( + "Site preferences updated! They will reflect on page reload.", + "success", + { clear: true } + ); + setHasChanges(false); + return; + } + + return ( + <form + className="mb-6" + onChange={() => setHasChanges(true)} + onSubmit={handleSiteSettingUpdate} + > + <div className="flex flex-col border-t border-white/30 pt-4 gap-y-2"> + <div className="flex flex-col gap-y-1"> + <h2 className="text-base leading-6 font-bold text-white"> + Custom Site Settings + </h2> + <p className="text-xs leading-[18px] font-base text-white/60"> + Change the content of the browser tab for customization and + branding. + </p> + </div> + + <div className="w-fit"> + <div className="flex flex-col gap-y-1"> + <h2 className="text-sm leading-6 text-white">Tab Title</h2> + <p className="text-xs leading-[18px] font-base text-white/60"> + Set a custom tab title when the app is open in a browser. + </p> + </div> + <div className="flex items-center gap-x-4"> + <input + name="meta_page_title" + type="text" + className="border-none bg-zinc-900 mt-3 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 max-w-[400px] placeholder:text-white/20" + placeholder="AnythingLLM | Your personal LLM trained on anything" + autoComplete="off" + onChange={(e) => { + setSettings((prev) => { + return { ...prev, title: e.target.value }; + }); + }} + value={ + settings.title ?? + "AnythingLLM | Your personal LLM trained on anything" + } + /> + </div> + </div> + + <div className="w-fit"> + <div className="flex flex-col gap-y-1"> + <h2 className="text-sm leading-6 text-white">Tab Favicon</h2> + <p className="text-xs leading-[18px] font-base text-white/60"> + Define a url to an image to use for your favicon + </p> + </div> + <div className="flex items-center gap-x-2"> + <img + src={settings.faviconUrl ?? "/favicon.png"} + onError={(e) => (e.target.src = "/favicon.png")} + className="h-10 w-10 rounded-lg mt-2.5" + /> + <input + name="meta_page_favicon" + type="url" + className="border-none bg-zinc-900 mt-3 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 max-w-[400px] placeholder:text-white/20" + placeholder="url to your image" + onChange={(e) => { + setSettings((prev) => { + return { ...prev, faviconUrl: e.target.value }; + }); + }} + autoComplete="off" + value={settings.faviconUrl ?? ""} + /> + </div> + </div> + + {hasChanges && ( + <button + type="submit" + className="border-none transition-all mt-6 w-fit duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800" + > + Save + </button> + )} + </div> + </form> + ); +} diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx index 5894a642c12e9f41990d495cab92d7fa58fe0789..ec3ff2672870c7101d4ab75705803c4fad10fc79 100644 --- a/frontend/src/pages/GeneralSettings/Appearance/index.jsx +++ b/frontend/src/pages/GeneralSettings/Appearance/index.jsx @@ -7,6 +7,7 @@ import CustomMessages from "./CustomMessages"; import { useTranslation } from "react-i18next"; import CustomAppName from "./CustomAppName"; import LanguagePreference from "./LanguagePreference"; +import CustomSiteSettings from "./CustomSiteSettings"; export default function Appearance() { const { t } = useTranslation(); @@ -34,6 +35,7 @@ export default function Appearance() { <CustomMessages /> <FooterCustomization /> <SupportEmail /> + <CustomSiteSettings /> </div> </div> </div> diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ff96bdcd20764399bd48e64badb65b3bee9f2545..b67e9ef7cea9e4cc6238aaafb38a93ee5e8cdf89 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -49,6 +49,15 @@ export default defineConfig({ }, build: { rollupOptions: { + output: { + // These settings ensure the primary JS and CSS file references are always index.{js,css} + // so we can SSR the index.html as text response from server/index.js without breaking references each build. + entryFileNames: 'index.js', + assetFileNames: (assetInfo) => { + if (assetInfo.name === 'index.css') return `index.css`; + return assetInfo.name; + }, + }, external: [ // Reduces transformation time by 50% and we don't even use this variant, so we can ignore. /@phosphor-icons\/react\/dist\/ssr/ diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index d7cab1f584604037b8d2d9091fbcb7113fbf609c..457d7567b95da0fe1536bd7e09deec7bbac5a9af 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -356,6 +356,14 @@ function adminEndpoints(app) { (await SystemSettings.get({ label: "custom_app_name" }))?.value || null, feature_flags: (await SystemSettings.getFeatureFlags()) || {}, + meta_page_title: await SystemSettings.getValueOrFallback( + { label: "meta_page_title" }, + null + ), + meta_page_favicon: await SystemSettings.getValueOrFallback( + { label: "meta_page_favicon" }, + null + ), }; response.status(200).json({ settings }); } catch (e) { diff --git a/server/index.js b/server/index.js index 141fe665c8d0cfafa834f2260be439f9e9a0889e..c2f584443cc5de50b158c83b86e3e6828861c38b 100644 --- a/server/index.js +++ b/server/index.js @@ -63,6 +63,9 @@ developerEndpoints(app, apiRouter); embeddedEndpoints(apiRouter); if (process.env.NODE_ENV !== "development") { + const { MetaGenerator } = require("./utils/boot/MetaGenerator"); + const IndexPage = new MetaGenerator(); + app.use( express.static(path.resolve(__dirname, "public"), { extensions: ["js"], @@ -75,7 +78,8 @@ if (process.env.NODE_ENV !== "development") { ); app.use("/", function (_, response) { - response.sendFile(path.join(__dirname, "public", "index.html")); + IndexPage.generate(response); + return; }); app.get("/robots.txt", function (_, response) { diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index ea1dd01e43489ffdc08b4907a76583e5b17623a9..70ed526e781b53d11b5ad1a3a63b2d4fe0105514 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -6,6 +6,7 @@ const { default: slugify } = require("slugify"); const { isValidUrl, safeJsonParse } = require("../utils/http"); const prisma = require("../utils/prisma"); const { v4 } = require("uuid"); +const { MetaGenerator } = require("../utils/boot/MetaGenerator"); function isNullOrNaN(value) { if (value === null) return true; @@ -21,6 +22,7 @@ const SystemSettings = { "telemetry_id", "footer_data", "support_email", + "text_splitter_chunk_size", "text_splitter_chunk_overlap", "agent_search_provider", @@ -28,6 +30,10 @@ const SystemSettings = { "agent_sql_connections", "custom_app_name", + // Meta page customization + "meta_page_title", + "meta_page_favicon", + // beta feature flags "experimental_live_file_sync", ], @@ -122,6 +128,27 @@ const SystemSettings = { if (!["enabled", "disabled"].includes(update)) return "disabled"; return String(update); }, + meta_page_title: (newTitle) => { + try { + if (typeof newTitle !== "string" || !newTitle) return null; + return String(newTitle); + } catch { + return null; + } finally { + new MetaGenerator().clearConfig(); + } + }, + meta_page_favicon: (faviconUrl) => { + if (!faviconUrl) return null; + try { + const url = new URL(faviconUrl); + return url.toString(); + } catch { + return null; + } finally { + new MetaGenerator().clearConfig(); + } + }, }, currentSettings: async function () { const { hasVectorCachedFiles } = require("../utils/files"); diff --git a/server/utils/boot/MetaGenerator.js b/server/utils/boot/MetaGenerator.js new file mode 100644 index 0000000000000000000000000000000000000000..ebf7eab504d33ccac0e45ec16b24479d4ea7bffe --- /dev/null +++ b/server/utils/boot/MetaGenerator.js @@ -0,0 +1,233 @@ +/** + * @typedef MetaTagDefinition + * @property {('link'|'meta')} tag - the type of meta tag element + * @property {{string:string}|null} props - the inner key/values of a meta tag + * @property {string|null} content - Text content to be injected between tags. If null self-closing. + */ + +/** + * This class serves the default index.html page that is not present when built in production. + * and therefore this class should not be called when in development mode since it is unused. + * All this class does is basically emulate SSR for the meta-tag generation of the root index page. + * Since we are an SPA, we can just render the primary page and the known entrypoints for the index.{js,css} + * we can always start at the right place and dynamically load in lazy-loaded as we typically normally would + * and we dont have any of the overhead that would normally come with having the rewrite the whole app in next or something. + * Lastly, this class is singleton, so once instantiate the same refernce is shared for as long as the server is alive. + * the main function is `.generate()` which will return the index HTML. These settings are stored in the #customConfig + * static property and will not be reloaded until the page is loaded AND #customConfig is explicity null. So anytime a setting + * for meta-props is updated you should get this singleton class and call `.clearConfig` so the next page load will show the new props. + */ +class MetaGenerator { + name = "MetaGenerator"; + + /** @type {MetaGenerator|null} */ + static _instance = null; + + /** @type {MetaTagDefinition[]|null} */ + #customConfig = null; + + constructor() { + if (MetaGenerator._instance) return MetaGenerator._instance; + MetaGenerator._instance = this; + } + + #log(text, ...args) { + console.log(`\x1b[36m[${this.name}]\x1b[0m ${text}`, ...args); + } + + #defaultMeta() { + return [ + { + tag: "link", + props: { type: "image/svg+xml", href: "/favicon.png" }, + content: null, + }, + { + tag: "title", + props: null, + content: "AnythingLLM | Your personal LLM trained on anything", + }, + + { + tag: "meta", + props: { + name: "title", + content: "AnythingLLM | Your personal LLM trained on anything", + }, + }, + { + tag: "meta", + props: { + description: "title", + content: "AnythingLLM | Your personal LLM trained on anything", + }, + }, + + // <!-- Facebook --> + { tag: "meta", props: { property: "og:type", content: "website" } }, + { + tag: "meta", + props: { property: "og:url", content: "https://useanything.com" }, + }, + { + tag: "meta", + props: { + property: "og:title", + content: "AnythingLLM | Your personal LLM trained on anything", + }, + }, + { + tag: "meta", + props: { + property: "og:description", + content: "AnythingLLM | Your personal LLM trained on anything", + }, + }, + { + tag: "meta", + props: { + property: "og:image", + content: + "https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png", + }, + }, + + // <!-- Twitter --> + { + tag: "meta", + props: { property: "twitter:card", content: "summary_large_image" }, + }, + { + tag: "meta", + props: { property: "twitter:url", content: "https://useanything.com" }, + }, + { + tag: "meta", + props: { + property: "twitter:title", + content: "AnythingLLM | Your personal LLM trained on anything", + }, + }, + { + tag: "meta", + props: { + property: "twitter:description", + content: "AnythingLLM | Your personal LLM trained on anything", + }, + }, + { + tag: "meta", + props: { + property: "twitter:image", + content: + "https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png", + }, + }, + + { tag: "link", props: { rel: "icon", href: "/favicon.png" } }, + { tag: "link", props: { rel: "apple-touch-icon", href: "/favicon.png" } }, + ]; + } + + /** + * Assembles Meta tags as one large string + * @param {MetaTagDefinition[]} tagArray + * @returns {string} + */ + #assembleMeta() { + const output = []; + for (const tag of this.#customConfig) { + let htmlString; + htmlString = `<${tag.tag} `; + + if (tag.props !== null) { + for (const [key, value] of Object.entries(tag.props)) + htmlString += `${key}="${value}" `; + } + + if (tag.content) { + htmlString += `>${tag.content}</${tag.tag}>`; + } else { + htmlString += `>`; + } + output.push(htmlString); + } + return output.join("\n"); + } + + #validUrl(faviconUrl = null) { + if (faviconUrl === null) return "/favicon.png"; + try { + const url = new URL(faviconUrl); + return url.toString(); + } catch { + return "/favicon.png"; + } + } + + async #fetchConfg() { + this.#log(`fetching custome meta tag settings...`); + const { SystemSettings } = require("../../models/systemSettings"); + const customTitle = await SystemSettings.getValueOrFallback( + { label: "meta_page_title" }, + null + ); + const faviconURL = await SystemSettings.getValueOrFallback( + { label: "meta_page_favicon" }, + null + ); + + // If nothing defined - assume defaults. + if (customTitle === null && faviconURL === null) { + this.#customConfig = this.#defaultMeta(); + } else { + this.#customConfig = [ + { + tag: "link", + props: { rel: "icon", href: this.#validUrl(faviconURL) }, + }, + { + tag: "title", + props: null, + content: + customTitle ?? + "AnythingLLM | Your personal LLM trained on anything", + }, + ]; + } + + return this.#customConfig; + } + + /** + * Clears the current config so it can be refetched on the server for next render. + */ + clearConfig() { + this.#customConfig = null; + } + + /** + * + * @param {import('express').Response} response + * @param {number} code + */ + async generate(response, code = 200) { + if (this.#customConfig === null) await this.#fetchConfg(); + response.status(code).send(` + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + ${this.#assembleMeta()} + <script type="module" crossorigin src="/index.js"></script> + <link rel="stylesheet" href="/index.css"> + </head> + <body> + <div id="root" class="h-screen"></div> + </body> + </html>`); + } +} + +module.exports.MetaGenerator = MetaGenerator;