From a4e1c3c60e933aae0c8244fbd0f9c3dc2e48b4c6 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 7 Aug 2026 17:30:27 +0200 Subject: [PATCH] perf(assistant): load the assistant runtime only when it is enabled AssistantProvider was imported statically by the root layout, so assistant-ui, the Lexical composer, the Markdown renderer and Mermaid were downloaded and evaluated on every page. The existing getAvailability gate only skipped rendering, and the disabled branch still imported the board widget, which pulls the whole conversation surface. An instance with the assistant switched off paid the full cost, and so did the login page. Measured on two production builds, /auth/login went from 42 chunks / 3.81MB including an 820KB assistant-ui chunk to 36 chunks / 2.40MB with none, and the board page from 55 / 5.35MB to 56 / 4.56MB with none. Adds getFeatureFlagsAsync as one source of truth for instance-level feature switches, exposed both to the root layout and through a small GET /api/features endpoint. Resolving the flag on the server is what makes this work: next/dynamic never references the heavy chunk when the feature is off, while enabled instances still server-render it, so there is no loading flash and no remount. --- apps/nextjs/src/app/[locale]/layout.tsx | 6 +- .../custom-widgets/_copy-ai-prompt-button.tsx | 2 +- apps/nextjs/src/app/api/features/route.ts | 24 ++++ .../assistant/assistant-context.tsx | 56 ++++++++ .../components/assistant/assistant-gate.tsx | 73 ++++++++++ .../assistant/assistant-provider.tsx | 136 ++---------------- .../assistant-spotlight-placeholder.ts | 28 ++++ .../assistant/assistant-widget-lazy.tsx | 18 +++ .../components/assistant/assistant-widget.tsx | 2 +- .../src/components/user-avatar-menu.tsx | 2 +- packages/api/package.json | 1 + packages/api/src/features.ts | 36 +++++ packages/api/src/test/features.spec.ts | 64 +++++++++ 13 files changed, 322 insertions(+), 126 deletions(-) create mode 100644 apps/nextjs/src/app/api/features/route.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-context.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-gate.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx create mode 100644 packages/api/src/features.ts create mode 100644 packages/api/src/test/features.spec.ts 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 }); + }); +});