fix assistant widget configuration

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 8d06201b41
commit 1f60b4b4be
16 files changed
+696 -11

No files matched your search

@@ -75,6 +75,20 @@ describe("getPendingAssistantAction", () => {
).toEqual({ kind: "form", toolName: "configure_board_settings", detail: "Home" });
});
test("exposes the native widget form while configuration still needs review", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "widget-1",
toolName: "configure_widget",
args: { boardId: "board-1", boardName: "Home", kind: "notebook" },
argsText: "{}",
}),
),
).toEqual({ kind: "form", toolName: "configure_widget", detail: "Home" });
});
test("exposes an unresolved mutation approval", () => {
expect(
getPendingAssistantAction(
@@ -10,6 +10,7 @@ const humanToolKinds = {
ask_user: "question",
configure_app: "form",
configure_board_settings: "form",
configure_widget: "form",
} as const;
const getStringArg = (args: unknown, key: string) => {
@@ -40,7 +41,11 @@ export const getPendingAssistantAction = (message: ThreadMessage | undefined): A
toolName: part.toolName,
detail: getStringArg(
part.args,
kind === "question" ? "question" : part.toolName === "configure_board_settings" ? "boardName" : "name",
kind === "question"
? "question"
: part.toolName === "configure_board_settings" || part.toolName === "configure_widget"
? "boardName"
: "name",
),
};
}
@@ -43,6 +43,7 @@ import { sendAssistantPrompt as sendPromptThroughRuntime } from "./assistant-sen
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
import { AssistantConfigureWidgetTool } from "./assistant-widget-tool";
import type { AssistantUIMessage } from "./assistant-message-metadata";
import {
getSuccessfulApprovedAssistantMutationIds,
@@ -458,6 +459,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
...browserToolContracts.configure_board_settings,
render: AssistantConfigureBoardSettingsTool,
},
configure_widget: {
type: "human",
display: "standalone",
...browserToolContracts.configure_widget,
render: AssistantConfigureWidgetTool,
},
navigate_to_route: {
type: "frontend",
...browserToolContracts.navigate_to_route,
@@ -24,6 +24,14 @@ const supportedToolArguments = {
summary: "Improve dashboard contrast.",
changes: { primaryColor: "#7C3AED" },
},
configure_widget: {
boardId: "board-1",
boardName: "Home",
kind: "notebook",
summary: "Add a formatted Plex self-hosting guide.",
options: { content: "<h2>Self-host Plex</h2><p>Install Docker first.</p>" },
integrationIds: [],
},
navigate_to_route: { path: "/manage/apps" },
open_command_menu: {},
open_media_request_search: {},
@@ -127,6 +135,20 @@ describe("assistant human tool contracts", () => {
expect(() => z.toJSONSchema(browserToolContracts.configure_board_settings.parameters)).not.toThrow();
});
test("accepts a native widget configuration with proposed options and integrations", () => {
expect(
browserToolContracts.configure_widget.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
kind: "mediaServer",
summary: "Show current Plex streams.",
options: { showOnlyPlaying: true },
integrationIds: ["plex-1"],
}).success,
).toBe(true);
expect(() => z.toJSONSchema(browserToolContracts.configure_widget.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,6 +1,11 @@
import { z } from "zod/v4";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
widgetKinds,
} from "@homarr/definitions";
export const assistantAskUserOptionKinds = ["affirmative", "negative", "alternative"] as const;
export type AssistantAskUserOptionKind = (typeof assistantAskUserOptionKinds)[number];
@@ -85,6 +90,18 @@ export const browserToolContracts = {
changes: assistantBoardSettingsChangesSchema,
}),
},
configure_widget: {
description:
"Open Homarr's native widget editor with inferred options and integrations preselected. Use this before board_addItem for every dashboard widget, including app and notebook widgets. The editor applies widget defaults, validates supported options, and only offers compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds directly with board_addItem.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
kind: z.enum(widgetKinds),
summary: z.string().trim().min(1).max(400),
options: z.record(z.string(), z.unknown()).optional(),
integrationIds: z.array(z.string().trim().min(1).max(64)).max(32).optional(),
}),
},
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.",
@@ -122,6 +139,20 @@ export type AssistantBoardSettingsChanges = z.infer<typeof assistantBoardSetting
export type ConfigureBoardSettingsResult =
| (AssistantBoardSettingsChanges & { id: string })
| { id: string; cancelled: true };
export type ConfigureWidgetArgs = z.infer<(typeof browserToolContracts)["configure_widget"]["parameters"]>;
export type ConfigureWidgetResult =
| {
boardId: string;
kind: ConfigureWidgetArgs["kind"];
options: Record<string, unknown>;
integrationIds: string[];
}
| {
boardId: string;
kind: ConfigureWidgetArgs["kind"];
cancelled: true;
reason?: "no-compatible-integration" | "user-cancelled";
};
export const normalizeAssistantAppIconUrl = (value: string | undefined) => {
if (!value) return "";
@@ -0,0 +1,104 @@
import { describe, expect, test } from "vitest";
import type { WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
describe("getAssistantWidgetConfiguration", () => {
test("keeps server-side integration support synchronized with widget definitions", () => {
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
const definition = widgetImport.definition;
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
}
});
test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration(
{
boardId: "board-1",
boardName: "Home",
kind: "notebook",
summary: "Add a Plex guide",
options: { content: "<h2>Self-host Plex</h2><p>Use Docker.</p>" },
},
settings,
[],
);
expect(result.integrationSupport).toBe(false);
expect(result.value.options).toEqual({
content: "<h2>Self-host Plex</h2><p>Use Docker.</p>",
showToolbar: true,
allowReadOnlyCheck: true,
});
});
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration(
{
boardId: "board-1",
boardName: "Home",
kind: "audioStats",
summary: "Show audio statistics",
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
},
settings,
[
{
id: "navidrome-1",
name: "Navidrome",
kind: "navidrome",
url: "https://music.example.com",
permissions: { hasUseAccess: true },
},
{
id: "audiobookshelf-1",
name: "Audiobookshelf",
kind: "audiobookshelf",
url: "https://books.example.com",
permissions: { hasUseAccess: true },
},
{
id: "plex-1",
name: "Plex",
kind: "plex",
url: "https://plex.example.com",
permissions: { hasUseAccess: true },
},
{
id: "navidrome-private",
name: "Private Navidrome",
kind: "navidrome",
url: "https://private.example.com",
permissions: { hasUseAccess: false },
},
],
);
expect(result.integrationsRequired).toBe(true);
expect(result.integrationData.map((integration) => integration.id)).toEqual(["navidrome-1", "audiobookshelf-1"]);
expect(result.value.integrationIds).toEqual(["navidrome-1"]);
});
test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration(
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings,
[],
);
const calendar = getAssistantWidgetConfiguration(
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings,
[],
);
expect(mediaServer.integrationsRequired).toBe(true);
expect(calendar.integrationsRequired).toBe(false);
});
});
@@ -0,0 +1,233 @@
"use client";
import { useMemo } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { WidgetEditModal } from "@homarr/widgets/modals";
import classes from "./assistant-panel.module.css";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
interface AssistantIntegration extends IntegrationSelectOption {
permissions: { hasUseAccess: boolean };
}
export const getAssistantWidgetConfiguration = (
args: ConfigureWidgetArgs,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrations: AssistantIntegration[],
) => {
const definition = widgetImports[args.kind].definition;
const integrationSupport = "supportedIntegrations" in definition;
const supportedIntegrations = integrationSupport
? (definition.supportedIntegrations as readonly IntegrationKind[])
: [];
const integrationData = integrations.filter(
(integration) => integration.permissions.hasUseAccess && supportedIntegrations.includes(integration.kind),
);
const requestedIds = new Set(args.integrationIds ?? []);
const selectedIntegrations =
requestedIds.size > 0 ? integrationData.filter((integration) => requestedIds.has(integration.id)) : integrationData;
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const integrationIds = selectedIntegrations.slice(0, maxIntegrations).map((integration) => integration.id);
return {
integrationData,
integrationSupport,
integrationsRequired:
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
integrationIds,
},
};
};
export const AssistantConfigureWidgetTool = ({
args,
result,
addResult,
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
const t = useScopedI18n("common.assistant.configureWidget");
const fullT = useI18n();
const settings = useSettings();
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
const integrations = clientApi.integration.all.useQuery(undefined, {
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
retry: false,
});
const configuration = useMemo(() => {
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
const missingRequiredIntegration =
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
const { openModal } = useModalAction(WidgetEditModal);
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && configuration !== null,
completed: result !== undefined,
confirm: () => {
if (!args || !configuration) return;
if (missingRequiredIntegration) {
addResult({ boardId: args.boardId, kind: args.kind, cancelled: true, reason: "no-compatible-integration" });
return;
}
addResult({
boardId: args.boardId,
kind: args.kind,
options: configuration.value.options,
integrationIds: configuration.value.integrationIds,
});
},
});
if (result) {
const cancelled = "cancelled" in result && result.cancelled;
return (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color={cancelled ? "orange" : "green"}>
{cancelled ? <IconAlertTriangle size={13} /> : <IconCheck size={13} />}
</ThemeIcon>
<Box miw={0}>
<Text size="xs" c="dimmed">
{cancelled ? t("cancelled") : t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{args?.kind ? fullT(`widget.${args.kind}.name`) : t("title")}
</Text>
</Box>
</Box>
);
}
if (hasFailedAssistantToolArguments(status)) {
return (
<Alert color="red" variant="light" title={t("errorTitle")} icon={<IconAlertTriangle size={18} />}>
{t("errorDescription")}
</Alert>
);
}
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
return (
<Box className={classes.appTool} aria-label={t("preparing")}>
<Stack gap="sm">
<Skeleton height={18} width="45%" />
<Skeleton height={14} width="76%" />
<Skeleton height={38} />
</Stack>
</Box>
);
}
if (integrations.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={() => integrations.refetch()}>
{t("retry")}
</Button>
</Stack>
</Alert>
);
}
if (!configuration) return null;
if (autoConfirming) {
return (
<Box className={classes.appTool}>
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
</Box>
);
}
if (missingRequiredIntegration) {
return (
<Alert color="orange" variant="light" title={t("noIntegrationTitle")} icon={<IconAlertTriangle size={18} />}>
<Stack gap="sm">
<Text size="sm">{t("noIntegrationDescription")}</Text>
<Group gap="xs">
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
<Button
size="compact-sm"
variant="default"
onClick={() =>
addResult({
boardId: args.boardId,
kind: args.kind,
cancelled: true,
reason: "no-compatible-integration",
})
}
>
{t("skip")}
</Button>
</Group>
</Stack>
</Alert>
);
}
const WidgetIcon = widgetImports[args.kind].definition.icon;
return (
<Box className={classes.appTool}>
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" size="lg" radius="md">
<WidgetIcon size={20} />
</ThemeIcon>
<Box miw={0} flex={1}>
<Text fw={700}>{fullT(`widget.${args.kind}.name`)}</Text>
<Text size="sm" c="dimmed">
{args.summary}
</Text>
</Box>
</Group>
<Button
mt="md"
fullWidth
leftSection={<IconSettings size={17} />}
onClick={() =>
openModal(
{
kind: args.kind,
value: configuration.value,
integrationData: configuration.integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
)
}
>
{t("review")}
</Button>
</Box>
);
};