fix: harden assistant integration

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent 10ef871777
commit 5db95f2def
14 files changed
+349 -189

No files matched your search

+39 -33
View File
@@ -1,3 +1,5 @@
import { hkdfSync } from "node:crypto";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { ToolSet, UIMessage } from "ai";
import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool } from "ai";
@@ -13,11 +15,17 @@ import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
import { extractMcpTools } from "../../mcp/_extract-tools";
export const maxDuration = 60;
const logger = createLogger({ module: "assistant" });
const toolApprovalSecret = Buffer.from(
hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-tool-approval", 32),
).toString("base64url");
const safeStreamError = "The model endpoint stopped the response. Check its URL, model, and credentials.";
const requestSchema = z.object({
id: z.string().min(1).max(64),
@@ -45,26 +53,6 @@ const requestSchema = z.object({
.optional(),
});
const browserToolDefinitions = {
navigate_to_route: {
description: "Navigate the current Homarr tab to a safe internal route.",
parameters: {
type: "object",
properties: { path: { type: "string", description: "An internal Homarr path beginning with /" } },
required: ["path"],
additionalProperties: false,
},
},
open_command_menu: {
description: "Open Homarr's command and search menu.",
parameters: { type: "object", properties: {}, additionalProperties: false },
},
open_media_request_search: {
description: "Open Homarr's media request search interface.",
parameters: { type: "object", properties: {}, additionalProperties: false },
},
} as const;
const assistantInstructions = `You are Homarr Assistant, embedded in the user's self-hosted Homarr dashboard.
Use the available Homarr tools whenever live instance data is needed. Never invent integrations, boards, apps, users, media, system status, or action results.
@@ -164,11 +152,10 @@ export async function POST(request: Request) {
const context = createTRPCContext({ headers: request.headers, session });
const caller = mcpRouter.createCaller(context);
const procedureTypes = getProcedureTypeMap();
const mcpTools = extractMcpTools();
const homarrTools = Object.fromEntries(
extractMcpTools().map((mcpTool) => {
const procedurePath = mcpTool.pathInRouter.join(".");
const isMutation = procedureTypes.get(procedurePath) === "mutation";
mcpTools.map((mcpTool) => {
return [
mcpTool.name,
tool({
@@ -176,7 +163,6 @@ export async function POST(request: Request) {
inputSchema: jsonSchema(
(mcpTool.inputSchema ?? { type: "object", properties: {} }) as Parameters<typeof jsonSchema>[0],
),
needsApproval: isMutation,
execute: async (input) => {
try {
const procedure = mcpTool.pathInRouter.reduce<unknown>(
@@ -205,17 +191,24 @@ export async function POST(request: Request) {
];
}),
) satisfies ToolSet;
const toolApproval = Object.fromEntries(
mcpTools.flatMap((mcpTool) =>
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
? [[mcpTool.name, "user-approval" as const]]
: [],
),
);
const frontendTools = Object.fromEntries(
Object.keys(parsed.data.tools ?? {}).flatMap((name) => {
if (!(name in browserToolDefinitions)) return [];
const definition = browserToolDefinitions[name as keyof typeof browserToolDefinitions];
if (!(name in browserToolContracts)) return [];
const definition = browserToolContracts[name as keyof typeof browserToolContracts];
return [
[
name,
tool({
description: definition.description,
inputSchema: jsonSchema(definition.parameters as Parameters<typeof jsonSchema>[0]),
inputSchema: jsonSchema(z.toJSONSchema(definition.parameters) as Parameters<typeof jsonSchema>[0]),
}),
] as const,
];
@@ -249,16 +242,29 @@ export async function POST(request: Request) {
abortSignal: request.signal,
timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 },
maxRetries: 2,
experimental_toolApprovalSecret: env.SECRET_ENCRYPTION_KEY,
toolApproval,
experimental_toolApprovalSecret: toolApprovalSecret,
onError: ({ error }) => {
logger.error("Assistant response stream failed", {
error: error instanceof Error ? error.message : String(error),
});
},
onFinish: async () => {
await db
.update(assistantThreads)
.set({ modelId: configuration.modelId, updatedAt: new Date() })
.where(eq(assistantThreads.id, thread.id));
try {
await db
.update(assistantThreads)
.set({ modelId: configuration.modelId, updatedAt: new Date() })
.where(eq(assistantThreads.id, thread.id));
} catch (error) {
logger.error("Failed to update assistant conversation metadata", {
threadId: thread.id,
error: error instanceof Error ? error.message : String(error),
});
}
},
});
return result.toUIMessageStreamResponse();
return result.toUIMessageStreamResponse({ onError: () => safeStreamError });
} catch {
return Response.json(
{ error: "The configured model endpoint could not start this response. Check its URL, model, and credentials." },
@@ -1,7 +1,3 @@
.drawer {
--assistant-panel-width: min(32rem, 100vw);
}
.content {
height: 100dvh;
background: var(--mantine-color-body);
@@ -13,6 +13,7 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
IconArchive,
IconArrowUp,
@@ -206,8 +207,17 @@ const ArchivedThreadListItem = () => {
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => void }) => {
const t = useScopedI18n("common.assistant");
const isMobile = useMediaQuery("(max-width: 48em)");
const hidden = isMobile && !opened;
return (
<Stack className={classes.history} gap="xs" p="sm" data-opened={opened}>
<Stack
className={classes.history}
gap="xs"
p="sm"
data-opened={opened}
inert={hidden ? true : undefined}
aria-hidden={hidden || undefined}
>
<Group gap="xs" wrap="nowrap">
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onClose}>
@@ -3,7 +3,6 @@
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useChat } from "@ai-sdk/react";
import type { MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, Toolkit } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
@@ -13,12 +12,11 @@ import {
useAuiState,
useRemoteThreadListRuntime,
} from "@assistant-ui/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
import type { UIMessage } from "ai";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { z } from "zod/v4";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -32,6 +30,7 @@ import {
} from "@homarr/spotlight";
import { AssistantPanel } from "./assistant-panel";
import { browserToolContracts } from "./assistant-tool-contracts";
interface AssistantContextValue {
enabled: boolean;
@@ -178,22 +177,15 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
});
const AssistantThreadRuntime = () => {
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const aui = useAui();
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
const chat = useChat<UIMessage>({
id: localThreadId,
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
return useChatRuntime<UIMessage>({
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
adapters: { history },
});
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const runtime = useAISDKRuntime(chat, { adapters: { history } });
transport.setRuntime(runtime);
transport["__internal_setGetThreadListItem"](() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
return runtime;
};
const AssistantRuntime = ({ children }: PropsWithChildren) => {
@@ -208,17 +200,20 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
defineToolkit({
navigate_to_route: {
type: "frontend",
description:
"Navigate the current Homarr tab to a safe internal route. Only paths beginning with a single slash are accepted.",
parameters: z.object({
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
}),
...browserToolContracts.navigate_to_route,
execute: async ({ path }) => {
if (!path.startsWith("/") || path.startsWith("//")) {
const target = URL.canParse(path, window.location.origin) ? new URL(path, window.location.origin) : null;
if (
!path.startsWith("/") ||
path.startsWith("/\\") ||
target === null ||
target.origin !== window.location.origin
) {
return { success: false, error: "Only internal Homarr paths are allowed." };
}
router.push(path);
return { success: true, path };
const internalPath = `${target.pathname}${target.search}${target.hash}`;
router.push(internalPath);
return { success: true, path: internalPath };
},
renderText: {
running: ({ args }) => `Opening ${args.path}…`,
@@ -227,8 +222,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
},
open_command_menu: {
type: "frontend",
description: "Open Homarr's command and search menu.",
parameters: z.object({}),
...browserToolContracts.open_command_menu,
execute: async () => {
openSpotlight();
return { success: true };
@@ -237,8 +231,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
},
open_media_request_search: {
type: "frontend",
description: "Open Homarr's media request search interface.",
parameters: z.object({}),
...browserToolContracts.open_media_request_search,
execute: async () => {
openMediaRequestSearch();
return { success: true };
@@ -0,0 +1,19 @@
import { z } from "zod/v4";
export const browserToolContracts = {
navigate_to_route: {
description:
"Navigate the current Homarr tab to a safe internal route. Only same-origin paths beginning with a single slash are accepted.",
parameters: z.object({
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
}),
},
open_command_menu: {
description: "Open Homarr's command and search menu.",
parameters: z.object({}),
},
open_media_request_search: {
description: "Open Homarr's media request search interface.",
parameters: z.object({}),
},
} as const;