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 }); + }); +});