feat: add assistant board styling tools

This commit is contained in:
ajnart committed 2026-08-01 10:39:41 +02:00
1 parent a5fa6d3afe
commit 5458e7d647
21 files changed
+811 -18

No files matched your search

+52
View File
@@ -52,6 +52,7 @@ import {
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
boardSettingsSchema,
boardSummarySchema,
} from "@homarr/validation/board";
import { byIdSchema } from "@homarr/validation/common";
@@ -682,6 +683,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).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
@@ -586,6 +586,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
+1
View File
@@ -54,6 +54,7 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_getBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("icon_findIcons");
expect(toolNames).toContain("info_getInfo");
+35 -4
View File
@@ -1543,9 +1543,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…",
@@ -1601,7 +1601,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",
@@ -1641,6 +1642,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"
},
@@ -1670,6 +1672,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",
+5
View File
@@ -70,6 +70,11 @@ export const boardSavePartialSettingsSchema = z
})
.partial();
export const boardSettingsSchema = boardSavePartialSettingsSchema.required().extend({
id: z.string(),
name: z.string(),
});
export const boardSaveLayoutsSchema = z.object({
id: z.string(),
layouts: z.array(