From 5db95f2defcc267a51972ac89eaad78a2bb82e36 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 22:56:28 +0200 Subject: [PATCH] fix: harden assistant integration --- .../src/app/api/assistant/chat/route.ts | 72 +++++++------ .../assistant/assistant-panel.module.css | 4 - .../components/assistant/assistant-panel.tsx | 12 ++- .../assistant/assistant-provider.tsx | 45 ++++---- .../assistant/assistant-tool-contracts.ts | 19 ++++ packages/api/src/router/assistant.ts | 25 +++-- packages/api/src/test/mcp.spec.ts | 65 ++++++++++- .../migrations/mysql/0044_add_assistant.sql | 8 +- .../postgresql/0010_workable_shard.sql | 8 +- .../migrations/sqlite/0042_bumpy_tattoo.sql | 4 + packages/db/schema/mysql.ts | 54 ++++++---- packages/db/schema/postgresql.ts | 54 ++++++---- packages/db/schema/sqlite.ts | 66 +++++++----- packages/db/test/schema.spec.ts | 102 +++++++++++------- 14 files changed, 349 insertions(+), 189 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-tool-contracts.ts diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index e4dd9efe9..cb58701d2 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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[0], ), - needsApproval: isMutation, execute: async (input) => { try { const procedure = mcpTool.pathInRouter.reduce( @@ -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[0]), + inputSchema: jsonSchema(z.toJSONSchema(definition.parameters) as Parameters[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." }, diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 0707d41e2..ba3d26f71 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -1,7 +1,3 @@ -.drawer { - --assistant-panel-width: min(32rem, 100vw); -} - .content { height: 100dvh; background: var(--mantine-color-body); diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index a99a3a164..3aebdc1fe 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 ( - +