From cba2df72e2683c606fcd1002916bde91396bb961 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 08:25:00 +0200 Subject: [PATCH] feat: promote assistant configuration --- apps/docs/docs/management/assistant.mdx | 76 +++++++++++++++++++ apps/docs/docs/management/mcp.mdx | 2 +- .../_components/assistant-configuration.tsx} | 27 ++++--- .../app/[locale]/manage/assistant/page.tsx | 39 ++++++++++ .../nextjs/src/app/[locale]/manage/layout.tsx | 7 ++ .../assistant/assistant-provider.tsx | 25 ++++-- .../api/src/test/assistant-providers.spec.ts | 9 +++ packages/definitions/src/assistant.ts | 15 ++++ .../actions/items/group-action-item.tsx | 2 + .../modes/command/context-specific-group.tsx | 13 +++- .../src/modes/home/context-specific-group.tsx | 13 +++- packages/spotlight/src/modes/home/context.tsx | 2 + packages/translation/src/lang/en.json | 12 ++- 13 files changed, 219 insertions(+), 23 deletions(-) create mode 100644 apps/docs/docs/management/assistant.mdx rename apps/nextjs/src/app/[locale]/manage/{settings/_components/assistant-settings.tsx => assistant/_components/assistant-configuration.tsx} (95%) create mode 100644 apps/nextjs/src/app/[locale]/manage/assistant/page.tsx diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx new file mode 100644 index 000000000..88b8a0b2c --- /dev/null +++ b/apps/docs/docs/management/assistant.mdx @@ -0,0 +1,76 @@ +--- +tags: + - Assistant + - AI + - Administration + - Security +--- + +# Homarr Assistant + +Administrators can configure the instance-wide AI assistant from **Management → 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. + +When the assistant is unavailable, its command-menu result remains visible but disabled and explains that +an administrator must configure and enable it. + +## Configure a model provider + +Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, xAI, Groq, Mistral AI, +DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom +preset connects any other service that implements 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 configuration. + +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. + +The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as +suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be +ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant +uses. Choose OpenRouter or another provider when you prefer a provider-neutral production endpoint. + +## 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. + +::: diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 9ff4f6b02..b4c52f040 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -12,7 +12,7 @@ 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. +The optional [Homarr Assistant](./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. diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx similarity index 95% rename from apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx rename to apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index 3570ceb2c..bcd582531 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -10,6 +10,7 @@ import { Card, Divider, Group, + Image, LoadingOverlay, PasswordInput, Select, @@ -18,7 +19,6 @@ import { Switch, Text, TextInput, - Title, Tooltip, } from "@mantine/core"; import { @@ -26,7 +26,6 @@ import { IconDatabaseSearch, IconKey, IconPlus, - IconRobot, IconShieldLock, IconTool, IconTrash, @@ -41,7 +40,16 @@ import { useScopedI18n } from "@homarr/translation/client"; type HeaderEntry = { id: number; name: string; value: string }; -export const AssistantSettings = () => { +const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => { + const iconUrl = assistantProviderPresets[providerId].iconUrl; + return iconUrl ? ( + + ) : ( + + ); +}; + +export const AssistantConfiguration = () => { const t = useScopedI18n("management.page.settings.section.assistant"); const utils = clientApi.useUtils(); const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery(); @@ -200,12 +208,6 @@ export const AssistantSettings = () => { return ( - - - {t("title")} - - {t("description")} - @@ -234,6 +236,13 @@ export const AssistantSettings = () => { data={providerOptions} searchable allowDeselect={false} + leftSection={} + renderOption={({ option }) => ( + + + {option.label} + + )} /> + + +
+ {t("title")} + + {t("description")} + +
+ +
+ + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index d5d96d3f6..7e8a7fff2 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -21,6 +21,7 @@ import { IconMailForward, IconPhotoFilled, IconPointerFilled, + IconRobot, IconSearch, IconSettingsFilled, IconUserFilled, @@ -198,6 +199,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) { }, ], }, + { + label: t("items.assistant"), + href: "/manage/assistant", + icon: IconRobot, + hidden: !session?.user.permissions.includes("admin"), + }, { label: t("items.settings"), href: "/manage/settings", diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 98b29f6b6..012b40610 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -259,7 +259,7 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => { const t = useScopedI18n("common.assistant"); const session = useSession(); const [opened, setOpened] = useState(false); - const { data } = clientApi.assistant.getAvailability.useQuery(undefined, { + const { data, isLoading: isAvailabilityLoading } = clientApi.assistant.getAvailability.useQuery(undefined, { enabled: session.status === "authenticated", staleTime: 60_000, }); @@ -268,19 +268,32 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => { const open = useCallback(() => setOpened(true), []); const close = useCallback(() => setOpened(false), []); const toggle = useCallback(() => setOpened((current) => !current), []); - useHotkeys([[hotkeys.openAssistant, open]]); + const openIfEnabled = useCallback(() => { + if (enabled) { + open(); + } + }, [enabled, open]); + useHotkeys([[hotkeys.openAssistant, openIfEnabled]]); const spotlightItem = useMemo( () => ({ id: "homarr-assistant", name: t("spotlight"), icon: "/logo/logo.png", - interaction: () => ({ type: "javaScript" as const, onSelect: open }), + description: enabled + ? t("spotlightDescription") + : session.status !== "authenticated" + ? t("unavailable.signIn") + : isAvailabilityLoading + ? t("unavailable.checking") + : t("unavailable.notConfigured"), + unavailable: !enabled, + interaction: () => (enabled ? { type: "javaScript" as const, onSelect: open } : { type: "none" as const }), }), - [open, t], + [enabled, isAvailabilityLoading, open, session.status, t], ); - useRegisterSpotlightContextResults("homarr-assistant", enabled ? [spotlightItem] : [], [enabled, spotlightItem]); - useRegisterSpotlightContextActions("homarr-assistant", enabled ? [spotlightItem] : [], [enabled, spotlightItem]); + useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); + useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo(() => ({ enabled, opened, open, close, toggle }), [close, enabled, open, opened, toggle]); diff --git a/packages/api/src/test/assistant-providers.spec.ts b/packages/api/src/test/assistant-providers.spec.ts index 2858cd9d9..78e1b5080 100644 --- a/packages/api/src/test/assistant-providers.spec.ts +++ b/packages/api/src/test/assistant-providers.spec.ts @@ -21,4 +21,13 @@ describe("assistant provider presets", () => { expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted"); } }); + + test.each(assistantProviderIds.filter((provider) => provider !== "custom"))( + "%s has a Dashboard Icons provider mark", + (provider) => { + const iconUrl = assistantProviderPresets[provider].iconUrl; + expect(iconUrl).not.toBeNull(); + expect(() => new URL(iconUrl ?? "")).not.toThrow(); + }, + ); }); diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts index 1d926d207..1bafb7df0 100644 --- a/packages/definitions/src/assistant.ts +++ b/packages/definitions/src/assistant.ts @@ -16,12 +16,15 @@ export const assistantProviderIds = [ export type AssistantProvider = (typeof assistantProviderIds)[number]; export type AssistantProviderCategory = "hosted" | "local" | "custom"; +const providerIconBaseUrl = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons"; + interface AssistantProviderPreset { baseUrl: string; modelDiscoveryPath: string | null; requiresApiKey: boolean; category: AssistantProviderCategory; discoveryAuthentication: "bearer" | "anthropic"; + iconUrl: string | null; } export const assistantProviderPresets = { @@ -31,6 +34,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/openrouter-color.svg`, }, openai: { baseUrl: "https://api.openai.com/v1", @@ -38,6 +42,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/openai.svg`, }, anthropic: { baseUrl: "https://api.anthropic.com/v1", @@ -45,6 +50,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "anthropic", + iconUrl: `${providerIconBaseUrl}/anthropic.svg`, }, "google-gemini": { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", @@ -52,6 +58,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/gemini-color.svg`, }, xai: { baseUrl: "https://api.x.ai/v1", @@ -59,6 +66,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/xai.svg`, }, groq: { baseUrl: "https://api.groq.com/openai/v1", @@ -66,6 +74,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/groq.svg`, }, mistral: { baseUrl: "https://api.mistral.ai/v1", @@ -73,6 +82,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/mistral-color.svg`, }, deepseek: { baseUrl: "https://api.deepseek.com", @@ -80,6 +90,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/deepseek-color.svg`, }, together: { baseUrl: "https://api.together.xyz/v1", @@ -87,6 +98,7 @@ export const assistantProviderPresets = { requiresApiKey: true, category: "hosted", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/together-color.svg`, }, ollama: { baseUrl: "http://localhost:11434/v1", @@ -94,6 +106,7 @@ export const assistantProviderPresets = { requiresApiKey: false, category: "local", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/ollama.svg`, }, "lm-studio": { baseUrl: "http://localhost:1234/v1", @@ -101,6 +114,7 @@ export const assistantProviderPresets = { requiresApiKey: false, category: "local", discoveryAuthentication: "bearer", + iconUrl: `${providerIconBaseUrl}/lmstudio.svg`, }, custom: { baseUrl: "", @@ -108,6 +122,7 @@ export const assistantProviderPresets = { requiresApiKey: false, category: "custom", discoveryAuthentication: "bearer", + iconUrl: null, }, } as const satisfies Record; diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index 5ec50ca81..700b106ca 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -27,6 +27,7 @@ export const SpotlightGroupActionItem = option, }: SpotlightGroupActionItemProps) => { const interaction = group.useInteraction(option, query); + const unavailable = "unavailable" in option && option.unavailable === true; // Avoid passing React's special `key` prop via spread const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record; @@ -74,6 +75,7 @@ export const SpotlightGroupActionItem = (interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false) } className={classes.spotlightAction} + aria-disabled={unavailable || undefined} > diff --git a/packages/spotlight/src/modes/command/context-specific-group.tsx b/packages/spotlight/src/modes/command/context-specific-group.tsx index 845c70b59..7ed445b51 100644 --- a/packages/spotlight/src/modes/command/context-specific-group.tsx +++ b/packages/spotlight/src/modes/command/context-specific-group.tsx @@ -1,4 +1,4 @@ -import { Group, Text } from "@mantine/core"; +import { Group, Stack, Text } from "@mantine/core"; import { createGroup } from "../../lib/group"; import type { ContextSpecificItem } from "../home/context"; @@ -16,9 +16,16 @@ export const contextSpecificActionsSearchGroups = createGroup + {icon} - {option.name} + + {option.name} + {option.description && ( + + {option.description} + + )} + ); }, diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx index 336e944dd..a51887966 100644 --- a/packages/spotlight/src/modes/home/context-specific-group.tsx +++ b/packages/spotlight/src/modes/home/context-specific-group.tsx @@ -1,4 +1,4 @@ -import { Group, Text } from "@mantine/core"; +import { Group, Stack, Text } from "@mantine/core"; import { createGroup } from "../../lib/group"; import type { ContextSpecificItem } from "./context"; @@ -16,9 +16,16 @@ export const contextSpecificSearchGroups = createGroup({ ); return ( - + {icon} - {option.name} + + {option.name} + {option.description && ( + + {option.description} + + )} + ); }, diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx index fced7fc06..76e628cde 100644 --- a/packages/spotlight/src/modes/home/context.tsx +++ b/packages/spotlight/src/modes/home/context.tsx @@ -12,6 +12,8 @@ export type ContextSpecificItem = { icon: TablerIcon | string; interaction: () => inferSearchInteractionDefinition; disabled?: boolean; + description?: string; + unavailable?: boolean; }; interface SpotlightContextProps { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 864bdf246..297b50f70 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1984,6 +1984,12 @@ "open": "Open Homarr Assistant", "close": "Close assistant", "spotlight": "Ask Homarr Assistant", + "spotlightDescription": "Start or continue a conversation with your Homarr instance.", + "unavailable": { + "checking": "Checking whether the assistant is available…", + "notConfigured": "An administrator must configure and enable the assistant first.", + "signIn": "Sign in to use Homarr Assistant." + }, "newConversation": "New conversation", "archivedConversation": "Archived conversation", "conversations": "Conversations", @@ -5578,6 +5584,7 @@ "workshop": "Workshop", "searchEngies": "Search engines", "medias": "Medias", + "assistant": "Assistant", "users": { "label": "Users", "items": { @@ -5977,7 +5984,7 @@ "label": "Enable Homarr Assistant", "description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call." }, - "save": "Save assistant settings", + "save": "Save assistant", "notification": { "connectionSaved": { "title": "Provider connection saved", @@ -6884,6 +6891,9 @@ "medias": { "label": "Medias" }, + "assistant": { + "label": "Assistant" + }, "apps": { "label": "Apps", "new": {