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")} + + + + + +