feat: promote assistant configuration

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent c0445cdca2
commit cba2df72e2
13 files changed
+219 -23

No files matched your search

+76
View File
@@ -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.
:::
+1 -1
View File
@@ -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.
@@ -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 ? (
<Image src={iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />
) : (
<IconWorld size={size} aria-hidden />
);
};
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 (
<Stack>
<Group gap="sm">
<IconRobot size={26} />
<Title order={2}>{t("title")}</Title>
</Group>
<Text c="dimmed">{t("description")}</Text>
<Card pos="relative" withBorder>
<LoadingOverlay visible={pending} />
<Stack gap="lg">
@@ -234,6 +236,13 @@ export const AssistantSettings = () => {
data={providerOptions}
searchable
allowDeselect={false}
leftSection={<ProviderIcon providerId={provider} />}
renderOption={({ option }) => (
<Group gap="sm" wrap="nowrap">
<ProviderIcon providerId={option.value as AssistantProvider} />
<Text size="sm">{option.label}</Text>
</Group>
)}
/>
<TextInput
label={t("baseUrl.title")}
@@ -0,0 +1,39 @@
import { notFound } from "next/navigation";
import { Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.settings.section.assistant");
return {
title: `${t("title")} • Homarr`,
};
}
export default async function AssistantPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.settings.section.assistant");
return (
<>
<DynamicBreadcrumb />
<Stack>
<div>
<Title order={1}>{t("title")}</Title>
<Text c="dimmed" maw="70ch">
{t("description")}
</Text>
</div>
<AssistantConfiguration />
</Stack>
</>
);
}
@@ -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",
@@ -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]);
@@ -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();
},
);
});
+15
View File
@@ -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<AssistantProvider, AssistantProviderPreset>;
@@ -27,6 +27,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
option,
}: SpotlightGroupActionItemProps<TOption>) => {
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<string, unknown>;
@@ -74,6 +75,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
className={classes.spotlightAction}
aria-disabled={unavailable || undefined}
>
<group.Component {...(optionProps as TOption)} />
</Spotlight.Action>
@@ -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<ContextSpecificIte
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Text>{option.name}</Text>
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
</Stack>
</Group>
);
},
@@ -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<ContextSpecificItem>({
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Text>{option.name}</Text>
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
</Stack>
</Group>
);
},
@@ -12,6 +12,8 @@ export type ContextSpecificItem = {
icon: TablerIcon | string;
interaction: () => inferSearchInteractionDefinition<SearchInteraction>;
disabled?: boolean;
description?: string;
unavailable?: boolean;
};
interface SpotlightContextProps {
+11 -1
View File
@@ -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": {