diff --git a/frontend/index.html b/frontend/index.html index 5a7f4d6d..387fb00d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,38 +1,38 @@ - - - - - AnythingLLM | Your personal LLM trained on anything + + + + + AnythingLLM | Your personal LLM trained on anything - - + + - - - - - - + + + + + + - - - - - - + + + + + + - - - + + + - -
- - + +
+ + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 3aa23b20..a5f754a3 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 00000000..bcba17ba --- /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 00000000..c68d5368 --- /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 ( +
setHasChanges(true)} + onSubmit={handleSiteSettingUpdate} + > +
+
+

+ Custom Site Settings +

+

+ Change the content of the browser tab for customization and + branding. +

+
+ +
+
+

Tab Title

+

+ Set a custom tab title when the app is open in a browser. +

+
+
+ { + setSettings((prev) => { + return { ...prev, title: e.target.value }; + }); + }} + value={ + settings.title ?? + "AnythingLLM | Your personal LLM trained on anything" + } + /> +
+
+ +
+
+

Tab Favicon

+

+ Define a url to an image to use for your favicon +

+
+
+ (e.target.src = "/favicon.png")} + className="h-10 w-10 rounded-lg mt-2.5" + /> + { + setSettings((prev) => { + return { ...prev, faviconUrl: e.target.value }; + }); + }} + autoComplete="off" + value={settings.faviconUrl ?? ""} + /> +
+
+ + {hasChanges && ( + + )} +
+
+ ); +} diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx index 5894a642..ec3ff267 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() { + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ff96bdcd..b67e9ef7 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 d7cab1f5..457d7567 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 141fe665..c2f58444 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 ea1dd01e..70ed526e 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 00000000..ebf7eab5 --- /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", + }, + }, + + // + { 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", + }, + }, + + // + { + 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}`; + } 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(` + + + + + + ${this.#assembleMeta()} + + + + +
+ + `); + } +} + +module.exports.MetaGenerator = MetaGenerator;