feat: add assistant board styling tools

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 07547a462c
commit 6dc9a565b0
20 files changed
+1045 -157

No files matched your search

+12
View File
@@ -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.
@@ -247,6 +247,10 @@ Change the radius of your items and Containers.
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
+77 -42
View File
@@ -8,7 +8,12 @@ tags:
# Model Context Protocol (MCP)
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
The endpoint implements the stateless MCP 2026-07-28 protocol and accepts 2025 Streamable HTTP clients through a
stateless compatibility path. It does not create protocol sessions or require sticky sessions. Homarr creates the
server and extracts its tools only after an authenticated request reaches the route. The previous `/api/mcp/mcp` URL
remains available as a compatibility alias, but new clients should always use `/api/mcp`.
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.
@@ -46,7 +51,7 @@ Add the following to your client's MCP configuration file:
{
"mcpServers": {
"homarr": {
"url": "https://your-homarr-instance.com/api/mcp/mcp",
"url": "https://your-homarr-instance.com/api/mcp",
"headers": {
"ApiKey": "<your-api-key>"
}
@@ -77,7 +82,7 @@ Replace `<your-api-key>` 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:<your-api-key>"]
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp", "--header", "ApiKey:<your-api-key>"]
}
}
}
@@ -100,6 +105,10 @@ Custom Widget and Workshop authoring are different: their tools, prompts, and re
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
API key never elevates the owner.
OAuth authorization and token requests bind issued credentials to the canonical `/api/mcp` resource. Dynamic client
registration remains available for compatibility with current MCP clients while the ecosystem transitions toward
pre-registered clients and Client ID Metadata Documents.
## Testing the Connection
Verify your MCP endpoint is working:
@@ -107,13 +116,17 @@ Verify your MCP endpoint is working:
```bash
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Accept: application/json" \
-H "ApiKey: <your-api-key>" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \
https://your-homarr-instance.com/api/mcp/mcp
-H "Mcp-Method: server/discover" \
-H "Mcp-Name: homarr" \
-d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' \
https://your-homarr-instance.com/api/mcp
```
A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools:
A successful response includes the supported protocol versions and server capabilities. Use `tools/list` to see all
available tools. MCP v2 clients add the per-request metadata and `Mcp-Method` / `Mcp-Name` headers automatically; the
abbreviated request below uses the stateless 2025 compatibility path for readability:
```bash
curl -s -X POST \
@@ -121,33 +134,36 @@ curl -s -X POST \
-H "Accept: application/json, text/event-stream" \
-H "ApiKey: <your-api-key>" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
https://your-homarr-instance.com/api/mcp/mcp
https://your-homarr-instance.com/api/mcp
```
Tool catalogs are returned in deterministic order and may be cached privately by the client for five minutes. The
removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`; use the single Streamable HTTP endpoint instead.
## Available Tools
Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action).
| Namespace | Example Tools | Type |
| ------------------ | --------------------------------------------------------- | ---------- |
| `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 |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
| `info` | Server health, version info | GET |
| `integration` | List integrations, test connection, delete integration | GET + POST |
| `invite` | List invitations, delete invite | GET + POST |
| `calendar` | Calendar events from media servers | GET |
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
| Namespace | Example Tools | Type |
| ------------------ | ---------------------------------------------------------- | ---------- |
| `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, 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 |
| `integration` | List integrations, test connection, delete integration | GET + POST |
| `invite` | List invitations, delete invite | GET + POST |
| `calendar` | Calendar events from media servers | GET |
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance.
@@ -179,21 +195,36 @@ An agent creates one `homarr-custom-widget-v2` definition at a time. Start with
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
secrets, publication, Workshop installation, and the HTTP authoring resources.
Then, read the live schema and component resources. These resources describe the installed Mantine components.
Load the installed skill before authoring. `customWidget_getSkill` returns the complete `SKILL.md` entrypoint and every
bundled reference in one response, so an MCP client does not need filesystem access or a second documentation fetch.
Then read the live schema and only the component resources required by the design. These resources describe the
installed Mantine components.
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
2. Build one Custom Widget definition.
3. Call `customWidget_validate` without saving the definition.
4. Call `customWidget_previewCreate`.
5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
6. Open the returned preview URL.
7. Examine `customWidget_previewJournal`.
8. Revise the local candidate and create another preview session until it is ready.
9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
13. Repeat this procedure for the next Custom Widget.
1. Call `customWidget_getSkill`, `customWidget_getAuthoringPrompt`, `customWidget_schema`, and
`customWidget_getComponentCatalog`.
2. Fetch only the relevant installed component documentation with `customWidget_getComponent` and, when useful,
one interaction example with `customWidget_getExample`. Component results contain component-specific props; collect
any needed shared prop names from the compact catalog and fetch them together once with `customWidget_getSharedProps`.
3. Build one Custom Widget definition. Use `templateLines` for generated multiline JSX to avoid a fragile JSON-escaped
template string.
4. Call `customWidget_validate` without saving the definition and repair every returned issue path.
5. Call `customWidget_previewCreate`.
6. Call `customWidget_previewQuery` for every entry in the returned `queries` list. Supply the listed invocation
parameters for manual queries and inspect the real HTTP status and response shape before binding template fields.
7. Test relevant actions with `customWidget_previewAction` in simulation mode.
8. Open the returned `previewPath` or `previewUrl`.
9. Examine `customWidget_previewJournal`.
10. Revise the local candidate and create another preview session until validation, query data, actions, and rendering
are ready.
11. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
12. Open the returned `managementPath` to review the exact saved definition.
13. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
14. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
15. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
16. Repeat this procedure for the next Custom Widget.
Validation and preview creation return compact lifecycle metadata. They do not echo the submitted widget or duplicate
its template as both a string and line array; the agent should retain and reuse its local candidate.
The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist.
@@ -229,6 +260,10 @@ The Custom Widget tools are:
- `customWidget_workshopGet`
- `customWidget_workshopInstall`
- `customWidget_getAuthoringPrompt`
- `customWidget_getComponentCatalog`
- `customWidget_getComponent`
- `customWidget_getSharedProps`
- `customWidget_getExample`
- `customWidget_getSkill`
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
@@ -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([
@@ -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;
};
@@ -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.
@@ -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<typeof completeSettingsSchema>;
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<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
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 (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color="green">
<IconCheck size={13} />
</ThemeIcon>
<Box miw={0}>
<Text size="xs" c="dimmed">
{cancelled ? t("cancelled") : t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{cancelled ? args?.boardName : t("changeCount", { count: changedCount })}
</Text>
</Box>
</Box>
);
}
if (!args?.boardId || !args.boardName) {
return <BoardSettingsSkeleton label={t("loading")} />;
}
if (settings.isError) {
return (
<Alert color="red" variant="light" title={t("loadErrorTitle")} icon={<IconAlertTriangle size={18} />}>
<Stack gap="sm">
<Text size="sm">{t("loadErrorDescription")}</Text>
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={() => settings.refetch()}>
{t("retry")}
</Button>
</Stack>
</Alert>
);
}
if (!settings.data) return <BoardSettingsSkeleton label={t("loading")} />;
return (
<BoardSettingsForm
current={settings.data}
args={args}
onSubmit={(changes) => addResult({ id: settings.data.id, ...changes })}
onCancel={() => addResult({ id: settings.data.id, cancelled: true })}
/>
);
};
const BoardSettingsSkeleton = ({ label }: { label: string }) => (
<Box className={classes.boardSettingsTool} aria-label={label}>
<Stack gap="sm">
<Skeleton height={20} width="45%" />
<Skeleton height={14} width="78%" />
<Skeleton height={38} />
<Skeleton height={180} />
</Stack>
</Box>
);
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 = <TValue extends string>(
values: readonly TValue[],
key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize",
) =>
values.map((value) => ({
value,
label: boardT(`board.field.${key}.option.${value}.label` as never),
}));
return (
<Box className={classes.boardSettingsTool}>
<Group className={classes.boardSettingsHeader} align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">
<IconSettings size={18} />
</ThemeIcon>
<Box flex={1} miw={0}>
<Group gap="xs" wrap="wrap">
<Text fw={700}>{t("title")}</Text>
<Badge variant="light" color="gray" size="sm">
{args.boardName}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={3}>
{args.summary}
</Text>
{proposedKeys.length > 0 && (
<Text size="xs" c="dimmed" mt="xs">
{t("proposedCount", { count: proposedKeys.length })}
</Text>
)}
</Box>
</Group>
<form
onSubmit={form.onSubmit((values) => {
if (submitting) return;
const nextChanges = getChangedBoardSettings(currentValues, values);
setSubmitting(true);
if (Object.keys(nextChanges).length === 0) {
onCancel();
return;
}
onSubmit(nextChanges);
})}
>
<Tabs defaultValue={defaultTab} mt="md" className={classes.boardSettingsTabs}>
<Tabs.List grow>
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
{t("tabs.css")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
{t("tabs.appearance")}
</Tabs.Tab>
<Tabs.Tab value="background" leftSection={<IconPhoto size={15} />}>
{t("tabs.background")}
</Tabs.Tab>
<Tabs.Tab value="general" leftSection={<IconSettings size={15} />}>
{t("tabs.general")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="css" pt="md">
<Stack gap="sm">
<Textarea
label={boardT("board.field.customCss.label")}
description={t("cssDescription")}
autosize
minRows={10}
maxRows={20}
classNames={{ input: classes.boardCssEditor }}
{...form.getInputProps("customCss")}
/>
<Group justify="space-between" gap="xs" wrap="wrap">
<Text size="xs" c={form.values.customCss.length > 15_500 ? "orange" : "dimmed"}>
{t("cssCharacters", { count: form.values.customCss.length })}
</Text>
<Group gap="xs">
<CopyButton value={form.values.customCss}>
{({ copied, copy }) => (
<Tooltip label={copied ? t("copied") : t("copyCss")}>
<Button
variant="subtle"
size="compact-sm"
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? t("copied") : t("copy")}
</Button>
</Tooltip>
)}
</CopyButton>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconRefresh size={14} />}
disabled={form.values.customCss === currentValues.customCss}
onClick={() => form.setFieldValue("customCss", currentValues.customCss)}
>
{t("restoreCss")}
</Button>
</Group>
</Group>
{(cssWarnings.importsStylesheet || cssWarnings.loadsRemoteResource) && (
<Alert
variant="light"
color="yellow"
icon={<IconAlertTriangle size={18} />}
title={t("remoteCssTitle")}
>
{t("remoteCssDescription")}
</Alert>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="appearance" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<ColorInput
label={boardT("board.field.primaryColor.label")}
format="hex"
{...form.getInputProps("primaryColor")}
/>
<ColorInput
label={boardT("board.field.secondaryColor.label")}
format="hex"
{...form.getInputProps("secondaryColor")}
/>
<ColorInput
label={boardT("board.field.iconColor.label")}
format="hex"
{...form.getInputProps("iconColor")}
/>
<Select
label={boardT("board.field.itemRadius.label")}
data={(["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
value,
label: boardT(`board.field.itemRadius.option.${value}`),
}))}
{...form.getInputProps("itemRadius")}
/>
</SimpleGrid>
<InputWrapper label={boardT("board.field.opacity.label")}>
<Slider min={0} max={100} step={5} label={(value) => `${value}%`} {...form.getInputProps("opacity")} />
</InputWrapper>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="background" pt="md">
<Stack gap="md">
<TextInput
label={boardT("board.field.backgroundImageUrl.label")}
placeholder={boardT("board.field.backgroundImageUrl.placeholder")}
{...form.getInputProps("backgroundImageUrl")}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Select
label={boardT("board.field.backgroundImageSize.label")}
data={optionData(backgroundImageSizes.values, "backgroundImageSize")}
{...form.getInputProps("backgroundImageSize")}
/>
<Select
label={boardT("board.field.backgroundImageRepeat.label")}
data={optionData(backgroundImageRepeats.values, "backgroundImageRepeat")}
{...form.getInputProps("backgroundImageRepeat")}
/>
<Select
label={boardT("board.field.backgroundImageAttachment.label")}
data={optionData(backgroundImageAttachments.values, "backgroundImageAttachment")}
{...form.getInputProps("backgroundImageAttachment")}
/>
</SimpleGrid>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="general" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput label={boardT("board.field.pageTitle.label")} {...form.getInputProps("pageTitle")} />
<TextInput label={boardT("board.field.metaTitle.label")} {...form.getInputProps("metaTitle")} />
<TextInput label={boardT("board.field.logoImageUrl.label")} {...form.getInputProps("logoImageUrl")} />
<TextInput
label={boardT("board.field.faviconImageUrl.label")}
{...form.getInputProps("faviconImageUrl")}
/>
</SimpleGrid>
<Switch
label={boardT("board.field.disableStatus.label")}
description={boardT("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</Stack>
</Tabs.Panel>
</Tabs>
<Group className={classes.boardSettingsActions} justify="space-between" gap="sm" mt="md" wrap="wrap">
<Button
variant="subtle"
color="gray"
leftSection={<IconRefresh size={15} />}
disabled={changedCount === 0 || submitting}
onClick={() => form.setValues(currentValues)}
>
{t("resetAll")}
</Button>
<Group gap="sm" wrap="nowrap" className={classes.boardSettingsSubmitGroup}>
<Text size="xs" c="dimmed">
{changedCount === 0 ? t("noChanges") : t("changeCount", { count: changedCount })}
</Text>
<Button type="submit" loading={submitting}>
{changedCount === 0 ? t("keepCurrent") : t("continue")}
</Button>
</Group>
</Group>
</form>
</Box>
);
};
const isAppearanceKey = (key: string) =>
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(key);
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings";
describe("getChangedBoardSettings", () => {
test("returns only changed settings and treats empty nullable values equally", () => {
expect(
getChangedBoardSettings(
{ pageTitle: null, primaryColor: "#112233", opacity: 85 },
{ pageTitle: "", primaryColor: "#445566", opacity: 85 },
),
).toEqual({ primaryColor: "#445566" });
});
test("keeps an intentional custom CSS removal", () => {
expect(getChangedBoardSettings({ customCss: ".item { color: red; }" }, { customCss: "" })).toEqual({
customCss: "",
});
});
});
describe("getCustomCssWarnings", () => {
test("detects imported and remote CSS resources", () => {
expect(getCustomCssWarnings('@import "theme.css"; .item { background: url(https://example.com/a.png); }')).toEqual({
importsStylesheet: true,
loadsRemoteResource: true,
});
});
});
@@ -0,0 +1,33 @@
import type { AssistantBoardSettingsChanges } from "./assistant-tool-contracts";
export const assistantBoardSettingKeys = [
"pageTitle",
"metaTitle",
"logoImageUrl",
"faviconImageUrl",
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
"primaryColor",
"secondaryColor",
"opacity",
"customCss",
"iconColor",
"itemRadius",
"disableStatus",
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
const comparableValue = (value: unknown) => (value === null ? "" : value);
export const getChangedBoardSettings = (current: AssistantBoardSettingsChanges, next: AssistantBoardSettingsChanges) =>
Object.fromEntries(
assistantBoardSettingKeys.flatMap((key) =>
comparableValue(current[key]) === comparableValue(next[key]) ? [] : [[key, next[key]]],
),
) as AssistantBoardSettingsChanges;
export const getCustomCssWarnings = (css: string) => ({
importsStylesheet: /@import\b/iu.test(css),
loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css),
});
@@ -55,6 +55,43 @@
height: 2.5rem;
}
.boardSettingsTool {
padding: 0.9rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
}
.boardSettingsHeader {
min-width: 0;
}
.boardSettingsTabs :global(.mantine-Tabs-list) {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.boardSettingsTabs :global(.mantine-Tabs-tab) {
min-width: max-content;
}
.boardCssEditor {
line-height: 1.55;
tab-size: 2;
font-family: var(--mantine-font-family-monospace);
font-size: var(--mantine-font-size-xs);
}
.boardSettingsActions {
padding-top: 0.8rem;
border-top: 1px solid var(--mantine-color-default-border);
}
.boardSettingsSubmitGroup {
margin-inline-start: auto;
}
.floatingPanel {
position: fixed;
z-index: 290;
@@ -1049,9 +1086,20 @@
}
.humanTool,
.appTool {
.appTool,
.boardSettingsTool {
padding: 0.8rem;
}
.boardSettingsActions,
.boardSettingsSubmitGroup {
width: 100%;
}
.boardSettingsSubmitGroup {
justify-content: space-between;
margin-inline-start: 0;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -68,13 +68,13 @@ import {
IconChevronUp,
IconCopy,
IconDownload,
IconCommand,
IconFile,
IconFileExport,
IconLink,
IconMessage,
IconMinus,
IconPaperclip,
IconPalette,
IconPencil,
IconHistory,
IconPlayerStop,
@@ -1061,9 +1061,9 @@ const EmptyThread = () => {
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.command.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconCommand size={18} />}>
{t("suggestions.command.label")}
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
@@ -1189,6 +1189,12 @@ const ComposerTriggers = () => {
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
{
id: "style",
label: "/style",
description: t("commands.style"),
execute: () => aui.composer().setText(t("suggestions.style.prompt")),
},
],
});
@@ -1471,7 +1477,11 @@ const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
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 isBoardSettings = action.toolName === "configure_board_settings";
return {
title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"),
detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")),
};
}
const tool = action.toolName.replaceAll("_", " ");
return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool };
@@ -61,6 +61,20 @@ describe("getPendingAssistantAction", () => {
).toEqual({ kind: "form", toolName: "configure_app", detail: "Wikipedia" });
});
test("exposes the board settings form while changes still need review", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "settings-1",
toolName: "configure_board_settings",
args: { boardId: "board-1", boardName: "Home", changes: { customCss: ".item {}" } },
argsText: "{}",
}),
),
).toEqual({ kind: "form", toolName: "configure_board_settings", detail: "Home" });
});
test("exposes an unresolved mutation approval", () => {
expect(
getPendingAssistantAction(
@@ -9,6 +9,7 @@ export type AssistantPendingAction = {
const humanToolKinds = {
ask_user: "question",
configure_app: "form",
configure_board_settings: "form",
} as const;
const getStringArg = (args: unknown, key: string) => {
@@ -37,7 +38,10 @@ export const getPendingAssistantAction = (message: ThreadMessage | undefined): A
return {
kind,
toolName: part.toolName,
detail: getStringArg(part.args, kind === "question" ? "question" : "name"),
detail: getStringArg(
part.args,
kind === "question" ? "question" : part.toolName === "configure_board_settings" ? "boardName" : "name",
),
};
}
@@ -33,6 +33,7 @@ import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
import type { AssistantUIMessage } from "./assistant-message-metadata";
interface AssistantContextValue {
@@ -412,6 +413,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
...browserToolContracts.configure_app,
render: AssistantConfigureAppTool,
},
configure_board_settings: {
type: "human",
display: "standalone",
...browserToolContracts.configure_board_settings,
render: AssistantConfigureBoardSettingsTool,
},
navigate_to_route: {
type: "frontend",
...browserToolContracts.navigate_to_route,
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { browserToolContracts, normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
@@ -34,6 +35,35 @@ describe("assistant human tool contracts", () => {
).toBe(true);
});
test("accepts a scoped board settings proposal", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
summary: "Improve dashboard contrast and tighten card spacing.",
changes: {
primaryColor: "#7C3AED",
customCss: ".mantine-Card-root { padding: 0.75rem; }",
},
}).success,
).toBe(true);
});
test("rejects invalid board colors and oversized CSS", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
summary: "Change styling",
changes: { primaryColor: "purple", customCss: "a".repeat(16_385) },
}).success,
).toBe(false);
});
test("converts the board review parameters to a provider JSON schema", () => {
expect(() => z.toJSONSchema(browserToolContracts.configure_board_settings.parameters)).not.toThrow();
});
test("only preserves usable app icon URLs", () => {
expect(normalizeAssistantAppIconUrl("youtube")).toBe("");
expect(normalizeAssistantAppIconUrl("/api/media/youtube.svg")).toBe("/api/media/youtube.svg");
@@ -1,11 +1,43 @@
import { z } from "zod/v4";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
const askUserOptionSchema = z.object({
id: z.string().trim().min(1).max(48),
label: z.string().trim().min(1).max(80),
description: z.string().trim().max(180).optional(),
});
const boardImageValueSchema = z.string().trim().max(2_048).nullable();
const boardTitleValueSchema = z.string().trim().max(255).nullable();
const boardColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
export const assistantBoardSettingsChangesSchema = z
.object({
pageTitle: boardTitleValueSchema.optional(),
metaTitle: boardTitleValueSchema.optional(),
logoImageUrl: boardImageValueSchema.optional(),
faviconImageUrl: boardImageValueSchema.optional(),
backgroundImageUrl: boardImageValueSchema.optional(),
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
primaryColor: boardColorSchema.optional(),
secondaryColor: boardColorSchema.optional(),
opacity: z.number().min(0).max(100).optional(),
customCss: z
.string()
.max(16_384)
.optional()
.describe(
"The complete resulting board stylesheet. Preserve existing rules from board_getBoardSettings unless the user explicitly asks to replace them.",
),
iconColor: boardColorSchema.nullable().optional(),
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
disableStatus: z.boolean().optional(),
})
.describe("Only the board fields the user asked to change.");
export const browserToolContracts = {
ask_user: {
description:
@@ -35,6 +67,16 @@ export const browserToolContracts = {
pingUrl: z.string().trim().max(2_048).nullable().optional(),
}),
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
summary: z.string().trim().min(1).max(400),
changes: assistantBoardSettingsChangesSchema,
}),
},
navigate_to_route: {
description:
"Navigate the current Homarr tab to a safe internal route. Only same-origin paths beginning with a single slash are accepted.",
@@ -59,6 +101,13 @@ export type AskUserResult = {
source: "option" | "other";
};
export type ConfigureAppArgs = z.infer<(typeof browserToolContracts)["configure_app"]["parameters"]>;
export type ConfigureBoardSettingsArgs = z.infer<
(typeof browserToolContracts)["configure_board_settings"]["parameters"]
>;
export type AssistantBoardSettingsChanges = z.infer<typeof assistantBoardSettingsChangesSchema>;
export type ConfigureBoardSettingsResult =
| (AssistantBoardSettingsChanges & { id: string })
| { id: string; cancelled: true };
export const normalizeAssistantAppIconUrl = (value: string | undefined) => {
if (!value) return "";
+207 -98
View File
@@ -43,6 +43,9 @@ import {
normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
widgetIntegrationLimits,
widgetIntegrationSupport,
widgetKindsWithOptionalIntegrations,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
@@ -60,6 +63,7 @@ import {
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
boardSettingsSchema,
boardSummarySchema,
} from "@homarr/validation/board";
import { byIdSchema } from "@homarr/validation/common";
@@ -69,12 +73,57 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
interface BoardItemPlacementRectangle {
xOffset: number;
yOffset: number;
width: number;
height: number;
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
const { promise: current, resolve: release } = Promise.withResolvers<void>();
const tail = previous.then(() => current);
boardItemPlacementTails.set(boardId, tail);
await previous;
try {
return await operation();
} finally {
release();
if (boardItemPlacementTails.get(boardId) === tail) boardItemPlacementTails.delete(boardId);
}
};
const findFirstAvailableBoardItemPosition = (
placements: readonly BoardItemPlacementRectangle[],
columnCount: number,
size: { width: number; height: number },
) => {
for (let yOffset = 0; yOffset < 9999; yOffset++) {
for (let xOffset = 0; xOffset + size.width <= columnCount; xOffset++) {
const overlaps = placements.some(
(placement) =>
placement.yOffset < yOffset + size.height &&
placement.yOffset + placement.height > yOffset &&
placement.xOffset < xOffset + size.width &&
placement.xOffset + placement.width > xOffset,
);
if (!overlaps) return { xOffset, yOffset };
}
}
return null;
};
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -793,6 +842,57 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardSettings: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the editable visual and behavior settings for one board, including its current custom CSS. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes",
},
})
.input(z.object({ id: z.string() }))
.output(boardSettingsSchema)
.query(async ({ input, ctx }) => {
const boardWhere = eq(boards.id, input.id);
await throwIfActionForbiddenAsync(ctx, boardWhere, "modify");
const board = await ctx.db.query.boards.findFirst({
columns: {
id: true,
name: true,
pageTitle: true,
metaTitle: true,
logoImageUrl: true,
faviconImageUrl: true,
backgroundImageUrl: true,
backgroundImageAttachment: true,
backgroundImageRepeat: true,
backgroundImageSize: true,
primaryColor: true,
secondaryColor: true,
opacity: true,
customCss: true,
iconColor: true,
itemRadius: true,
disableStatus: true,
},
where: boardWhere,
});
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
return {
...board,
pageTitle: board.pageTitle ?? "",
metaTitle: board.metaTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
backgroundImageUrl: board.backgroundImageUrl ?? "",
customCss: board.customCss ?? "",
iconColor: board.iconColor ?? "",
};
}),
saveLayouts: protectedProcedure
.input(boardSaveLayoutsSchema)
.output(boardSaveLayoutsSchema.shape.layouts)
@@ -1760,7 +1860,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }",
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the main canvas at the first free grid position without overlapping items or containers. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
@@ -1777,9 +1877,28 @@ export const boardRouter = createTRPCRouter({
await validateTimetableOptionsChangeAsync(input.options);
}
const supportedIntegrations = widgetIntegrationSupport[input.kind];
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
if (input.integrationIds.length > integrationLimit) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
});
}
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
}
if (
supportedIntegrations !== undefined &&
input.integrationIds.length === 0 &&
!widgetKindsWithOptionalIntegrations.has(input.kind)
) {
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
}
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true },
columns: { id: true, kind: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
@@ -1787,111 +1906,101 @@ export const boardRouter = createTRPCRouter({
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
}
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: true,
layouts: true,
items: { with: { layouts: true } },
},
});
await Promise.all(
input.integrationIds.map((integrationId) =>
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
),
);
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((s) => s.kind === "empty")
.toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" });
}
const itemId = createId();
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
for (const layout of board.layouts) {
const existingInSection = board.items
.flatMap((item) => item.layouts)
.filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id);
const occupied: boolean[][] = [];
for (const il of existingInSection) {
for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
while (occupied.length <= y) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
occupiedRow[x] = true;
}
}
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const fitsAt = (x: number, y: number) => {
if (x + sizeForLayout.width > layout.columnCount) return false;
for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
}
return true;
};
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (fitsAt(x, y)) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: sizeForLayout.width,
height: sizeForLayout.height,
});
placed = true;
}
}
}
if (!placed) {
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
if (incompatible.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
});
}
}
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: { with: { layouts: true } },
layouts: true,
items: { with: { layouts: true } },
},
});
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
return { itemId };
const emptySection = board.sections
.filter((section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main")
.toSorted(
(sectionA, sectionB) =>
(sectionA.yOffset ?? 0) - (sectionB.yOffset ?? 0) || sectionA.id.localeCompare(sectionB.id),
)[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no main section to place items in" });
}
const itemId = createId();
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const layoutRows: (typeof itemLayouts.$inferInsert)[] = board.layouts.map((layout) => {
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(emptySection.xOffset));
const size = { ...defaultSize, width: Math.min(columnCount, defaultSize.width) };
const itemPlacements = board.items
.flatMap((item) => item.layouts)
.filter((itemLayout) => itemLayout.sectionId === emptySection.id && itemLayout.layoutId === layout.id);
const containerPlacements = board.sections
.filter((section) => section.kind === "container")
.flatMap((section) => section.layouts)
.filter(
(sectionLayout) =>
sectionLayout.parentSectionId === emptySection.id && sectionLayout.layoutId === layout.id,
);
const position = findFirstAvailableBoardItemPosition(
[...itemPlacements, ...containerPlacements],
columnCount,
size,
);
if (!position) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
return {
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
...position,
width: size.width,
height: size.height,
};
});
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
if (layoutRows.length > 0) await ctx.db.insert(itemLayouts).values(layoutRows);
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
return { itemId };
});
}),
});
@@ -1908,7 +2017,7 @@ export const boardRouter = createTRPCRouter({
* 8. serverSettings.homeBoardId
* 9. show NOT_FOUND error
*/
const getHomeIdBoardAsync = async (
export const getHomeIdBoardAsync = async (
db: Database,
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
@@ -658,6 +658,28 @@ describe("getBoardByName should return board by name", () => {
});
});
describe("getBoardSettings should return editable board settings", () => {
test("returns current custom CSS and requires modify permission", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "default");
const customCss = ".dashboard-card { border-radius: 0.5rem; }";
await db.update(boards).set({ customCss, primaryColor: "#123456", pageTitle: null }).where(eq(boards.id, boardId));
const result = await caller.getBoardSettings({ id: boardId });
expect(result).toMatchObject({
id: boardId,
name: "default",
customCss,
primaryColor: "#123456",
pageTitle: null,
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
});
describe("savePartialBoardSettings should save general settings", () => {
it("should save general settings", async () => {
// Arrange
+35 -4
View File
@@ -2019,9 +2019,9 @@
"label": "Find media to request",
"prompt": "Help me find a movie or series to request."
},
"command": {
"label": "Open the command menu",
"prompt": "Open the Homarr command menu for me."
"style": {
"label": "Improve a dashboard",
"prompt": "Help me improve a board's appearance or custom CSS. Show me my boards first, then preserve my current settings while we make the change."
}
},
"composerPlaceholder": "Ask Homarr Assistant…",
@@ -2077,7 +2077,8 @@
"hint": "Type / for shortcuts",
"health": "Prepare a service-health check",
"explore": "Prepare an overview of this Homarr instance",
"media": "Prepare a media search"
"media": "Prepare a media search",
"style": "Prepare a board appearance or custom CSS change"
},
"usage": {
"tokens": "tokens",
@@ -2117,6 +2118,7 @@
"answerTitle": "Answer needed",
"answerFallback": "Choose an answer to continue",
"formTitle": "Review app details",
"boardFormTitle": "Review board changes",
"review": "Review",
"sendBlocked": "Resolve the pending request before sending"
},
@@ -2146,6 +2148,35 @@
"continue": "Use these details",
"ready": "App details ready"
},
"configureBoardSettings": {
"title": "Review board changes",
"loading": "Loading current board settings",
"loadErrorTitle": "Board settings could not be loaded",
"loadErrorDescription": "The board may no longer exist, or you may not have permission to modify it.",
"retry": "Try again",
"proposedCount": "Assistant proposed {count} fields",
"changeCount": "{count} changes ready",
"ready": "Board changes ready",
"cancelled": "Current board settings kept",
"noChanges": "No settings will change",
"keepCurrent": "Keep current settings",
"continue": "Continue to approval",
"resetAll": "Reset all changes",
"tabs": {
"css": "Custom CSS",
"appearance": "Appearance",
"background": "Background",
"general": "General"
},
"cssDescription": "Edit the complete stylesheet. Existing rules are kept unless they are removed here.",
"cssCharacters": "{count} of 16384 characters",
"copy": "Copy",
"copyCss": "Copy CSS",
"copied": "Copied",
"restoreCss": "Restore current CSS",
"remoteCssTitle": "This CSS loads a remote resource",
"remoteCssDescription": "Imported stylesheets and remote URLs contact another server when the board loads. Review them before continuing."
},
"approvalRequired": "Approval required",
"complete": "Complete",
"denied": "Denied",
+10 -1
View File
@@ -72,6 +72,11 @@ export const boardSavePartialSettingsSchema = z
})
.partial();
export const boardSettingsSchema = boardSavePartialSettingsSchema.required().extend({
id: z.string(),
name: z.string(),
});
export const boardLayoutSchema = z
.object({
id: z.string(),
@@ -175,5 +180,9 @@ export const addItemToBoardSchema = z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()).default({}),
integrationIds: z.array(z.string()).default([]),
integrationIds: z
.array(z.string())
.max(32)
.refine((ids) => new Set(ids).size === ids.length)
.default([]),
});