feat: promote assistant configuration
This commit is contained in:
1 parent
c0445cdca2
commit
cba2df72e2
13 files changed
+219
-23
No files matched your search
@@ -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.
|
||||
|
||||
:::
|
||||
@@ -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.
|
||||
|
||||
|
||||
+18
-9
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user