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:
5 files changed
+9
-128
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user