feat: mock Homarr Assistant in demo mode and fix board page crash (#6524)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:56:41 +02:00
1 parent 5e6ebef961
commit c1aaa1a955
6 files changed
+127 -43

No files matched your search

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { cache } from "react";
import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
@@ -43,8 +44,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
page: async ({ params }: { params: Promise<TParams> }) => {
const resolvedParams = await params;
const queryClient = getQueryClient();
const session = await auth();
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const board = await getInitialBoard(resolvedParams).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
redirect("/auth/login");
}
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error;
});
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -96,8 +113,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
// Ignore not found and bad-request errors and return empty metadata
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
return {};
}
@@ -462,7 +462,7 @@ export const AssistantConfiguration = () => {
</Badge>
}
>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Stack gap="md">
<Select
label={t("provider.title")}
description={t(`provider.options.${provider}.description`)}
@@ -503,14 +503,14 @@ export const AssistantConfiguration = () => {
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
placeholder="https://provider.example/v1"
/>
</SimpleGrid>
<TextInput
label={t("model.discoveryPath")}
description={t("model.discoveryPathDescription")}
value={modelDiscoveryPath}
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
placeholder="/models"
/>
<TextInput
label={t("model.discoveryPath")}
description={t("model.discoveryPathDescription")}
value={modelDiscoveryPath}
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
placeholder="/models"
/>
</Stack>
{destinationChanged && (
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { toProviderOptionsKey } from "./assistant-provider-options";
describe("toProviderOptionsKey", () => {
test("converts the hyphenated provider name to the camelCase AI SDK provider option key", () => {
expect(toProviderOptionsKey("homarr-openrouter")).toBe("homarrOpenrouter");
});
test("matches the @ai-sdk/openai-compatible camelCase conversion", () => {
expect(toProviderOptionsKey("homarr-anthropic")).toBe("homarrAnthropic");
expect(toProviderOptionsKey("homarr-google-gemini")).toBe("homarrGoogleGemini");
});
test("leaves names without separators unchanged", () => {
expect(toProviderOptionsKey("homarr")).toBe("homarr");
});
});
@@ -0,0 +1,17 @@
/**
* The AI SDK resolves provider options by the provider name registered on the
* language model. The @ai-sdk/openai-compatible provider converts the raw
* provider name (for example `homarr-openrouter`) to camelCase
* (`homarrOpenrouter`) and warns when the raw key is still used. This helper
* mirrors that conversion so providerOptions reach the provider without a
* deprecation warning.
*/
export const toProviderOptionsKey = (name: string) =>
name
.split("-")
.filter(Boolean)
.map((segment, index) => {
if (index === 0) return segment;
return segment.charAt(0).toUpperCase() + segment.slice(1);
})
.join("");
@@ -3,7 +3,7 @@ import { hkdfSync } from "node:crypto";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
import type { ToolSet, UIMessage } from "ai";
import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
@@ -27,6 +27,7 @@ import {
} from "@homarr/definitions";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
import { env as appEnv } from "~/env";
import type {
AssistantMessageMetadata,
AssistantRequestStep,
@@ -43,6 +44,7 @@ import {
normalizeOpenRouterWebSearchSources,
withOpenRouterWebSearch,
} from "./assistant-openrouter";
import { toProviderOptionsKey } from "./assistant-provider-options";
import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getSafeAssistantToolError } from "./assistant-tool-error";
@@ -322,6 +324,31 @@ const getProcedureTypeMap = () => {
);
};
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const text =
"This is the Homarr demo assistant. It is running in preview/mock mode, so it responds with a canned answer instead of calling a real language model. In a real deployment, an administrator configures a provider and a model for full conversations.";
const responseId = crypto.randomUUID();
const partId = crypto.randomUUID();
const chunkTexts = text.match(/.{1,3}/gs) ?? [text];
const stream = createUIMessageStream<UIMessage>({
originalMessages: request.messages as UIMessage[],
execute: async ({ writer }) => {
writer.write({ type: "start", messageId: responseId });
writer.write({ type: "text-start", id: partId });
for (const chunk of chunkTexts) {
writer.write({ type: "text-delta", id: partId, delta: chunk });
await delay(18);
}
writer.write({ type: "text-end", id: partId });
writer.write({ type: "finish", finishReason: "stop" });
},
});
return createUIMessageStreamResponse({ stream });
};
export async function POST(request: Request) {
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > 2_000_000) {
@@ -359,10 +386,7 @@ export async function POST(request: Request) {
const configuration = await db.query.assistantConfigurations.findFirst({
where: eq(assistantConfigurations.id, "default"),
});
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
}
const thread = await db.query.assistantThreads.findFirst({
where: and(eq(assistantThreads.id, parsed.data.id), eq(assistantThreads.userId, session.user.id)),
});
@@ -370,6 +394,15 @@ export async function POST(request: Request) {
return Response.json({ error: "Conversation not found." }, { status: 404 });
}
if (appEnv.DEMO_MODE) {
return createDemoAssistantResponse(parsed.data);
}
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
}
const context = createTRPCContext({ headers: request.headers, session });
const requestStartedAt = Date.now();
const requestId = crypto.randomUUID();
@@ -543,7 +576,7 @@ export async function POST(request: Request) {
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
providerOptions:
configuration.provider === "openrouter" || openRouterServerToolsEnabled
? { [providerName]: { usage: { include: true } } }
? { [toProviderOptionsKey(providerName)]: { usage: { include: true } } }
: undefined,
toolApproval,
experimental_toolApprovalSecret: getToolApprovalSecret(),
+24 -25
View File
@@ -30,7 +30,7 @@ import {
import type { createTRPCContext } from "../trpc";
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { boardRouter } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
@@ -440,14 +440,10 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
};
export const assistantRouter = createTRPCRouter({
getAvailability: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "Check whether Homarr Assistant is enabled and configured for the current user",
},
})
.query(async ({ ctx }) => {
getAvailability: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return { enabled: true };
}
const configuration = await getConfigurationAsync(ctx.db);
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
return {
@@ -457,26 +453,11 @@ export const assistantRouter = createTRPCRouter({
};
}),
getContextEntities: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Lists the apps, integrations, boards, and widgets the signed-in user may reference. Integration entries are permission-filtered. Use returned IDs as inputs for other tools.",
},
})
.query(async ({ ctx }) => {
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
return await getAssistantContextEntitiesAsync(ctx);
}),
getModelCapabilities: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns the configured assistant model's discovered input modalities and whether image input is supported, unsupported, or unknown.",
},
})
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
.query(async ({ ctx, input }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -495,6 +476,24 @@ export const assistantRouter = createTRPCRouter({
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (isDemoMode) {
return {
provider: "homarr",
defaultModelId: "homarr-assistant",
models: [
{
id: "homarr-assistant",
name: "Homarr Assistant (Demo)",
description: null,
contextLength: null,
promptPrice: null,
completionPrice: null,
inputModalities: [],
toolSupport: "unknown" as const,
},
],
};
}
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
}