diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index a7d383a40..e7db1f0e3 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -14,6 +14,7 @@ import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
+import { getFeatureFlagsAsync } from "@homarr/api/features";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
@@ -26,7 +27,7 @@ import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
-import { AssistantProvider } from "~/components/assistant/assistant-provider";
+import { AssistantGate } from "~/components/assistant/assistant-gate";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
@@ -86,6 +87,7 @@ export default async function Layout(props: {
const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
+ const features = await getFeatureFlagsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
@@ -124,7 +126,7 @@ export default async function Layout(props: {
(innerProps) => ,
(innerProps) => ,
(innerProps) => ,
- (innerProps) => ,
+ (innerProps) => ,
]);
const { locale } = await props.params;
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx
index c169c311a..63c0f4e2f 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
-import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-provider";
+import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
diff --git a/apps/nextjs/src/app/api/features/route.ts b/apps/nextjs/src/app/api/features/route.ts
new file mode 100644
index 000000000..06d33f73b
--- /dev/null
+++ b/apps/nextjs/src/app/api/features/route.ts
@@ -0,0 +1,24 @@
+import { getFeatureFlagsAsync } from "@homarr/api/features";
+import { auth } from "@homarr/auth/next";
+import { db } from "@homarr/db";
+
+/**
+ * Reports which optional features are switched on for this instance.
+ *
+ * Kept deliberately small: booleans only, no configuration detail, so it stays cheap to call and
+ * safe to hand to the client. It is the single place to look when deciding whether a feature's
+ * client code needs to be loaded at all.
+ *
+ * Requires a session because whether an instance runs an AI assistant is not something an
+ * anonymous visitor needs to know.
+ */
+export async function GET() {
+ const session = await auth();
+ if (!session) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const features = await getFeatureFlagsAsync(db);
+
+ return Response.json({ features }, { headers: { "Cache-Control": "private, max-age=60" } });
+}
diff --git a/apps/nextjs/src/components/assistant/assistant-context.tsx b/apps/nextjs/src/components/assistant/assistant-context.tsx
new file mode 100644
index 000000000..6c1306210
--- /dev/null
+++ b/apps/nextjs/src/components/assistant/assistant-context.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
+
+/**
+ * Context and hooks live in their own module so that consumers such as the user menu can read the
+ * assistant state without pulling in `assistant-provider`, which statically imports the
+ * assistant-ui runtime, the Lexical composer, the Markdown renderer and Mermaid. Importing those
+ * from a component that renders on every page would ship them to every page.
+ */
+export interface AssistantContextValue {
+ enabled: boolean;
+ unavailableDescription: string | null;
+ opened: boolean;
+ isRunning: boolean;
+ isRefreshing: boolean;
+ unreadCount: number;
+ open: () => void;
+ close: () => void;
+ toggle: () => void;
+ sendPrompt: (prompt: string) => boolean;
+ refreshCurrentView: () => Promise;
+}
+
+export const AssistantContext = createContext(null);
+
+export interface AssistantPreferencesContextValue {
+ defaultModelId: string | null;
+ modelId: string | null;
+ models: AssistantRuntimeModelOption[];
+ reasoning: AssistantReasoningMode;
+ isLoading: boolean;
+ setModelId: (modelId: string) => void;
+ setReasoning: (reasoning: AssistantReasoningMode) => void;
+ getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
+}
+
+export const AssistantPreferencesContext = createContext(null);
+
+export const useHomarrAssistant = () => {
+ const value = useContext(AssistantContext);
+ if (!value) {
+ throw new Error("useHomarrAssistant must be used within AssistantProvider");
+ }
+ return value;
+};
+
+export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
+
+export const useAssistantPreferences = () => {
+ const value = useContext(AssistantPreferencesContext);
+ if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
+ return value;
+};
diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx
new file mode 100644
index 000000000..dbcc42841
--- /dev/null
+++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { useMemo } from "react";
+import dynamic from "next/dynamic";
+
+import { useSession } from "@homarr/auth/client";
+import { useScopedI18n } from "@homarr/translation/client";
+import { AssistantWidgetRendererProvider } from "@homarr/widgets";
+
+import { AssistantContext } from "./assistant-context";
+import { AssistantBoardWidgetLazy } from "./assistant-widget-lazy";
+import { useRegisterAssistantSpotlightPlaceholder } from "./assistant-spotlight-placeholder";
+
+/**
+ * The enabled runtime is the expensive half of the assistant (assistant-ui, Lexical, Markdown,
+ * Mermaid). Loading it through `next/dynamic` puts it in its own chunk that is only requested when
+ * this component actually renders. Because `enabled` is resolved on the server and handed down as a
+ * prop, an instance with the assistant switched off never renders it, so the chunk is never part of
+ * the page payload. SSR stays on so enabled instances render children in the initial HTML without a
+ * loading flash or a remount once the chunk arrives.
+ */
+const EnabledAssistantRoot = dynamic(() =>
+ import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
+);
+
+const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
+ useRegisterAssistantSpotlightPlaceholder(description);
+
+ const value = useMemo(
+ () => ({
+ enabled: false,
+ unavailableDescription: description,
+ opened: false,
+ isRunning: false,
+ isRefreshing: false,
+ unreadCount: 0,
+ open: () => undefined,
+ close: () => undefined,
+ toggle: () => undefined,
+ sendPrompt: () => false,
+ refreshCurrentView: () => Promise.resolve(),
+ }),
+ [description],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+interface AssistantGateProps extends PropsWithChildren {
+ /** Resolved on the server from the instance configuration, not per user. */
+ enabled: boolean;
+}
+
+export const AssistantGate = ({ enabled, children }: AssistantGateProps) => {
+ const t = useScopedI18n("common.assistant");
+ const session = useSession();
+
+ if (!enabled) {
+ return {children};
+ }
+
+ // The instance has the assistant on, but it is still per-user gated behind a session.
+ if (session.status !== "authenticated") {
+ return {children};
+ }
+
+ return {children};
+};
diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx
index db9825f8f..8bdd1a5ef 100644
--- a/apps/nextjs/src/components/assistant/assistant-provider.tsx
+++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
-import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type {
AttachmentAdapter,
@@ -17,7 +17,6 @@ import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
import { clientApi, fetchApi } from "@homarr/api/client";
-import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
@@ -30,6 +29,7 @@ import {
} from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
+import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
@@ -53,33 +53,6 @@ import { initialAssistantNotificationState, updateAssistantNotificationState } f
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
import { AssistantBoardWidget } from "./assistant-widget";
-interface AssistantContextValue {
- enabled: boolean;
- unavailableDescription: string | null;
- opened: boolean;
- isRunning: boolean;
- isRefreshing: boolean;
- unreadCount: number;
- open: () => void;
- close: () => void;
- toggle: () => void;
- sendPrompt: (prompt: string) => boolean;
- refreshCurrentView: () => Promise;
-}
-
-const AssistantContext = createContext(null);
-interface AssistantPreferencesContextValue {
- defaultModelId: string | null;
- modelId: string | null;
- models: AssistantRuntimeModelOption[];
- reasoning: AssistantReasoningMode;
- isLoading: boolean;
- setModelId: (modelId: string) => void;
- setReasoning: (reasoning: AssistantReasoningMode) => void;
- getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
-}
-
-const AssistantPreferencesContext = createContext(null);
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
const assistantDocumentAttachmentTypes = [
@@ -158,22 +131,6 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
};
};
-export const useHomarrAssistant = () => {
- const value = useContext(AssistantContext);
- if (!value) {
- throw new Error("useHomarrAssistant must be used within AssistantProvider");
- }
- return value;
-};
-
-export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
-
-export const useAssistantPreferences = () => {
- const value = useContext(AssistantPreferencesContext);
- if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
- return value;
-};
-
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
staleTime: 10 * 60_000,
@@ -766,79 +723,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
);
};
-interface DisabledAssistantProviderProps extends PropsWithChildren {
- description: string;
-}
-
-const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => {
- const t = useScopedI18n("common.assistant");
- const spotlightItem = useMemo(
- () => ({
- id: "homarr-assistant",
- name: t("spotlight"),
- icon: "/logo/logo.png",
- description,
- unavailable: true,
- alwaysVisible: true,
- interaction: (_query: string) => ({ type: "none" as const }),
- }),
- [description, t],
- );
- useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
-
- const value = useMemo(
- () => ({
- enabled: false,
- unavailableDescription: description,
- opened: false,
- isRunning: false,
- isRefreshing: false,
- unreadCount: 0,
- open: () => undefined,
- close: () => undefined,
- toggle: () => undefined,
- sendPrompt: () => false,
- refreshCurrentView: () => Promise.resolve(),
- }),
- [description],
- );
-
- return (
-
- {children}
-
- );
-};
-
-export const AssistantProvider = ({ children }: PropsWithChildren) => {
- const t = useScopedI18n("common.assistant");
- const session = useSession();
- const { data, isLoading, isError } = clientApi.assistant.getAvailability.useQuery(undefined, {
- enabled: session.status === "authenticated",
- staleTime: 60_000,
- });
- const enabled = Boolean(data?.enabled && session.status === "authenticated");
-
- if (enabled) {
- return (
-
-
-
- {children}
-
-
-
- );
- }
-
- const unavailableDescription =
- session.status !== "authenticated"
- ? t("unavailable.signIn")
- : isLoading
- ? t("unavailable.checking")
- : isError
- ? t("unavailable.error")
- : t("unavailable.notConfigured");
-
- return {children};
-};
+/**
+ * The enabled half of the assistant. Only rendered (and only downloaded) when the instance has the
+ * assistant configured and the visitor is signed in - see `assistant-gate`.
+ */
+export const EnabledAssistantRoot = ({ children }: PropsWithChildren) => (
+
+
+
+ {children}
+
+
+
+);
diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts
new file mode 100644
index 000000000..f92beec6c
--- /dev/null
+++ b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts
@@ -0,0 +1,28 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
+import { useScopedI18n } from "@homarr/translation/client";
+
+/**
+ * Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the
+ * assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap.
+ */
+export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
+ const t = useScopedI18n("common.assistant");
+ const spotlightItem = useMemo(
+ () => ({
+ id: "homarr-assistant",
+ name: t("spotlight"),
+ icon: "/logo/logo.png",
+ description,
+ unavailable: true,
+ alwaysVisible: true,
+ interaction: (_query: string) => ({ type: "none" as const }),
+ }),
+ [description, t],
+ );
+
+ useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
+};
diff --git a/apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx b/apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx
new file mode 100644
index 000000000..059453826
--- /dev/null
+++ b/apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+import type { WidgetComponentProps } from "@homarr/widgets";
+
+/**
+ * Boards register a renderer for the assistant widget even when no board uses one. Loading the
+ * renderer lazily keeps the conversation surface out of the board bundle until a board actually
+ * places an assistant widget.
+ */
+const LazyAssistantBoardWidget = dynamic(() =>
+ import("./assistant-widget").then((module) => ({ default: module.AssistantBoardWidget })),
+);
+
+export const AssistantBoardWidgetLazy = (props: WidgetComponentProps<"assistant">) => (
+
+);
diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx
index 9244444d5..0326f7fac 100644
--- a/apps/nextjs/src/components/assistant/assistant-widget.tsx
+++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx
@@ -12,7 +12,7 @@ import type { WidgetComponentProps } from "@homarr/widgets";
import classes from "./assistant-panel.module.css";
import { AssistantConversationSurface } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
-import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider";
+import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
const assistant = useHomarrAssistant();
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index c2edd6627..ad8a4b449 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
-import { useOptionalHomarrAssistant } from "./assistant/assistant-provider";
+import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
diff --git a/packages/api/package.json b/packages/api/package.json
index 94fb1a00d..14c833eeb 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -15,6 +15,7 @@
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./assistant": "./src/assistant.ts",
+ "./features": "./src/features.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts"
},
diff --git a/packages/api/src/features.ts b/packages/api/src/features.ts
new file mode 100644
index 000000000..9c100b3b3
--- /dev/null
+++ b/packages/api/src/features.ts
@@ -0,0 +1,36 @@
+import { eq } from "@homarr/db";
+import type { Database } from "@homarr/db";
+import { assistantConfigurations } from "@homarr/db/schema";
+import { assistantProviderRequiresApiKey } from "@homarr/definitions";
+
+/**
+ * Instance-level feature switches. These say whether a feature is turned on for the whole Homarr
+ * instance, never whether the current user may use it - permission checks stay on the individual
+ * procedures.
+ *
+ * The point of collecting them in one place is that the server can decide, before rendering,
+ * whether a feature's client bundle needs to exist on the page at all. A feature that is off must
+ * not cost the browser anything.
+ */
+export interface FeatureFlags {
+ assistant: boolean;
+}
+
+export const getFeatureFlagsAsync = async (db: Database): Promise => {
+ const assistantConfiguration = await db.query.assistantConfigurations.findFirst({
+ where: eq(assistantConfigurations.id, "default"),
+ columns: { enabled: true, provider: true, modelId: true, encryptedApiKey: true },
+ });
+
+ const requiresApiKey = assistantConfiguration
+ ? assistantProviderRequiresApiKey(assistantConfiguration.provider)
+ : false;
+
+ return {
+ assistant: Boolean(
+ assistantConfiguration?.enabled &&
+ assistantConfiguration.modelId &&
+ (!requiresApiKey || assistantConfiguration.encryptedApiKey),
+ ),
+ };
+};
diff --git a/packages/api/src/test/features.spec.ts b/packages/api/src/test/features.spec.ts
new file mode 100644
index 000000000..df0d4f11f
--- /dev/null
+++ b/packages/api/src/test/features.spec.ts
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "vitest";
+
+import type { Database } from "@homarr/db";
+import { assistantConfigurations } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { getFeatureFlagsAsync } from "../features";
+
+const seedConfigurationAsync = async (db: Database, values: Partial) => {
+ await db.insert(assistantConfigurations).values({ id: "default", ...values });
+};
+
+describe("getFeatureFlagsAsync", () => {
+ test("reports the assistant as off when it was never configured", async () => {
+ const db = createDb() as unknown as Database;
+
+ await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
+ });
+
+ test("reports the assistant as off while it is configured but not switched on", async () => {
+ const db = createDb() as unknown as Database;
+ await seedConfigurationAsync(db, {
+ enabled: false,
+ provider: "openrouter",
+ modelId: "some/model",
+ encryptedApiKey: "a.b",
+ });
+
+ await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
+ });
+
+ test("reports the assistant as off when a required api key is missing", async () => {
+ const db = createDb() as unknown as Database;
+ await seedConfigurationAsync(db, { enabled: true, provider: "openrouter", modelId: "some/model" });
+
+ await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
+ });
+
+ test("reports the assistant as off when no model was picked", async () => {
+ const db = createDb() as unknown as Database;
+ await seedConfigurationAsync(db, { enabled: true, provider: "openrouter", encryptedApiKey: "a.b" });
+
+ await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
+ });
+
+ test("reports the assistant as on once it is fully configured", async () => {
+ const db = createDb() as unknown as Database;
+ await seedConfigurationAsync(db, {
+ enabled: true,
+ provider: "openrouter",
+ modelId: "some/model",
+ encryptedApiKey: "a.b",
+ });
+
+ await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: true });
+ });
+
+ test("does not require an api key for providers that run without one", async () => {
+ const db = createDb() as unknown as Database;
+ await seedConfigurationAsync(db, { enabled: true, provider: "ollama", modelId: "llama3" });
+
+ await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: true });
+ });
+});