feat: add assistant board styling tools
This commit is contained in:
1 parent
07547a462c
commit
6dc9a565b0
20 files changed
+1045
-157
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
Reference in new issue
Block a user