refactor(assistant): resolve availability through the existing endpoint

Drops the feature-flags module, the /api/features endpoint and its test in
favour of calling api.assistant.getAvailability() from the root layout, the
same way the layout already calls api.user.getById().

The gate only needs one server-resolved boolean, and getAvailability
already computes exactly that. The separate helper duplicated the enabled /
modelId / api-key-required rule, so the two copies could drift.

A general feature-flag surface is still worth having, but it belongs at
boot time across every optional feature rather than as a per-request
database read introduced alongside the assistant.
This commit is contained in:
Thomas Camlong committed 2026-08-07 17:59:51 +02:00
1 parent f25be7cf1c
commit 7276bdad21
5 files changed
+9 -128

No files matched your search

+9 -3
View File
@@ -14,7 +14,6 @@ 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";
@@ -87,7 +86,14 @@ 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);
// Resolved on the server so the assistant runtime chunk is never referenced by a page when the
// feature is off. Reuses the same availability check the assistant management page invalidates.
const assistantEnabled = session
? await api.assistant
.getAvailability()
.then((availability) => availability.enabled)
.catch(() => false)
: false;
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
@@ -126,7 +132,7 @@ export default async function Layout(props: {
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantGate enabled={features.assistant} {...innerProps} />,
(innerProps) => <AssistantGate enabled={assistantEnabled} {...innerProps} />,
]);
const { locale } = await props.params;
-24
View File
@@ -1,24 +0,0 @@
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" } });
}
-1
View File
@@ -15,7 +15,6 @@
"./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"
},
-36
View File
@@ -1,36 +0,0 @@
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<FeatureFlags> => {
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),
),
};
};
-64
View File
@@ -1,64 +0,0 @@
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<typeof assistantConfigurations.$inferInsert>) => {
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 });
});
});