From 00e8790ec22953dc1771201ca77cccc325efe5a8 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 21:33:17 +0200 Subject: [PATCH 001/125] feat: add Homarr assistant --- apps/docs/docs/management/mcp.mdx | 12 +- apps/docs/docs/management/settings/index.mdx | 81 +- apps/nextjs/package.json | 6 + apps/nextjs/src/app/[locale]/layout.tsx | 2 + .../_components/assistant-settings.tsx | 437 +++ .../src/app/[locale]/manage/settings/page.tsx | 2 + .../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 | 8 + 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 + .../postgresql/meta/0010_snapshot.json | 2690 ++++++++++++++++ .../migrations/postgresql/meta/_journal.json | 9 +- .../migrations/sqlite/0042_bumpy_tattoo.sql | 32 + .../migrations/sqlite/meta/0042_snapshot.json | 2747 +++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 9 +- 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 | 1962 +++++++++++- pnpm-workspace.yaml | 6 + 31 files changed, 9919 insertions(+), 26 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/postgresql/meta/0010_snapshot.json create mode 100644 packages/db/migrations/sqlite/0042_bumpy_tattoo.sql create mode 100644 packages/db/migrations/sqlite/meta/0042_snapshot.json 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")} + +
+ + + { + if (value) onModelChange(value); + }} + data={models.map((model) => ({ value: model.id, label: model.name }))} + searchable + allowDeselect={false} + disabled={modelOptionsLoading || models.length === 0} + leftSection={modelOptionsLoading ? : } + aria-label={t("runtime.model")} + placeholder={t("runtime.model")} + nothingFoundMessage={t("runtime.noModels")} + comboboxProps={{ withinPortal: true, position: "top-start" }} + /> + { {t("destinationChanged.description")} )} + - - -
- {t("apiKey.title")} - - {preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")} - -
- {configuration?.apiKeyConfigured && ( - - {clearApiKey ? t("apiKey.pendingRemoval") : t("apiKey.configuredBadge")} - - )} -
- { - setApiKey(event.currentTarget.value); - setClearApiKey(false); - }} - leftSection={} - placeholder={ - configuration?.apiKeyConfigured ? t("apiKey.replacementPlaceholder") : t("apiKey.placeholder") - } - autoComplete="new-password" - /> - {configuration?.apiKeyConfigured && ( - changeApiKeyRemoval(event.currentTarget.checked)} - color="red" - label={t("apiKey.remove")} - description={clearApiKey ? t("apiKey.removalPendingDescription") : t("apiKey.removeDescription")} - /> - )} -
- - - - - -
- {t("headers.title")} - - {t("headers.description")} - -
- -
- {configuration?.customHeadersConfigured && headers.length === 0 && ( - {t("headers.configured")} - )} - {headers.map((header) => ( - - updateHeader(header.id, "name", event.currentTarget.value)} - placeholder="X-Provider-Header" - /> - updateHeader(header.id, "value", event.currentTarget.value)} - placeholder={t("headers.valuePlaceholder")} - autoComplete="off" - /> - - setHeaders((current) => current.filter((item) => item.id !== header.id))} - > - - - - - ))} - {configuration?.customHeadersConfigured && headers.length === 0 && ( + + {hasStoredApiKey + ? t("apiKey.configuredBadge") + : preset.requiresApiKey + ? t("credentials.required") + : t("credentials.optional")} + + } + actions={ + - )} -
- - - - {(configuration?.apiKeyConfigured || configuration?.customHeadersConfigured) && ( - - )} - - - - - - -
- {t("model.title")} - - {t("model.description")} - -
- + )} +
+ } + > + {hasStoredApiKey && credentialFlow === "idle" ? ( + + + + + + + + {t("apiKey.savedTitle")} + + + {t("apiKey.savedDescription")} + + + + + + + + + ) : credentialFlow === "replace" ? ( + + - {t("model.refresh")} + + + + {t("apiKey.replaceFlow.title")} + + {t("apiKey.replaceFlow.description")} + + + setApiKey(event.currentTarget.value)} + leftSection={} + placeholder={t("apiKey.replacementPlaceholder")} + autoComplete="new-password" + ref={replacementKeyInputRef} + /> + + + + + + + + + } + title={t("apiKey.replaceFlow.warningTitle")} + > + {t("apiKey.replaceFlow.warningDescription")} + + + + + {t("apiKey.replaceFlow.provider")} + + + {t(`provider.options.${provider}.label`)} + + + + + {t("apiKey.replaceFlow.endpoint")} + + + {normalizedBaseUrl} + + + + + + + + + + + + ) : credentialFlow === "remove" ? ( + } title={t("apiKey.removeFlow.title")}> + + + {preset.requiresApiKey + ? t("apiKey.removeFlow.description") + : t("apiKey.removeFlow.optionalDescription")} + + + + + + + + ) : ( + setApiKey(event.currentTarget.value)} + leftSection={} + placeholder={t("apiKey.placeholder")} + autoComplete="new-password" + /> + )} + + + + }> + + + + {t("headers.title")} + + + {t("headers.description")} + + + {configuration?.customHeadersConfigured && ( + + {clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")} + + )} + + + + + + + {configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")} + + + + {headers.map((header) => ( + + updateHeader(header.id, "name", event.currentTarget.value)} + placeholder="X-Provider-Header" + /> + updateHeader(header.id, "value", event.currentTarget.value)} + placeholder={t("headers.valuePlaceholder")} + autoComplete="off" + /> + + setHeaders((current) => current.filter((item) => item.id !== header.id))} + > + + + + + ))} + {configuration?.customHeadersConfigured && headers.length === 0 && ( + + )} + + + + + + + + {configuration.enabled ? t("overview.available") : t("overview.disabled")} + + ) : ( + + {t("overview.noModel")} + + ) + } + actions={ + + - {hasDiscoveredModels ? ( - setModelId(value ?? "")} + data={modelControlsDisabled ? [] : modelOptions} + disabled={modelControlsDisabled} + placeholder={t("model.placeholder")} + description={t("model.discovered", { count: models?.length ?? 0 })} + searchable + allowDeselect={false} + limit={100} + /> + ) : ( + setModelId(event.currentTarget.value)} + disabled={modelControlsDisabled} + placeholder={t("model.placeholder")} + description={ + connectionPending + ? t("model.saveConnectionFirst") + : isDiscovering + ? t("model.discovering") + : discoveryError || models?.length === 0 + ? t("model.manualFallback") + : t("model.manual") + } + /> + )} + {discoveryError && !connectionPending && ( + } title={t("model.discoveryFailed")}> + {discoveryError.message} + + )} + {selectedModel && ( + + + {selectedModel.name} + {selectedModel.description && ( + + {selectedModel.description} + + )} + + + {selectedModel.contextLength && ( + + {t("model.context", { count: selectedModel.contextLength.toLocaleString() })} + + )} + } + > + {selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")} + + + + )} - setEnabled(event.currentTarget.checked)} - disabled={!canSaveConfiguration} - label={t("enabled.label")} - description={t("enabled.description")} - /> + + + + {t("enabled.label")} + + + {t("enabled.description")} + + + setEnabled(event.currentTarget.checked)} + disabled={!canSaveConfiguration} + aria-label={t("enabled.label")} + size="md" + /> + + +
+ setClearCredentialsOpened(false)} + title={t("clearCredentials.title")} + centered + > + + } title={t("clearCredentials.warningTitle")}> + {t("clearCredentials.description")} + + - + -
-
+ + ); }; diff --git a/e2e/assistant-management.spec.ts b/e2e/assistant-management.spec.ts new file mode 100644 index 000000000..025b1c820 --- /dev/null +++ b/e2e/assistant-management.spec.ts @@ -0,0 +1,98 @@ +import { chromium, expect } from "@playwright/test"; +import { describe, test } from "vitest"; + +import { createHomarrContainer } from "./shared/create-homarr-container"; +import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { seedAdminUserAsync } from "./shared/seed-admin-user"; +import * as sqliteSchema from "../packages/db/schema/sqlite"; + +const adminCredentials = { + username: "admin", + password: "Comp(exP4sswOrd", +}; + +describe("Assistant management", () => { + test("keeps provider actions clear and the API key replacement flow responsive", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + await seedAdminUserAsync(db, adminCredentials); + await db.insert(sqliteSchema.assistantConfigurations).values({ + id: "default", + enabled: true, + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + modelDiscoveryPath: null, + encryptedApiKey: "encrypted.key", + modelId: "deepseek/deepseek-v4-flash", + }); + + const homarrContainer = await createHomarrContainer({ + environment: { + AUTH_PROVIDERS: "credentials", + }, + mounts: { + "/appdata": localMountPath, + }, + }).start(); + + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + + try { + await page.goto(`${baseUrl}/auth/login`); + await page.getByLabel("Username").fill(adminCredentials.username); + await page.locator("#password").fill(adminCredentials.password); + await page.getByRole("button", { name: "Login" }).click(); + await page.waitForURL(baseUrl, { timeout: 15_000 }); + + await page.goto(`${baseUrl}/manage/assistant`); + await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible(); + await expect(page.getByText("Connection ready")).toBeVisible(); + await expect(page.getByText("Key protected")).toBeVisible(); + await expect(page.getByText("Encrypted API key saved")).toBeVisible(); + await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove saved API key" })).toBeVisible(); + + await page.getByRole("button", { name: "Replace key" }).click(); + const newKey = page.getByLabel("New API key"); + await expect(newKey).toBeFocused(); + await newKey.fill("replacement-test-api-key"); + await page.getByRole("button", { name: "Review replacement" }).click(); + await expect(page.getByText("The current key will stop being used")).toBeVisible(); + await expect(page.getByText("https://openrouter.ai/api/v1", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible(); + + await page.getByRole("button", { name: "Edit new key" }).click(); + await page.getByRole("button", { name: "Cancel replacement" }).click(); + await page.getByRole("button", { name: "Additional request headers" }).click(); + await expect(page.getByRole("button", { name: "Add header" })).toBeVisible(); + + await page.getByRole("button", { name: "Remove all credentials" }).click(); + await expect(page.getByRole("dialog", { name: "Remove all assistant credentials" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove credentials" })).toBeVisible(); + await page.getByRole("button", { name: "Keep credentials" }).click(); + await expect(page.getByRole("dialog", { name: "Remove all assistant credentials" })).toBeHidden(); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save assistant" })).toBeVisible(); + await page.getByRole("button", { name: "Replace key" }).click(); + await expect(page.getByLabel("New API key")).toBeFocused(); + await page.getByLabel("New API key").fill("replacement-test-api-key"); + await page.getByRole("button", { name: "Review replacement" }).click(); + await expect(page.getByText("The current key will stop being used")).toBeVisible(); + await expect(page.getByRole("button", { name: "Edit new key" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible(); + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ); + expect(hasHorizontalOverflow).toBe(false); + } finally { + await browser.close(); + await homarrContainer.stop(); + } + }, 120_000); +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index bacd8f2d1..beeab65ae 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4992,14 +4992,30 @@ "title": "Server-side secrets", "description": "API keys and additional header values are encrypted in the Homarr database. They are never returned to browsers. Model requests are sent from the Homarr server directly to the configured endpoint." }, + "overview": { + "connectionReady": "Connection ready", + "unsavedChanges": "Unsaved changes", + "setupRequired": "Setup required", + "keyProtected": "Key protected", + "available": "Available", + "disabled": "Disabled", + "noModel": "No model selected" + }, "connection": { "title": "Provider connection", + "endpointTitle": "Provider and endpoint", "description": "Choose a preset or connect any service that implements the OpenAI Chat Completions API.", "saved": "Connection saved", "notSaved": "Not configured", "save": "Save provider connection", "clearCredentials": "Remove all credentials" }, + "credentials": { + "title": "Credentials", + "description": "Add the secrets this endpoint needs. Stored values stay encrypted and cannot be read back.", + "required": "Key required", + "optional": "Key optional" + }, "provider": { "title": "Provider", "groups": { @@ -5074,9 +5090,35 @@ "replacementPlaceholder": "Enter a replacement API key", "configuredBadge": "Saved key active", "pendingRemoval": "Removal pending", + "savedTitle": "Encrypted API key saved", + "savedDescription": "Homarr can use this key, but it cannot display or recover its value.", + "replace": "Replace key", "remove": "Remove saved API key", "removeDescription": "Deletes the saved key after you click Save provider connection.", - "removalPendingDescription": "The saved key will be deleted when you save the provider connection." + "removalPendingDescription": "The saved key will be deleted when you save the provider connection.", + "replaceFlow": { + "title": "Replace the saved API key", + "description": "Enter the new key first. You will review the endpoint and impact before anything changes.", + "enterStep": "Enter new key", + "reviewStep": "Review change", + "newKey": "New API key", + "newKeyDescription": "The existing key remains active until you confirm the replacement.", + "cancel": "Cancel replacement", + "review": "Review replacement", + "warningTitle": "The current key will stop being used", + "warningDescription": "Confirming replaces the encrypted key for this endpoint. Homarr cannot restore the previous value, and model discovery and assistant requests will use the new key immediately.", + "provider": "Provider", + "endpoint": "Destination", + "back": "Edit new key", + "confirm": "Replace API key" + }, + "removeFlow": { + "title": "Remove the saved API key?", + "description": "This provider requires an API key. Removing it can stop model discovery and assistant responses until another key is saved.", + "optionalDescription": "The endpoint will stop receiving bearer authentication after this key is removed. Confirm that it accepts unauthenticated requests or uses another stored header.", + "cancel": "Keep saved key", + "confirm": "Remove API key" + } }, "headers": { "title": "Additional request headers", @@ -5087,6 +5129,9 @@ "valuePlaceholder": "Secret or routing value", "remove": "Remove header", "configured": "Additional encrypted headers are configured. Adding headers replaces the complete stored set.", + "empty": "No additional headers are stored for this provider.", + "savedBadge": "Headers saved", + "pendingRemoval": "Removal pending", "clear": "Remove stored headers when saving", "keep": "Keep stored headers" }, @@ -5095,6 +5140,7 @@ "description": "Select a discovered model or enter an exact endpoint model ID.", "placeholder": "Model ID", "refresh": "Refresh models", + "chooseModel": "Choose the model used for new conversations.", "discoveryPath": "Model discovery path", "discoveryPathDescription": "Appended to the API base URL. Leave blank to disable discovery and use a manual model ID.", "manual": "Enter a model ID manually or save the connection to discover models.", @@ -5111,6 +5157,13 @@ "label": "Enable Assistant", "description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call." }, + "clearCredentials": { + "title": "Remove all assistant credentials", + "warningTitle": "The assistant will be disabled", + "description": "This permanently removes the stored API key and additional headers, clears the selected model, and disables the assistant. The encrypted values cannot be recovered.", + "cancel": "Keep credentials", + "confirm": "Remove credentials" + }, "save": "Save assistant", "notification": { "connectionSaved": { From e6981a7f440ce2439c64a989c007e7bb410a6b37 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 23:31:24 +0200 Subject: [PATCH 031/125] fix: invoke mutations before approval --- .../chat/assistant-tool-policy.spec.ts | 84 +++++++++++++++++++ .../assistant/chat/assistant-tool-policy.ts | 26 ++++++ .../src/app/api/assistant/chat/route.ts | 24 ++++-- 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts new file mode 100644 index 000000000..0fb365986 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import type { UIMessage } from "ai"; + +import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy"; + +const assistantMessage = (...parts: UIMessage["parts"]): UIMessage => ({ + id: "assistant-message", + role: "assistant", + parts, +}); + +describe("withAssistantToolPolicy", () => { + test("tells models to call approval-gated mutations without a prose confirmation", () => { + const description = withAssistantToolPolicy("Create a Homarr app.", true); + + expect(description).toContain("call this tool immediately"); + expect(description).toContain("does not execute until the user selects Approve and run"); + expect(description).toContain("Never ask for confirmation in prose"); + }); + + test("does not alter read-only tool descriptions", () => { + expect(withAssistantToolPolicy("List all Homarr apps.", false)).toBe("List all Homarr apps."); + }); +}); + +describe("getForcedAssistantToolName", () => { + test("continues directly from the reviewed app form to app creation", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_app", + toolCallId: "configure-1", + input: { name: "YouTube" }, + state: "output-available", + output: { + name: "YouTube", + iconUrl: "/api/icons/youtube.svg", + href: "https://youtube.com", + }, + }), + ]), + ).toBe("app_create"); + }); + + test("waits for the user to finish reviewing the app form", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_app", + toolCallId: "configure-1", + input: { name: "YouTube" }, + state: "input-available", + }), + ]), + ).toBeUndefined(); + }); + + test("does not repeat app creation after the mutation tool has been called", () => { + expect( + getForcedAssistantToolName([ + assistantMessage( + { + type: "dynamic-tool", + toolName: "configure_app", + toolCallId: "configure-1", + input: { name: "YouTube" }, + state: "output-available", + output: { name: "YouTube", href: "https://youtube.com" }, + }, + { + type: "dynamic-tool", + toolName: "app_create", + toolCallId: "create-1", + input: { name: "YouTube", href: "https://youtube.com" }, + state: "approval-requested", + approval: { id: "approval-1" }, + }, + ), + ]), + ).toBeUndefined(); + }); +}); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts new file mode 100644 index 000000000..56975c90b --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts @@ -0,0 +1,26 @@ +import { getToolName, isToolUIPart } from "ai"; +import type { UIMessage } from "ai"; + +const mutationApprovalInstruction = + "This action is protected by Homarr's native approval UI. When the user's request and the required inputs are clear, call this tool immediately. The tool call only requests approval; it does not execute until the user selects Approve and run. Never ask for confirmation in prose before calling it."; + +export const withAssistantToolPolicy = (description: string | undefined, requiresApproval: boolean) => { + if (!requiresApproval) return description; + return description ? `${description}\n\n${mutationApprovalInstruction}` : mutationApprovalInstruction; +}; + +export const getForcedAssistantToolName = (messages: UIMessage[]) => { + const latestMessage = messages.at(-1); + if (latestMessage?.role !== "assistant") return undefined; + + const latestToolPart = latestMessage.parts.toReversed().find((part) => isToolUIPart(part)); + if ( + latestToolPart === undefined || + getToolName(latestToolPart) !== "configure_app" || + latestToolPart.state !== "output-available" + ) { + return undefined; + } + + return "app_create" as const; +}; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index bd805701f..e7dd274e0 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -28,6 +28,7 @@ import type { import { extractMcpTools } from "../../mcp/_extract-tools"; import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; +import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy"; export const maxDuration = 60; @@ -88,15 +89,18 @@ Homarr concepts: Action rules: - Prefer read-only tools before actions. -- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose and do not tell the user to type yes or no. For a confirmation-style question, offer Yes, No, and Alternative options and leave the freeform Other answer enabled. -- Mutating Homarr tools already pause for native user approval. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears. Never ask for a second textual confirmation first. +- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose. Offer distinct choices and leave the freeform Other answer enabled. +- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears. +- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call. - Do not retry a denied action. - Before creating an app, call configure_app with the best defaults so the user can review Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. - When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL. - Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL. - Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON. - Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists. -- If configuration or a service is unavailable, say what the user or administrator can do next.`; +- If configuration or a service is unavailable, say what the user or administrator can do next. + +Critical approval protocol: when a mutation's inputs are known, your next response must contain the mutation tool call, not a confirmation question. Homarr itself obtains approval after the tool call and before execution.`; const asRecord = (value: unknown): Record | null => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null; @@ -355,10 +359,11 @@ export async function POST(request: Request) { const homarrTools = Object.fromEntries( mcpTools.map((mcpTool) => { + const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"; return [ mcpTool.name, tool({ - description: mcpTool.description, + description: withAssistantToolPolicy(mcpTool.description, requiresApproval), inputSchema: jsonSchema( (mcpTool.inputSchema ?? { type: "object", properties: {} }) as Parameters[0], ), @@ -413,6 +418,8 @@ export async function POST(request: Request) { ]; }), ) satisfies ToolSet; + const availableTools = { ...homarrTools, ...frontendTools }; + const forcedToolName = getForcedAssistantToolName(parsed.data.messages as UIMessage[]); try { const customHeaders = configuration.encryptedHeaders @@ -443,7 +450,14 @@ export async function POST(request: Request) { model: provider(modelId), instructions: `${assistantInstructions}${mentionContext}`, messages: await convertToModelMessages(prepareMessagesForModel(parsed.data.messages as UIMessage[])), - tools: { ...homarrTools, ...frontendTools }, + tools: availableTools, + prepareStep: ({ stepNumber }) => { + if (stepNumber !== 0 || forcedToolName === undefined || !(forcedToolName in availableTools)) return undefined; + return { + activeTools: [forcedToolName], + toolChoice: { type: "tool", toolName: forcedToolName }, + }; + }, stopWhen: stepCountIs(8), abortSignal: request.signal, timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 }, From 1d15b614f3b30ae1a30de830ea1228cf10a4b87a Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 31 Jul 2026 06:00:49 +0200 Subject: [PATCH 032/125] refactor: group assistant runtime controls --- apps/docs/docs/management/assistant.mdx | 10 +- .../assistant/assistant-panel.module.css | 88 +++++++--- .../components/assistant/assistant-panel.tsx | 158 +++++++++++++----- 3 files changed, 192 insertions(+), 64 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 2e070a3f1..fa6254957 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -95,11 +95,11 @@ as part of the prompt. Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations, boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on -the server before adding it to the model context. Type `/` for common prompt shortcuts. The compact -controls below the composer can switch between models discovered from the configured endpoint. Model -selection is stored on the conversation after a completed request. Thinking mode can be left on -**Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's -provider-neutral reasoning setting and still depend on support from the chosen model and endpoint. +the server before adding it to the model context. Type `/` for common prompt shortcuts. The joined +controls below the composer open a searchable model picker and the thinking-level menu. Model selection +is stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, +or set from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning +setting and still depend on support from the chosen model and endpoint. Each completed response includes a circular context-window meter. Select it to open request details when the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context, diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 961bce411..6624c14a7 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -740,30 +740,70 @@ flex: 1 1 auto; } -.modelSelect { - width: min(15rem, 52%); - min-width: 8rem; +.runtimeButtonGroup { + min-width: 0; + max-width: 100%; } -.reasoningSelect { - width: 8.75rem; - flex: 0 0 auto; -} - -.runtimeSelectInput { - border-color: transparent; - border-radius: var(--mantine-radius-md); +.runtimeButton { + min-width: 0; + border-color: var(--mantine-color-default-border); + color: var(--mantine-color-text); background: var(--mantine-color-body); font-weight: 600; } -.runtimeSelectInput:hover { +.runtimeButton:hover { background: var(--mantine-color-default-hover); } -.runtimeSelectInput:focus, -.runtimeSelectInput:focus-within { - border-color: var(--mantine-primary-color-filled); +.runtimeButtonLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelButton { + min-width: 8rem; + max-width: 15rem; + flex: 1 1 auto; +} + +.reasoningButton { + min-width: 7.5rem; + max-width: 10.5rem; + flex: 0 1 auto; +} + +.modelDropdown { + max-width: calc(100vw - 2rem); +} + +.modelOptions { + max-height: min(18rem, 42dvh); + overflow-y: auto; + overscroll-behavior: contain; +} + +.modelOption { + padding-block: 0.45rem; +} + +.modelOptionText { + min-width: 0; + flex: 1; +} + +.runtimeOptionCheck { + flex: 0 0 auto; + color: var(--mantine-primary-color-filled); +} + +.runtimeMenuItem[data-selected] { + color: var(--mantine-primary-color-filled); + background: var(--mantine-primary-color-light); + font-weight: 600; } .triggerPopover { @@ -918,10 +958,17 @@ width: 100%; } - .modelSelect { - width: auto; + .runtimeButtonGroup { + width: 100%; + } + + .modelButton { min-width: 0; - flex: 1 1 auto; + max-width: none; + } + + .reasoningButton { + max-width: 9rem; } .suggestions { @@ -970,8 +1017,9 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .reasoningSelect { - width: 7.75rem; + .reasoningButton { + min-width: 2.5rem; + max-width: 7.75rem; } .approvalActions { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index ccbde59ac..654d44a56 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -36,19 +36,21 @@ import { Box, Button, Collapse, + Combobox, Divider, FocusTrap, Group, Loader, + Menu, Popover, RingProgress, ScrollArea, - Select, Stack, Text, ThemeIcon, Tooltip, UnstyledButton, + useCombobox, } from "@mantine/core"; import { useWindowEvent } from "@mantine/hooks"; import { @@ -1212,45 +1214,123 @@ const RuntimeControls = ({ onReasoningChange, }: ComposerProps) => { const t = useScopedI18n("common.assistant"); + const [modelSearch, setModelSearch] = useState(""); + const selectedModel = models.find((model) => model.id === modelId); + const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase(); + const visibleModels = + normalizedModelSearch.length === 0 + ? models + : models.filter( + (model) => + model.name.toLocaleLowerCase().includes(normalizedModelSearch) || + model.id.toLocaleLowerCase().includes(normalizedModelSearch), + ); + const modelCombobox = useCombobox({ + onDropdownOpen: () => { + modelCombobox.selectActiveOption(); + requestAnimationFrame(() => modelCombobox.focusSearchInput()); + }, + onDropdownClose: () => { + modelCombobox.resetSelectedOption(); + setModelSearch(""); + }, + }); + + const selectModel = (value: string) => { + if (!models.some((model) => model.id === value)) return; + onModelChange(value); + modelCombobox.closeDropdown(); + }; + return ( - - { - if (value && assistantReasoningModes.includes(value as AssistantReasoningMode)) { - onReasoningChange(value as AssistantReasoningMode); - } - }} - data={assistantReasoningModes.map((mode) => ({ - value: mode, - label: t(`runtime.reasoning.${mode}`), - }))} - allowDeselect={false} - leftSection={} - aria-label={t("runtime.thinking")} - comboboxProps={{ withinPortal: true, position: "top-end" }} - /> + + + + + + + + { + setModelSearch(event.currentTarget.value); + modelCombobox.updateSelectedOptionIndex(); + }} + placeholder={t("runtime.model")} + aria-label={t("runtime.model")} + /> + + {visibleModels.map((model) => ( + + + + + {model.name} + + + {model.id} + + + {model.id === modelId && } + + + ))} + {visibleModels.length === 0 && {t("runtime.noModels")}} + + + + + + + + + {t("runtime.thinking")} + {assistantReasoningModes.map((mode) => ( + : null} + onClick={() => onReasoningChange(mode)} + > + {t(`runtime.reasoning.${mode}`)} + + ))} + + + ); }; From bf53a2a26280df24f22fc90e311f33feec9ef5bd Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 31 Jul 2026 06:32:57 +0200 Subject: [PATCH 033/125] fix: keep assistant menu selection visible --- apps/docs/docs/management/assistant.mdx | 3 +- .../components/assistant/assistant-panel.tsx | 40 +++++++++++++- .../assistant-trigger-scroll.spec.ts | 53 +++++++++++++++++++ .../assistant/assistant-trigger-scroll.ts | 17 ++++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index fa6254957..43baf7d27 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -95,7 +95,8 @@ as part of the prompt. Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations, boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on -the server before adding it to the model context. Type `/` for common prompt shortcuts. The joined +the server before adding it to the model context. Type `/` for common prompt shortcuts. Both menus support +keyboard navigation and keep the active result visible while moving through long lists. The joined controls below the composer open a searchable model picker and the thinking-level menu. Model selection is stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 654d44a56..94e7aa0b1 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -1,7 +1,7 @@ "use client"; -import type { ComponentPropsWithoutRef } from "react"; -import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentPropsWithoutRef, RefObject } from "react"; +import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { FileMessagePartProps, ImageMessagePartProps, @@ -27,6 +27,7 @@ import { useAuiState, unstable_useMentionAdapter, unstable_useSlashCommandAdapter, + unstable_useTriggerPopoverScopeContext, } from "@assistant-ui/react"; import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; import { @@ -100,6 +101,7 @@ import classes from "./assistant-panel.module.css"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; +import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; import { getToolResultPresentation } from "./assistant-tool-result"; interface AssistantPanelProps { @@ -1066,6 +1068,34 @@ const contextIcons = { tools: IconTool, }; +const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject }) => { + const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = + unstable_useTriggerPopoverScopeContext(); + + useLayoutEffect(() => { + if (!open) return; + + const viewport = viewportRef.current; + const highlightedItem = viewport?.querySelector("[data-highlighted]"); + if (!viewport || !highlightedItem) return; + + const viewportRect = viewport.getBoundingClientRect(); + const itemRect = highlightedItem.getBoundingClientRect(); + const viewportTop = viewportRect.top + viewport.clientTop; + const viewportBottom = viewportTop + viewport.clientHeight; + + viewport.scrollTop = getNearestTriggerScrollTop({ + scrollTop: viewport.scrollTop, + viewportTop, + viewportBottom, + itemTop: itemRect.top, + itemBottom: itemRect.bottom, + }); + }, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]); + + return null; +}; + const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => { const Icon = typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons @@ -1093,6 +1123,8 @@ const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: numbe const ComposerTriggers = () => { const t = useScopedI18n("common.assistant"); const aui = useAui(); + const mentionViewportRef = useRef(null); + const slashViewportRef = useRef(null); const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000, }); @@ -1150,12 +1182,14 @@ const ComposerTriggers = () => { return ( <> + {(items) => @@ -1186,11 +1220,13 @@ const ComposerTriggers = () => { + {(items) => items.map((item, index) => )} diff --git a/apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts new file mode 100644 index 000000000..b00fb18d0 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; + +import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; + +describe("getNearestTriggerScrollTop", () => { + test("keeps a fully visible highlighted item in place", () => { + expect( + getNearestTriggerScrollTop({ + scrollTop: 120, + viewportTop: 100, + viewportBottom: 400, + itemTop: 180, + itemBottom: 228, + }), + ).toBe(120); + }); + + test("scrolls down only enough to reveal the highlighted item", () => { + expect( + getNearestTriggerScrollTop({ + scrollTop: 120, + viewportTop: 100, + viewportBottom: 400, + itemTop: 388, + itemBottom: 436, + }), + ).toBe(156); + }); + + test("scrolls up only enough to reveal the highlighted item", () => { + expect( + getNearestTriggerScrollTop({ + scrollTop: 120, + viewportTop: 100, + viewportBottom: 400, + itemTop: 72, + itemBottom: 120, + }), + ).toBe(92); + }); + + test("clamps upward scrolling at the start of the list", () => { + expect( + getNearestTriggerScrollTop({ + scrollTop: 12, + viewportTop: 100, + viewportBottom: 400, + itemTop: 40, + itemBottom: 88, + }), + ).toBe(0); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts new file mode 100644 index 000000000..d571b22dc --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts @@ -0,0 +1,17 @@ +export const getNearestTriggerScrollTop = ({ + scrollTop, + viewportTop, + viewportBottom, + itemTop, + itemBottom, +}: { + scrollTop: number; + viewportTop: number; + viewportBottom: number; + itemTop: number; + itemBottom: number; +}) => { + if (itemTop < viewportTop) return Math.max(0, scrollTop - (viewportTop - itemTop)); + if (itemBottom > viewportBottom) return scrollTop + (itemBottom - viewportBottom); + return scrollTop; +}; From 9a2d6f692e9a9fe6828c531a55a43805c9553c33 Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 31 Jul 2026 06:58:39 +0200 Subject: [PATCH 034/125] feat: expose assistant global shortcuts --- apps/docs/docs/management/assistant.mdx | 6 +++-- .../assistant/assistant-provider.tsx | 15 ++++------- .../assistant/assistant-spotlight.spec.ts | 21 ++++++++++++++++ .../assistant/assistant-spotlight.tsx | 25 +++++++++++++------ .../src/components/user-avatar-menu.tsx | 12 +++++++-- .../spotlight/src/components/spotlight.tsx | 5 +++- .../modes/command/context-specific-group.tsx | 4 +-- .../modes/home/context-specific-group.spec.ts | 25 +++++++++++++++++++ .../src/modes/home/context-specific-group.tsx | 14 +++++++---- packages/spotlight/src/modes/home/context.tsx | 3 ++- 10 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 packages/spotlight/src/modes/home/context-specific-group.spec.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 43baf7d27..cd9b0e796 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -9,8 +9,10 @@ tags: # Assistant Administrators can configure the instance-wide AI assistant from **Management → Assistant**. -It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu. Selecting -**Ask Homarr Assistant** in the command menu turns its search field into a prompt composer. Press +It is available to signed-in users from the user menu, with `Cmd/Ctrl + Shift + K`, and from the +`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default +search. Selecting it with an existing query sends that query in the background; selecting it without a +query turns the command-menu search field into a prompt composer. Press `Enter` to send the prompt in the background without opening the conversation. The search field receives focus whenever the command menu opens, including when it is opened by an assistant tool. diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 1e3d1f79d..360c13a9c 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -30,12 +30,7 @@ import { createId } from "@homarr/common"; import { hotkeys } from "@homarr/definitions"; import { showErrorNotification, showWarningNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { - openMediaRequestSearch, - openSpotlight, - useRegisterSpotlightContextActions, - useRegisterSpotlightContextResults, -} from "@homarr/spotlight"; +import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit"; import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools"; @@ -638,12 +633,12 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { name: t("spotlight"), icon: "/logo/logo.png", description: t("spotlightDescription"), - interaction: () => createAssistantPromptInteraction({ sendPrompt }), + alwaysVisible: true, + interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }), }), [sendPrompt, t], ); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); - useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( () => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }), @@ -691,12 +686,12 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP icon: "/logo/logo.png", description, unavailable: true, - interaction: () => ({ type: "none" as const }), + alwaysVisible: true, + interaction: (_query: string) => ({ type: "none" as const }), }), [description, t], ); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); - useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( () => ({ diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts index 896bbfab5..72f386641 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts @@ -3,9 +3,27 @@ import { describe, expect, it, vi } from "vitest"; import { createAssistantPromptInteraction } from "./assistant-spotlight"; describe("createAssistantPromptInteraction", () => { + it("sends an existing default-search query immediately", () => { + const sendPrompt = vi.fn(); + const interaction = createAssistantPromptInteraction({ + sendPrompt, + prompt: " Check my services ", + }); + + expect(interaction.type).toBe("javaScript"); + if (interaction.type !== "javaScript") return; + + interaction.onSelect(); + expect(interaction.closeSpotlightOnTrigger).toBe(true); + expect(sendPrompt).toHaveBeenCalledWith("Check my services"); + }); + it("keeps Spotlight open when the prompt is empty", () => { const sendPrompt = vi.fn(); const interaction = createAssistantPromptInteraction({ sendPrompt }); + expect(interaction.type).toBe("children"); + if (interaction.type !== "children") return; + const [action] = interaction.useActions({}, " "); expect(action).toBeDefined(); if (!action) return; @@ -22,6 +40,9 @@ describe("createAssistantPromptInteraction", () => { it("trims and sends a prompt in the background", () => { const sendPrompt = vi.fn(); const interaction = createAssistantPromptInteraction({ sendPrompt }); + expect(interaction.type).toBe("children"); + if (interaction.type !== "children") return; + const [action] = interaction.useActions({}, " Check my services "); expect(action).toBeDefined(); if (!action) return; diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx index 2319cf82a..9cb431717 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx @@ -7,6 +7,7 @@ import { useScopedI18n } from "@homarr/translation/client"; interface AssistantPromptInteractionOptions { sendPrompt: (prompt: string) => void; + prompt?: string; } const AssistantPromptDetail = () => { @@ -39,25 +40,35 @@ const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => { ); }; -export const createAssistantPromptInteraction = ({ sendPrompt }: AssistantPromptInteractionOptions) => - ({ +export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: AssistantPromptInteractionOptions) => { + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt.length > 0) { + return { + type: "javaScript" as const, + onSelect: () => sendPrompt(trimmedPrompt), + closeSpotlightOnTrigger: true, + }; + } + + return { type: "children", option: {}, DetailComponent: AssistantPromptDetail, useActions: (_options: Record, query: string) => { - const prompt = query.trim(); + const nextPrompt = query.trim(); return [ { key: "send-assistant-prompt", - Component: () => 0} />, + Component: () => 0} />, useInteraction: () => ({ type: "javaScript" as const, onSelect: () => { - if (prompt.length > 0) sendPrompt(prompt); + if (nextPrompt.length > 0) sendPrompt(nextPrompt); }, - closeSpotlightOnTrigger: prompt.length > 0, + closeSpotlightOnTrigger: nextPrompt.length > 0, }), }, ]; }, - }) as const; + } as const; +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 82357fa05..835fe9b10 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Badge, Center, Indicator, Loader, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core"; +import { Badge, Center, Indicator, Kbd, Loader, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { useHotkeys, useTimeout } from "@mantine/hooks"; import { IconBrandDocker, @@ -36,6 +36,12 @@ interface UserAvatarMenuProps { isDockerEnabled?: boolean; } +const formatHotkeyLabel = (hotkey: string) => + hotkey + .split("+") + .map((key) => (key === "mod" ? "Mod" : `${key.charAt(0).toUpperCase()}${key.slice(1)}`)) + .join(" + "); + export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => { const t = useScopedI18n("common.userAvatar.menu"); const { toggleColorScheme } = useMantineColorScheme(); @@ -97,7 +103,9 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab {t("assistantThinking")} - ) : undefined + ) : ( + {formatHotkeyLabel(hotkeys.openAssistant)} + ) } onClick={assistant.open} > diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index d6b377431..f1e589237 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -16,6 +16,7 @@ import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups"; +import { contextSpecificSearchGroups } from "../modes/home/context-specific-group"; import { mediaRequestSearchEvent, mediaRequestSearchScopeAtom, @@ -225,7 +226,9 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh pushChildrenOptions(options); }} query={query} - groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups} + groups={ + mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups + } /> )} diff --git a/packages/spotlight/src/modes/command/context-specific-group.tsx b/packages/spotlight/src/modes/command/context-specific-group.tsx index 7ed445b51..c5cebd219 100644 --- a/packages/spotlight/src/modes/command/context-specific-group.tsx +++ b/packages/spotlight/src/modes/command/context-specific-group.tsx @@ -29,8 +29,8 @@ export const contextSpecificActionsSearchGroups = createGroup ); }, - useInteraction(option) { - return option.interaction(); + useInteraction(option, query) { + return option.interaction(query); }, filter(query, option) { return option.name.toLowerCase().includes(query.toLowerCase()); diff --git a/packages/spotlight/src/modes/home/context-specific-group.spec.ts b/packages/spotlight/src/modes/home/context-specific-group.spec.ts new file mode 100644 index 000000000..a29063676 --- /dev/null +++ b/packages/spotlight/src/modes/home/context-specific-group.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import type { ContextSpecificItem } from "./context"; +import { shouldShowContextSpecificResult } from "./context-specific-group"; + +const item = (overrides: Partial = {}): ContextSpecificItem => ({ + id: "result", + name: "Matching result", + icon: "/icon.png", + interaction: () => ({ type: "none" }), + ...overrides, +}); + +describe("contextSpecificSearchGroups", () => { + test("keeps an always-visible fallback in every default search", () => { + expect(shouldShowContextSpecificResult("unrelated query", item({ alwaysVisible: true }))).toBe(true); + expect(shouldShowContextSpecificResult("", item({ alwaysVisible: true }))).toBe(true); + }); + + test("shows ordinary local results only when their names match", () => { + expect(shouldShowContextSpecificResult("matching", item())).toBe(true); + expect(shouldShowContextSpecificResult("unrelated query", item())).toBe(false); + expect(shouldShowContextSpecificResult("", item())).toBe(false); + }); +}); diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx index a51887966..2978b37d8 100644 --- a/packages/spotlight/src/modes/home/context-specific-group.tsx +++ b/packages/spotlight/src/modes/home/context-specific-group.tsx @@ -4,6 +4,12 @@ import { createGroup } from "../../lib/group"; import type { ContextSpecificItem } from "./context"; import { useSpotlightContextResults } from "./context"; +export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => { + if (option.alwaysVisible) return true; + if (query.length === 0) return false; + return option.name.toLowerCase().includes(query.toLowerCase()); +}; + export const contextSpecificSearchGroups = createGroup({ title: (t) => t("search.mode.home.group.local.title"), keyPath: "id", @@ -29,12 +35,10 @@ export const contextSpecificSearchGroups = createGroup({ ); }, - useInteraction(option) { - return option.interaction(); - }, - filter(query, option) { - return option.name.toLowerCase().includes(query.toLowerCase()); + useInteraction(option, query) { + return option.interaction(query); }, + filter: shouldShowContextSpecificResult, useOptions() { return useSpotlightContextResults(); }, diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx index 76e628cde..3aec94589 100644 --- a/packages/spotlight/src/modes/home/context.tsx +++ b/packages/spotlight/src/modes/home/context.tsx @@ -10,10 +10,11 @@ export type ContextSpecificItem = { id: string; name: string; icon: TablerIcon | string; - interaction: () => inferSearchInteractionDefinition; + interaction: (query: string) => inferSearchInteractionDefinition; disabled?: boolean; description?: string; unavailable?: boolean; + alwaysVisible?: boolean; }; interface SpotlightContextProps { From a5fa6d3afebbf796736c20116c37c20386bc768c Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 09:53:19 +0200 Subject: [PATCH 035/125] fix: restore assistant approval flows --- apps/docs/docs/management/assistant.mdx | 5 +- .../assistant/assistant-human-tools.tsx | 23 +++- .../assistant/assistant-panel.module.css | 22 ++++ .../components/assistant/assistant-panel.tsx | 112 ++++++++++++++++-- .../assistant-pending-action.spec.ts | 93 +++++++++++++++ .../assistant/assistant-pending-action.ts | 45 +++++++ .../assistant/assistant-provider.tsx | 25 ++-- .../assistant-runtime-provider.spec.ts | 80 +++++++++++++ .../assistant/assistant-runtime-provider.tsx | 23 ++++ packages/translation/src/lang/en.json | 7 ++ 10 files changed, 398 insertions(+), 37 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-pending-action.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index cd9b0e796..f82402ae7 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -124,7 +124,10 @@ structured result remains available in the tool card details. When the assistant genuinely needs missing information or a choice, it can pause with a structured question. Users can select one of the proposed answers or choose **Other** and enter their own response. -Submitting the answer resumes the same agent run automatically. +Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the +question or approval that needs attention and opens directly back to the request. While an answer, app +form, or mutation approval is pending, the composer preserves its draft but does not replace the pending +tool call with a new message. App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker, including automatic matching and searches through the `icon.findIcons` procedure, before the final diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 83f8fa01f..e5ea434c3 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -32,6 +32,7 @@ export const AssistantAskUserTool = ({ const t = useScopedI18n("common.assistant.askUser"); const [showOther, setShowOther] = useState(false); const [other, setOther] = useState(""); + const [submitting, setSubmitting] = useState(false); const otherInputRef = useRef(null); const options = Array.isArray(args?.options) ? args.options : []; @@ -69,10 +70,16 @@ export const AssistantAskUserTool = ({ ); } + const submitResult = (answer: AskUserResult) => { + if (submitting) return; + setSubmitting(true); + addResult(answer); + }; + const submitOther = () => { const answer = other.trim(); if (!answer) return; - addResult({ answer, source: "other" }); + submitResult({ answer, source: "other" }); }; return ( @@ -102,7 +109,8 @@ export const AssistantAskUserTool = ({ size="md" fullWidth justify="space-between" - onClick={() => addResult({ answer: option.label, optionId: option.id, source: "option" })} + disabled={submitting} + onClick={() => submitResult({ answer: option.label, optionId: option.id, source: "option" })} > @@ -134,7 +142,7 @@ export const AssistantAskUserTool = ({ label={t("otherLabel")} placeholder={t("otherPlaceholder")} /> - @@ -422,7 +430,12 @@ const ToolPart = ({ fullWidth variant="default" leftSection={} - onClick={() => respondToApproval({ approved: false })} + loading={approvalResponse === "deny"} + disabled={approvalResponse !== null} + onClick={() => { + setApprovalResponse("deny"); + respondToApproval({ approved: false }); + }} > {t("deny")} @@ -1239,7 +1252,7 @@ const ComposerTriggers = () => { type ComposerProps = Pick< AssistantPanelProps, "modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange" ->; +> & { pendingAction: AssistantPendingAction | undefined }; const RuntimeControls = ({ modelId, @@ -1374,11 +1387,18 @@ const RuntimeControls = ({ const Composer = (props: ComposerProps) => { const t = useScopedI18n("common.assistant"); const running = useAuiState((state) => state.thread.isRunning); + const hasPendingAction = props.pendingAction !== undefined; return ( - + { + if (hasPendingAction) event.preventDefault(); + }} + > @@ -1414,7 +1434,13 @@ const Composer = (props: ComposerProps) => { ) : ( - + @@ -1438,12 +1464,55 @@ const Composer = (props: ComposerProps) => { ); }; +const usePendingActionCopy = (action: AssistantPendingAction | undefined) => { + const t = useScopedI18n("common.assistant"); + if (!action) return undefined; + if (action.kind === "question") { + return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") }; + } + if (action.kind === "form") { + return { title: t("pendingAction.formTitle"), detail: action.detail ?? t("configureApp.title") }; + } + const tool = action.toolName.replaceAll("_", " "); + return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool }; +}; + +const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => { + const t = useScopedI18n("common.assistant"); + const copy = usePendingActionCopy(pendingAction); + if (!copy) return null; + + return ( + + + + + + + + {copy.title} + + + {copy.detail} + + + + + + + + ); +}; + const AssistantActivityBar = ({ isRunning, unreadCount, latestAssistantText, latestUserText, latestStatus, + pendingAction, onOpen, onDismissActivity, activityDismissed, @@ -1454,12 +1523,14 @@ const AssistantActivityBar = ({ | "latestAssistantText" | "latestUserText" | "latestStatus" + | "pendingAction" | "onOpen" | "onDismissActivity" | "activityDismissed" >) => { const t = useScopedI18n("common.assistant"); - const needsApproval = latestStatus?.type === "requires-action"; + const pendingCopy = usePendingActionCopy(pendingAction); + const needsApproval = pendingAction !== undefined || latestStatus?.type === "requires-action"; const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled"; const visible = !activityDismissed && @@ -1469,14 +1540,16 @@ const AssistantActivityBar = ({ const title = isRunning ? t("activity.thinking") - : needsApproval - ? t("activity.approval") - : failed - ? t("activity.failed") - : t("activity.ready"); + : pendingCopy + ? pendingCopy.title + : needsApproval + ? t("activity.approval") + : failed + ? t("activity.failed") + : t("activity.ready"); const detail = isRunning ? latestUserText || t("activity.working") - : latestAssistantText || (failed ? t("responseError.description") : t("activity.completed")); + : pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed")); return ( @@ -1512,7 +1585,16 @@ const AssistantActivityBar = ({ {detail} - + {needsApproval ? ( + + + {t("pendingAction.review")} + + + + ) : ( + + )} {!isRunning && !needsApproval && ( @@ -1541,6 +1623,7 @@ export const AssistantPanel = ({ latestAssistantText, latestUserText, latestStatus, + pendingAction, modelId, models, modelOptionsLoading, @@ -1584,6 +1667,7 @@ export const AssistantPanel = ({ latestAssistantText={latestAssistantText} latestUserText={latestUserText} latestStatus={latestStatus} + pendingAction={pendingAction} /> )} {opened && ( @@ -1654,6 +1738,7 @@ export const AssistantPanel = ({ + diff --git a/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts b/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts new file mode 100644 index 000000000..3241d0538 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import type { ThreadMessage } from "@assistant-ui/react"; + +import { getPendingAssistantAction } from "./assistant-pending-action"; + +const assistantMessage = (...content: ThreadMessage["content"]): ThreadMessage => + ({ + id: "assistant-message", + role: "assistant", + content, + status: { type: "requires-action", reason: "tool-calls" }, + createdAt: new Date(), + metadata: { custom: {} }, + }) as ThreadMessage; + +describe("getPendingAssistantAction", () => { + test("exposes an unanswered structured question", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "ask-1", + toolName: "ask_user", + args: { question: "Which board should receive the widget?" }, + argsText: "{}", + }), + ), + ).toEqual({ + kind: "question", + toolName: "ask_user", + detail: "Which board should receive the widget?", + }); + }); + + test("clears a human action after its result is supplied", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "ask-1", + toolName: "ask_user", + args: { question: "Continue?" }, + argsText: "{}", + result: { answer: "Yes" }, + }), + ), + ).toBeUndefined(); + }); + + test("exposes the app form while its details still need review", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "configure-1", + toolName: "configure_app", + args: { name: "Wikipedia" }, + argsText: "{}", + }), + ), + ).toEqual({ kind: "form", toolName: "configure_app", detail: "Wikipedia" }); + }); + + test("exposes an unresolved mutation approval", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "create-1", + toolName: "app_create", + args: { name: "Wikipedia" }, + argsText: "{}", + approval: { id: "approval-1" }, + }), + ), + ).toEqual({ kind: "approval", toolName: "app_create", detail: "Wikipedia" }); + }); + + test("clears an approval after the user responds", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "create-1", + toolName: "app_create", + args: { name: "Wikipedia" }, + argsText: "{}", + approval: { id: "approval-1", approved: true }, + }), + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-pending-action.ts b/apps/nextjs/src/components/assistant/assistant-pending-action.ts new file mode 100644 index 000000000..41aae34df --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-pending-action.ts @@ -0,0 +1,45 @@ +import type { ThreadMessage } from "@assistant-ui/react"; + +export type AssistantPendingAction = { + kind: "question" | "form" | "approval"; + toolName: string; + detail?: string; +}; + +const humanToolKinds = { + ask_user: "question", + configure_app: "form", +} as const; + +const getStringArg = (args: unknown, key: string) => { + if (typeof args !== "object" || args === null || !(key in args)) return undefined; + const value = (args as Record)[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +}; + +export const getPendingAssistantAction = (message: ThreadMessage | undefined): AssistantPendingAction | undefined => { + if (message?.role !== "assistant") return undefined; + + for (const part of message.content.toReversed()) { + if (part.type !== "tool-call" || part.isError) continue; + + if (part.approval && part.approval.approved === undefined && !part.approval.resolution) { + return { + kind: "approval", + toolName: part.toolName, + detail: getStringArg(part.args, "name"), + }; + } + + const kind = humanToolKinds[part.toolName as keyof typeof humanToolKinds]; + if (kind === undefined || part.result !== undefined) continue; + + return { + kind, + toolName: part.toolName, + detail: getStringArg(part.args, kind === "question" ? "question" : "name"), + }; + } + + return undefined; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 360c13a9c..10477fee8 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -11,15 +11,7 @@ import type { ThreadMessage, Toolkit, } from "@assistant-ui/react"; -import { - AssistantRuntimeProvider, - defineToolkit, - Tools, - useAui, - useAuiEvent, - useAuiState, - useRemoteThreadListRuntime, -} from "@assistant-ui/react"; +import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react"; import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk"; import { useHotkeys } from "@mantine/hooks"; import { createAssistantStream } from "assistant-stream"; @@ -35,7 +27,9 @@ import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResul import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit"; import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools"; import { AssistantPanel } from "./assistant-panel"; +import { getPendingAssistantAction } from "./assistant-pending-action"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; +import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"; import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send"; import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { browserToolContracts } from "./assistant-tool-contracts"; @@ -461,14 +455,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => { }) as Toolkit, [router], ); - return ( - - + {children} - + ); }; @@ -495,11 +487,6 @@ const AssistantPreferenceSync = () => { return null; }; -const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => { - useAui({ tools: Tools({ toolkit }) }); - return null; -}; - const AssistantRuntimeEvents = () => { const t = useScopedI18n("common.assistant"); useAuiEvent("composer.attachmentAddError", ({ message }) => { @@ -543,6 +530,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const latestAssistantText = getMessageText(latestAssistantMessage); const latestUserText = getMessageText(latestUserMessage); const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined; + const pendingAction = getPendingAssistantAction(latestAssistantMessage); const assistantIsRunning = isRunning || queuedPrompt !== null; const notificationKey = getNotificationKey(latestAssistantMessage); const initializedRef = useRef(false); @@ -662,6 +650,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { latestAssistantText={latestAssistantText} latestUserText={queuedPrompt ?? latestUserText} latestStatus={latestStatus} + pendingAction={pendingAction} modelId={preferences.modelId} models={preferences.models} modelOptionsLoading={preferences.isLoading} diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts new file mode 100644 index 000000000..b5783a197 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react"; +import { defineToolkit, MessagePrimitive, ThreadPrimitive, useLocalRuntime } from "@assistant-ui/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { z } from "zod/v4"; + +import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const noOpAdapter: ChatModelAdapter = { async *run() {} }; +const toolkit = defineToolkit({ + ask_user: { + type: "human", + display: "standalone", + parameters: z.object({ question: z.string() }), + render: ({ args }) => createElement("div", { "data-testid": "ask-user-ui" }, `Question: ${args.question}`), + }, +}); +const messages: ThreadMessageLike[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "ask-1", + toolName: "ask_user", + args: { question: "Which board?" }, + argsText: JSON.stringify({ question: "Which board?" }), + }, + ], + status: { type: "requires-action", reason: "tool-calls" }, + }, +]; + +const Message = () => + createElement(MessagePrimitive.Parts, { + components: { + tools: { + Fallback: () => createElement("div", { "data-testid": "fallback-tool-ui" }, "Fallback"), + }, + }, + }); + +const TestAssistant = () => { + const runtime = useLocalRuntime(noOpAdapter, { initialMessages: messages }); + return createElement( + AssistantRuntimeProviderWithTools, + { runtime, toolkit }, + createElement(ThreadPrimitive.Messages, { components: { Message } }), + ); +}; + +const containers: HTMLDivElement[] = []; + +afterEach(() => { + for (const container of containers.splice(0)) container.remove(); +}); + +describe("AssistantRuntimeProviderWithTools", () => { + test("makes the registered human-tool renderer available to message parts", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render(createElement(TestAssistant)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(container.querySelector('[data-testid="ask-user-ui"]')?.textContent).toBe("Question: Which board?"); + expect(container.querySelector('[data-testid="fallback-tool-ui"]')).toBeNull(); + + await act(async () => root.unmount()); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx new file mode 100644 index 000000000..fb6e74f14 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import type { AssistantRuntime, Toolkit } from "@assistant-ui/react"; +import { AssistantRuntimeProvider, Tools, useAui } from "@assistant-ui/react"; + +interface AssistantRuntimeProviderWithToolsProps extends PropsWithChildren { + runtime: AssistantRuntime; + toolkit: Toolkit; +} + +export const AssistantRuntimeProviderWithTools = ({ + children, + runtime, + toolkit, +}: AssistantRuntimeProviderWithToolsProps) => { + const toolsAui = useAui({ tools: Tools({ toolkit }) }); + return ( + + {children} + + ); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index beeab65ae..13d89b1c7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1637,6 +1637,13 @@ "title": "The assistant is already working", "description": "Wait for the current response or open it to stop the run." }, + "pendingAction": { + "answerTitle": "Answer needed", + "answerFallback": "Choose an answer to continue", + "formTitle": "Review app details", + "review": "Review", + "sendBlocked": "Resolve the pending request before sending" + }, "activity": { "thinking": "Assistant is thinking", "working": "Working on your prompt", From 5458e7d647f0c44f148d00ef5ce04dcd1ec199e6 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 10:39:41 +0200 Subject: [PATCH 036/125] feat: add assistant board styling tools --- apps/docs/docs/management/assistant.mdx | 12 + apps/docs/docs/management/boards/index.mdx | 6 +- apps/docs/docs/management/mcp.mdx | 2 +- .../chat/assistant-tool-policy.spec.ts | 30 ++ .../assistant/chat/assistant-tool-policy.ts | 20 +- .../src/app/api/assistant/chat/route.ts | 1 + .../assistant-board-settings-tool.tsx | 401 ++++++++++++++++++ .../assistant-board-settings.spec.ts | 29 ++ .../assistant/assistant-board-settings.ts | 33 ++ .../assistant/assistant-panel.module.css | 50 ++- .../components/assistant/assistant-panel.tsx | 20 +- .../assistant-pending-action.spec.ts | 14 + .../assistant/assistant-pending-action.ts | 6 +- .../assistant/assistant-provider.tsx | 7 + .../assistant-tool-contracts.spec.ts | 30 ++ .../assistant/assistant-tool-contracts.ts | 49 +++ packages/api/src/router/board.ts | 52 +++ packages/api/src/router/test/board.spec.ts | 22 + packages/api/src/test/mcp.spec.ts | 1 + packages/translation/src/lang/en.json | 39 +- packages/validation/src/board.ts | 5 + 21 files changed, 811 insertions(+), 18 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-board-settings.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-board-settings.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index f82402ae7..8d133f5fa 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -133,6 +133,18 @@ App creation uses Homarr's native app form inside the conversation. The form reu including automatic matching and searches through the `icon.findIcons` procedure, before the final `app.create` approval is requested. The model is instructed not to invent external icon CDN URLs. +Board appearance changes use a similar review flow. The assistant first reads the board's current settings, +including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance, +Background, and General tabs. The form highlights how many fields were proposed, preserves settings the +assistant did not change, and returns only values that differ from the current board. Custom CSS can be +copied or restored before continuing, and Homarr warns when it contains an imported stylesheet or remote +resource URL. Continuing from the form opens the normal mutation approval before anything is saved. + +Ask for changes such as “make the cards denser on my Home board,” “add a mobile CSS rule without removing +my existing styles,” or “change the board colors and disable app status checks.” The current user must have +modify permission on the selected board. The empty conversation includes an **Improve a dashboard** starter, +and `/style` prepares the same guided workflow from the composer. + 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. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..07323fc12 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -142,6 +142,10 @@ Change the radius of your items / categories. This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. +When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom +CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an +editable review form before requesting approval to save the change. + [See this guide on how to work with custom CSS](/docs/advanced/styling/). ### Behaviour @@ -255,4 +259,4 @@ For the global home board you can only select boards that are public. ### Mobile home board Same as the home board, you can also set a mobile home board. -It will be used for devices that are detected as mobile devices. (Using User-Agent header) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index d5233ad74..540988f29 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -129,7 +129,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** | `app` | List all apps, get app by ID, create/update/delete app | GET + POST | | `apiKeys` | List API keys | GET | | `beszel` | Systems status, alerts, historical system metrics | GET | -| `board` | List boards, get board by ID, create/update/delete board | GET + POST | +| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST | | `docker` | List containers, inspect container, start/stop/restart | GET + POST | | `icon` | Search icons | GET | | `info` | Server health, version info | GET | diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts index 0fb365986..c00845b72 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts @@ -57,6 +57,36 @@ describe("getForcedAssistantToolName", () => { ).toBeUndefined(); }); + test("continues directly from reviewed board settings to the settings mutation", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_board_settings", + toolCallId: "settings-1", + input: { boardId: "board-1", boardName: "Home", changes: { customCss: ".item {}" } }, + state: "output-available", + output: { id: "board-1", customCss: ".item {}" }, + }), + ]), + ).toBe("board_savePartialBoardSettings"); + }); + + test("does not request a mutation when board settings were left unchanged", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_board_settings", + toolCallId: "settings-1", + input: { boardId: "board-1", boardName: "Home", changes: {} }, + state: "output-available", + output: { id: "board-1", cancelled: true }, + }), + ]), + ).toBeUndefined(); + }); + test("does not repeat app creation after the mutation tool has been called", () => { expect( getForcedAssistantToolName([ diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts index 56975c90b..476869cce 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts @@ -14,13 +14,23 @@ export const getForcedAssistantToolName = (messages: UIMessage[]) => { if (latestMessage?.role !== "assistant") return undefined; const latestToolPart = latestMessage.parts.toReversed().find((part) => isToolUIPart(part)); + if (latestToolPart === undefined || latestToolPart.state !== "output-available") return undefined; + + const nextToolByHumanTool = { + configure_app: "app_create", + configure_board_settings: "board_savePartialBoardSettings", + } as const; + const toolName = getToolName(latestToolPart); if ( - latestToolPart === undefined || - getToolName(latestToolPart) !== "configure_app" || - latestToolPart.state !== "output-available" + toolName === "configure_board_settings" && + typeof latestToolPart.output === "object" && + latestToolPart.output !== null && + "cancelled" in latestToolPart.output && + latestToolPart.output.cancelled === true ) { return undefined; } - - return "app_create" as const; + return toolName in nextToolByHumanTool + ? nextToolByHumanTool[toolName as keyof typeof nextToolByHumanTool] + : undefined; }; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index e7dd274e0..93a1da594 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -94,6 +94,7 @@ Action rules: - A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call. - Do not retry a denied action. - Before creating an app, call configure_app with the best defaults so the user can review Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. +- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement. - When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL. - Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL. - Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON. diff --git a/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx new file mode 100644 index 000000000..ca3c25012 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { + Alert, + Badge, + Box, + Button, + ColorInput, + CopyButton, + Group, + InputWrapper, + Select, + SimpleGrid, + Skeleton, + Slider, + Stack, + Switch, + Tabs, + Text, + Textarea, + TextInput, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconCheck, + IconCopy, + IconFileTypeCss, + IconPalette, + IconPhoto, + IconRefresh, + IconSettings, +} from "@tabler/icons-react"; +import type { z } from "zod/v4"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; +import { useZodForm } from "@homarr/form"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; + +import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings"; +import classes from "./assistant-panel.module.css"; +import type { + AssistantBoardSettingsChanges, + ConfigureBoardSettingsArgs, + ConfigureBoardSettingsResult, +} from "./assistant-tool-contracts"; + +type BoardSettings = RouterOutputs["board"]["getBoardSettings"]; +const completeSettingsSchema = boardSavePartialSettingsSchema.required(); +type BoardSettingsFormValues = z.infer; + +const normalizeFormValues = ( + current: BoardSettings, + proposed: AssistantBoardSettingsChanges, +): BoardSettingsFormValues => { + const values = { ...current, ...proposed }; + return { + pageTitle: values.pageTitle ?? "", + metaTitle: values.metaTitle ?? "", + logoImageUrl: values.logoImageUrl ?? "", + faviconImageUrl: values.faviconImageUrl ?? "", + backgroundImageUrl: values.backgroundImageUrl ?? "", + backgroundImageAttachment: values.backgroundImageAttachment, + backgroundImageRepeat: values.backgroundImageRepeat, + backgroundImageSize: values.backgroundImageSize, + primaryColor: values.primaryColor, + secondaryColor: values.secondaryColor, + opacity: values.opacity, + customCss: values.customCss, + iconColor: values.iconColor ?? "", + itemRadius: values.itemRadius, + disableStatus: values.disableStatus, + }; +}; + +const toCurrentValues = (current: BoardSettings): BoardSettingsFormValues => normalizeFormValues(current, {}); + +export const AssistantConfigureBoardSettingsTool = ({ + args, + result, + addResult, +}: ToolCallMessagePartProps) => { + const t = useScopedI18n("common.assistant.configureBoardSettings"); + const boardId = args?.boardId ?? ""; + const settings = clientApi.board.getBoardSettings.useQuery( + { id: boardId }, + { enabled: result === undefined && boardId.length > 0, retry: false }, + ); + + if (result) { + const cancelled = "cancelled" in result && result.cancelled; + const changedCount = cancelled ? 0 : Math.max(0, Object.keys(result).length - 1); + return ( + + + + + + + {cancelled ? t("cancelled") : t("ready")} + + + {cancelled ? args?.boardName : t("changeCount", { count: changedCount })} + + + + ); + } + + if (!args?.boardId || !args.boardName) { + return ; + } + + if (settings.isError) { + return ( + }> + + {t("loadErrorDescription")} + + + + ); + } + + if (!settings.data) return ; + + return ( + addResult({ id: settings.data.id, ...changes })} + onCancel={() => addResult({ id: settings.data.id, cancelled: true })} + /> + ); +}; + +const BoardSettingsSkeleton = ({ label }: { label: string }) => ( + + + + + + + + +); + +const BoardSettingsForm = ({ + current, + args, + onSubmit, + onCancel, +}: { + current: BoardSettings; + args: ConfigureBoardSettingsArgs; + onSubmit: (changes: AssistantBoardSettingsChanges) => void; + onCancel: () => void; +}) => { + const t = useScopedI18n("common.assistant.configureBoardSettings"); + const boardT = useI18n(); + const [submitting, setSubmitting] = useState(false); + const currentValues = useMemo(() => toCurrentValues(current), [current]); + const form = useZodForm(completeSettingsSchema, { + initialValues: normalizeFormValues(current, args.changes), + }); + const changes = getChangedBoardSettings(currentValues, form.values); + const changedCount = Object.keys(changes).length; + const cssWarnings = getCustomCssWarnings(form.values.customCss); + const proposedKeys = Object.keys(args.changes); + const defaultTab = + args.changes.customCss !== undefined ? "css" : proposedKeys.some(isAppearanceKey) ? "appearance" : "general"; + const optionData = ( + values: readonly TValue[], + key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize", + ) => + values.map((value) => ({ + value, + label: boardT(`board.field.${key}.option.${value}.label` as never), + })); + + return ( + + + + + + + + {t("title")} + + {args.boardName} + + + + {args.summary} + + {proposedKeys.length > 0 && ( + + {t("proposedCount", { count: proposedKeys.length })} + + )} + + + +
{ + if (submitting) return; + const nextChanges = getChangedBoardSettings(currentValues, values); + setSubmitting(true); + if (Object.keys(nextChanges).length === 0) { + onCancel(); + return; + } + onSubmit(nextChanges); + })} + > + + + }> + {t("tabs.css")} + + }> + {t("tabs.appearance")} + + }> + {t("tabs.background")} + + }> + {t("tabs.general")} + + + + + +