Compare commits

...
Author SHA1 Message Date
Thomas Camlong cd8f0a51c3 fix: prevent infinite render loop in assistant telemetry
RequestTelemetry derived thread message metadata inside the useAuiState
selector with messages.map(), returning a new array on every render.
useSyncExternalStore treats the new reference as a changed snapshot,
causing an infinite re-render loop (React error #185) while an assistant
message streams. Select the stable messages reference and memoize the
metadata derivation instead.
2026-08-07 14:49:51 +02:00
Thomas Camlong db9aecb1cd feat: remove MCP exposure for Homarr Assistant
Remove the MCP metadata from the assistant router procedures
(getAvailability, getContextEntities, getModelCapabilities) so the Homarr
Assistant is no longer exposed as MCP tools, and drop the corresponding
expectations from the MCP router test.
2026-08-06 13:36:45 +02:00
Thomas Camlong 974dc1609c fix: align board page and metadata error handling with layout
- resolve the session before handling board lookup errors so anonymous
  users still get the /auth/login redirect on a missing home board
- map both NOT_FOUND and BAD_REQUEST to a 404 in the board page and in
  generateMetadataAsync, matching the layout behavior
- clarify the getAvailability MCP description to mention demo-mode
  availability
2026-08-06 13:32:52 +02:00
Thomas Camlong f61e3591bd feat: mock Homarr Assistant in demo mode and fix board page crash
- demo mode now enables the assistant at runtime: getAvailability and
  getRuntimeOptions report an enabled Homarr Assistant (Demo) model without
  requiring a provider configuration or API key
- the assistant chat route returns a canned, streamed answer in demo mode
  instead of calling a real language model
- align the provider/endpoint fields in the assistant configuration page
- catch NOT_FOUND/BAD_REQUEST in the board page so a missing home board
  renders a 404 instead of crashing
- use the camelCase provider options key expected by @ai-sdk/openai-compatible
  to silence the deprecation warning
2026-08-06 13:10:21 +02:00
8 changed files with 132 additions and 49 deletions

No files matched your search

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
@@ -49,8 +50,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);
@@ -110,8 +127,11 @@ 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(),
@@ -923,7 +923,8 @@ const RequestTelemetry = () => {
const metadata = useAuiState((state) => state.message.metadata);
const messageId = useAuiState((state) => state.message.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const threadMessageMetadata = useAuiState((state) => state.thread.messages.map((message) => message.metadata));
const threadMessages = useAuiState((state) => state.thread.messages);
const threadMessageMetadata = useMemo(() => threadMessages.map((message) => message.metadata), [threadMessages]);
const persistedTelemetry = getAssistantTelemetry(metadata);
const persistedUsage = getAssistantUsage(metadata);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
+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." });
}
-5
View File
@@ -3,7 +3,6 @@
import { expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { assistantRouter } from "../router/assistant";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
@@ -28,7 +27,6 @@ vi.mock("@homarr/core/infrastructure/db/env", () => ({
}));
const mcpTestRouter = createTRPCRouter({
assistant: assistantRouter,
app: appRouter,
board: boardRouter,
customWidget: customWidgetRouter,
@@ -49,9 +47,6 @@ test("MCP tools should contain expected procedures", () => {
const toolNames = tools.map((tool) => tool.name);
expect(tools.length).toBeGreaterThan(0);
expect(toolNames).toContain("assistant_getAvailability");
expect(toolNames).toContain("assistant_getContextEntities");
expect(toolNames).toContain("assistant_getModelCapabilities");
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");