From fd69cd28f09ad9ab26b6e506fbec88aceffc14f1 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 21:33:17 +0200 Subject: [PATCH] feat: add Homarr assistant --- apps/docs/docs/management/mcp.mdx | 4 + apps/nextjs/package.json | 18 +- apps/nextjs/src/app/[locale]/layout.tsx | 2 + .../_components/assistant-settings.tsx | 437 +++ .../src/app/api/assistant/chat/route.ts | 268 ++ .../assistant/assistant-panel.module.css | 175 + .../components/assistant/assistant-panel.tsx | 355 ++ .../assistant/assistant-provider.tsx | 306 ++ .../assistant/assistant-trigger.tsx | 23 + apps/nextjs/src/components/layout/header.tsx | 2 + .../src/components/user-avatar-menu.tsx | 67 +- packages/api/src/root.ts | 1 + packages/api/src/router/assistant.ts | 467 +++ .../migrations/mysql/0044_add_assistant.sql | 37 + .../db/migrations/mysql/meta/_journal.json | 7 + .../postgresql/0010_workable_shard.sql | 33 + .../migrations/postgresql/meta/_journal.json | 7 + .../migrations/sqlite/0042_bumpy_tattoo.sql | 32 + .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/schema/index.ts | 6 + packages/db/schema/mysql.ts | 38 + packages/db/schema/postgresql.ts | 38 + packages/db/schema/sqlite.ts | 43 + packages/definitions/src/hotkeys.ts | 1 + packages/translation/src/lang/en.json | 142 +- pnpm-lock.yaml | 3460 +++++++++++++---- pnpm-workspace.yaml | 6 + 27 files changed, 5210 insertions(+), 772 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx create mode 100644 apps/nextjs/src/app/api/assistant/chat/route.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-panel.module.css create mode 100644 apps/nextjs/src/components/assistant/assistant-panel.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-provider.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-trigger.tsx create mode 100644 packages/api/src/router/assistant.ts create mode 100644 packages/db/migrations/mysql/0044_add_assistant.sql create mode 100644 packages/db/migrations/postgresql/0010_workable_shard.sql create mode 100644 packages/db/migrations/sqlite/0042_bumpy_tattoo.sql diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 34a5ac61a..9ff4f6b02 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -12,6 +12,10 @@ Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) serve With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools. +The optional [Homarr Assistant](./settings#homarr-assistant) uses these same tool definitions internally. +Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation +approval before running mutating procedures. + ## Getting Started The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab. diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 30afbf34e..212f54839 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -14,6 +14,12 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@ai-sdk/openai-compatible": "catalog:", + "@ai-sdk/react": "catalog:", + "@assistant-ui/react": "catalog:", + "@assistant-ui/react-ai-sdk": "catalog:", + "@assistant-ui/react-lexical": "catalog:", + "@assistant-ui/react-markdown": "catalog:", "@dnd-kit/abstract": "catalog:", "@dnd-kit/collision": "catalog:", "@dnd-kit/core": "catalog:", @@ -62,7 +68,7 @@ "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", "@mantine/tiptap": "catalog:", - "@modelcontextprotocol/sdk": "catalog:", + "@modelcontextprotocol/server": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", "@tanstack/react-devtools": "catalog:", @@ -77,6 +83,8 @@ "@xterm/addon-fit": "catalog:", "@xterm/xterm": "catalog:", "adm-zip": "catalog:", + "ai": "catalog:", + "assistant-stream": "catalog:", "babel-plugin-react-compiler": "catalog:", "better-sqlite3": "catalog:", "chroma-js": "catalog:", @@ -89,10 +97,9 @@ "isomorphic-dompurify": "catalog:", "jotai": "catalog:", "jszip": "catalog:", - "lucide-react": "catalog:", "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", - "mcp-handler": "catalog:", + "mermaid": "catalog:", "next": "catalog:", "postcss-preset-mantine": "catalog:", "posthog-js": "catalog:", @@ -100,11 +107,14 @@ "react": "catalog:", "react-dom": "catalog:", "react-error-boundary": "catalog:", + "react-shiki": "catalog:", "react-simple-code-editor": "catalog:", + "remark-breaks": "catalog:", + "remark-directive": "catalog:", + "remark-gfm": "catalog:", "sass": "catalog:", "sql.js": "catalog:", "superjson": "catalog:", - "trpc-to-mcp": "catalog:", "use-deep-compare-effect": "catalog:", "yaml": "catalog:", "zod": "catalog:" diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 03bb12d5c..b2329e60d 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { Analytics } from "~/components/layout/analytics"; +import { AssistantProvider } from "~/components/assistant/assistant-provider"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; import { env } from "~/env"; @@ -131,6 +132,7 @@ export default async function Layout(props: { (innerProps) => , (innerProps) => , (innerProps) => , + (innerProps) => , ]); const { locale } = await props.params; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx new file mode 100644 index 000000000..76f81f91a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + ActionIcon, + Alert, + Autocomplete, + Badge, + Button, + Card, + Divider, + Group, + LoadingOverlay, + PasswordInput, + Select, + SimpleGrid, + Stack, + Switch, + Text, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconDatabaseSearch, + IconKey, + IconPlus, + IconRobot, + IconShieldLock, + IconTrash, + IconWorld, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +const providerPresets = { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + discoveryPath: "/models", + requiresApiKey: true, + }, + openai: { + baseUrl: "https://api.openai.com/v1", + discoveryPath: "/models", + requiresApiKey: true, + }, + ollama: { + baseUrl: "http://localhost:11434/v1", + discoveryPath: "/models", + requiresApiKey: false, + }, + "lm-studio": { + baseUrl: "http://localhost:1234/v1", + discoveryPath: "/models", + requiresApiKey: false, + }, + custom: { + baseUrl: "", + discoveryPath: "/models", + requiresApiKey: false, + }, +} as const; + +type Provider = keyof typeof providerPresets; +type HeaderEntry = { id: number; name: string; value: string }; + +export const AssistantSettings = () => { + const t = useScopedI18n("management.page.settings.section.assistant"); + const utils = clientApi.useUtils(); + const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery(); + const [provider, setProvider] = useState("openrouter"); + const [baseUrl, setBaseUrl] = useState(providerPresets.openrouter.baseUrl); + const [modelDiscoveryPath, setModelDiscoveryPath] = useState("/models"); + const [apiKey, setApiKey] = useState(""); + const [clearApiKey, setClearApiKey] = useState(false); + const [headers, setHeaders] = useState([]); + const [clearHeaders, setClearHeaders] = useState(false); + const [nextHeaderId, setNextHeaderId] = useState(1); + const [modelId, setModelId] = useState(""); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + setProvider(configuration?.provider ?? "openrouter"); + setBaseUrl(configuration?.baseUrl ?? providerPresets.openrouter.baseUrl); + setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? ""); + setModelId(configuration?.modelId ?? ""); + setEnabled(configuration?.enabled ?? false); + }, [configuration]); + + const { + data: models, + error: discoveryError, + isFetching: isDiscovering, + refetch: discoverModels, + } = clientApi.assistant.discoverModels.useQuery(undefined, { + enabled: configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null, + staleTime: 5 * 60_000, + retry: false, + }); + + const modelOptions = useMemo( + () => + (models ?? []).map((model) => ({ + value: model.id, + label: model.name === model.id ? model.id : `${model.name} (${model.id})`, + })), + [models], + ); + + const destinationChanged = + configuration?.connectionConfigured === true && + (configuration.provider !== provider || configuration.baseUrl !== baseUrl.trim().replace(/\/$/, "")); + const preset = providerPresets[provider]; + const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0); + const hasEffectiveApiKey = + apiKey.trim().length > 0 || (!destinationChanged && configuration?.apiKeyConfigured === true && !clearApiKey); + const connectionValid = + baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey); + + const updateConnection = clientApi.assistant.updateConnection.useMutation({ + onSuccess: async ({ credentialsClearedForDestinationChange }) => { + setApiKey(""); + setClearApiKey(false); + setHeaders([]); + setClearHeaders(false); + await Promise.all([ + utils.assistant.getAdminConfiguration.invalidate(), + utils.assistant.discoverModels.invalidate(), + utils.assistant.getAvailability.invalidate(), + ]); + showSuccessNotification({ + title: t("notification.connectionSaved.title"), + message: credentialsClearedForDestinationChange + ? t("notification.connectionSaved.credentialsCleared") + : t("notification.connectionSaved.message"), + }); + }, + onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }), + }); + + const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.assistant.getAdminConfiguration.invalidate(), + utils.assistant.getAvailability.invalidate(), + ]); + showSuccessNotification({ + title: t("notification.saved.title"), + message: t("notification.saved.message"), + }); + }, + onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }), + }); + + const clearCredentials = clientApi.assistant.clearCredentials.useMutation({ + onSuccess: async () => { + setApiKey(""); + setHeaders([]); + setModelId(""); + setEnabled(false); + await Promise.all([ + utils.assistant.getAdminConfiguration.invalidate(), + utils.assistant.discoverModels.invalidate(), + utils.assistant.getAvailability.invalidate(), + ]); + showSuccessNotification({ + title: t("notification.credentialsCleared.title"), + message: t("notification.credentialsCleared.message"), + }); + }, + onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }), + }); + + const pending = isLoading || updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending; + + const changeProvider = (value: string | null) => { + if (!value || !(value in providerPresets)) return; + const nextProvider = value as Provider; + const nextPreset = providerPresets[nextProvider]; + setProvider(nextProvider); + setBaseUrl(nextPreset.baseUrl); + setModelDiscoveryPath(nextPreset.discoveryPath); + setEnabled(false); + }; + + const addHeader = () => { + setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]); + setNextHeaderId((current) => current + 1); + setClearHeaders(false); + }; + + const updateHeader = (id: number, field: "name" | "value", value: string) => { + setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header))); + }; + + const saveConnection = () => { + const customHeaders = + headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined; + updateConnection.mutate({ + provider, + baseUrl, + modelDiscoveryPath: modelDiscoveryPath.trim() || null, + apiKey: apiKey.trim() || undefined, + clearApiKey, + customHeaders, + clearCustomHeaders: clearHeaders, + }); + }; + + return ( + + + + {t("title")} + + {t("description")} + + + + + } color="blue" title={t("security.title")}> + {t("security.description")} + + + +
+ {t("connection.title")} + + {t("connection.description")} + +
+ + {configuration?.connectionConfigured ? t("connection.saved") : t("connection.notSaved")} + +
+ + +