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.
This commit is contained in:
13 files changed
+322
-126
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<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),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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<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