diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 7c79d9a8d..f4b9f3c6f 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. @@ -73,13 +77,7 @@ Replace `` with your actual API key. Never commit API keys to vers "mcpServers": { "homarr": { "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://your-homarr-instance.com/api/mcp/mcp", - "--header", - "ApiKey:" - ] + "args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:"] } } } diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index d7705e61a..370cd570a 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -9,10 +9,72 @@ tags: --- # Settings + The settings page has a few options that will be applied to the entire Homarr application. They may require a full page reload for changes to apply. +## Homarr Assistant + +Administrators can enable an instance-wide AI assistant from `Management` → `Settings` → **Homarr Assistant**. +It is available to signed-in users from the header, the user menu, and the `Cmd/Ctrl + K` command menu. +`Cmd/Ctrl + Shift + K` opens it directly. + +### Configure a model provider + +Homarr supports OpenRouter, OpenAI, Ollama, LM Studio, and custom services that implement the OpenAI +Chat Completions API. + +1. Choose a provider preset or **Custom endpoint**. +2. Confirm the API base URL. It usually ends in `/v1`. +3. Add an API key if the service requires bearer authentication. +4. Add any provider-specific authentication or routing headers. +5. Save the provider connection. +6. Refresh the model list or enter an exact model ID manually. +7. Enable the assistant and save the settings. + +The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when +the service does not expose model discovery. A manual model ID can still be used. + +API keys and additional header values are encrypted in the Homarr database. They are decrypted only on +the server for discovery and model requests, and they are never returned to the browser. Changing the +provider or base URL clears existing credentials unless replacements are submitted with the change. This +prevents credentials for one service from being sent to another endpoint. + +:::info Container networking + +For a containerized Homarr installation, `localhost` refers to the Homarr container. Use a resolvable +container name, internal DNS name, or host gateway address to reach Ollama, LM Studio, or another local +model server. + +::: + +The configured endpoint must support streaming Chat Completions and structured tool calls for the full +assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter +explicitly advertises the model's `tools` capability. + +### Conversations and tools + +Conversations and messages are stored per user in the Homarr database. Users can start, revisit, archive, +and delete conversations from the assistant panel. + +The assistant can use the same internal tool definitions as the [Homarr MCP server](../mcp). +Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only +procedures can run during the agent loop. Mutating procedures require an explicit approval in the +conversation before they execute. + +Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to +safe internal routes and open Homarr interfaces such as the command menu and media request search. +They never receive provider credentials and cannot navigate to arbitrary external URLs. + +:::warning + +Assistant prompts and selected tool results are sent to the configured model endpoint. Review that +service's privacy and retention terms before enabling this feature for an instance. + +::: + ## Analytics + By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements. The data is never shared with any third-parties and is fully GDPR & CCPA compliant. The software being used is [Umami Analytics](https://umami.is/) which is fully open source. @@ -21,25 +83,29 @@ The main server software can be found [here](https://github.com/umami-software/u A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions. We understand that you might not want Homarr to communicate with our services. -By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. -If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall. +By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. +If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall. Additionally, you opt in to more data collection to help us provide more useful data. ### Integration data + Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them. This will not include any URLs, names, secrets, errors and others. This is disabled by default. ### Widget data + Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them. This will not include any URLs, names, secrets, errors and others. This is disabled by default. ### Users data + Enabling this optional data will send the count of users and whether you activated SSO. This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs. This is disabled by default. ## Search Engine Optimization (SEO) + Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages. This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard. Homarr is requesting the crawlers to not index Homarr instances by default for this reason. @@ -47,22 +113,26 @@ If you use Homarr as a public website and want it to be searchable on these sear Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index. ### No index + This will ask crawlers to not index your Homarr instance on any indexes. It is up to the crawler to properly respect this request. By default, this setting is enabled. Disabling it will allow indexing. ### No follow + If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website. By default, this setting is enabled and will ask crawlers to not follow any links. Crawler of different companies may behave differently when indexing is prohibited and following is allowed. ### No translate + Google and Chrome on Android will prompt users to translate pages that are not in their device language. This can lead to some shifting and can be undesired in some cases. By enabling this, you ask crawlers to never translate and never prompt users for translation. It should be noted that some browsers will ignore this and translate regardless. ### No site links search box + This setting is exclusive to Google. [Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing". The user can search them directly in Google. @@ -72,7 +142,8 @@ This setting is disabled by default. ## Boards ### Global home board -You can set a global home board that will be used for all users that have not set a home board themselves. + +You can set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. Note that the board must be public to be used as a global home board. @@ -97,9 +168,11 @@ You can decide if the app status should be disabled for all boards. ## Appearance ### Default color scheme + You can set the default color scheme for all users that have not set a color scheme themselves. ## Culture ### Default language -You can set the default language for all users that have not set a language themselves. \ No newline at end of file + +You can set the default language for all users that have not set a language themselves. diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a3e0dd3e..6737dd0ee 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -14,6 +14,10 @@ "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:", "@dnd-kit/core": "catalog:", "@dnd-kit/modifiers": "catalog:", "@dnd-kit/sortable": "catalog:", @@ -72,6 +76,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:", diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2388e2812..a7d383a40 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -26,6 +26,7 @@ import type { SupportedLanguage } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { Analytics } from "~/components/layout/analytics"; +import { AssistantProvider } from "~/components/assistant/assistant-provider"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; @@ -123,6 +124,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")} + +
+ + +