diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 80473fd19..d1352bcf9 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -85,6 +85,12 @@ The configured endpoint must support streaming Chat Completions and structured t assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter explicitly advertises the model's `tools` capability. +OpenRouter connections also expose OpenRouter's provider-executed web-search server tool to the model. When +a user explicitly asks for live research or current external information, the assistant can search without +receiving the stored OpenRouter credential in the browser. Search results can be used in dashboard notes and +responses, including source links. Web search has separate OpenRouter usage costs and remains unavailable for +other OpenAI-compatible endpoints unless those providers expose their own supported search tool. + The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant @@ -152,7 +158,14 @@ read-only lookups may be performed together, while every mutation still pauses f The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows -the standard mutation approval, and places the widget in the next available dashboard position. +the native widget configuration form and standard mutation approval, then places the widget in the next +available dashboard position. The generated content is retained even though the Notebook content field is +normally hidden from widget settings. + +Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added. +The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that +the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive +multiple integrations, and the reviewed values are passed unchanged into the final approval-gated mutation. Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget form's **Design with AI** action sends a focused request into the current conversation, where the assistant diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts new file mode 100644 index 000000000..e872f85cf --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { withOpenRouterWebSearch } from "./assistant-openrouter"; + +describe("withOpenRouterWebSearch", () => { + test("adds the current OpenRouter server tool alongside Homarr function tools", () => { + expect( + withOpenRouterWebSearch({ + model: "deepseek/deepseek-v4-flash-latest", + tools: [{ type: "function", function: { name: "board_addItem", parameters: {} } }], + }), + ).toMatchObject({ + tools: [ + { type: "function", function: { name: "board_addItem" } }, + { type: "openrouter:web_search", parameters: { max_results: 5 } }, + ], + }); + }); + + test("does not register the web search tool twice", () => { + const body = { tools: [{ type: "openrouter:web_search", parameters: { max_results: 3 } }] }; + + expect(withOpenRouterWebSearch(body)).toBe(body); + }); +}); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts new file mode 100644 index 000000000..794f0ea61 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts @@ -0,0 +1,21 @@ +const openRouterWebSearchTool = { + type: "openrouter:web_search", + parameters: { max_results: 5 }, +} as const; + +export const withOpenRouterWebSearch = (body: Record) => { + const tools = Array.isArray(body.tools) ? body.tools : []; + if ( + tools.some( + (candidate) => + typeof candidate === "object" && + candidate !== null && + "type" in candidate && + candidate.type === openRouterWebSearchTool.type, + ) + ) { + return body; + } + + return { ...body, tools: [...tools, openRouterWebSearchTool] }; +}; diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts index c00845b72..1620cffb2 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts @@ -87,6 +87,46 @@ describe("getForcedAssistantToolName", () => { ).toBeUndefined(); }); + test("continues directly from reviewed widget settings to board placement", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_widget", + toolCallId: "widget-1", + input: { boardId: "board-1", boardName: "Home", kind: "notebook" }, + state: "output-available", + output: { + boardId: "board-1", + kind: "notebook", + options: { content: "

Plex

" }, + integrationIds: [], + }, + }), + ]), + ).toBe("board_addItem"); + }); + + test("does not add a widget after native configuration was cancelled", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_widget", + toolCallId: "widget-1", + input: { boardId: "board-1", boardName: "Home", kind: "mediaServer" }, + state: "output-available", + output: { + boardId: "board-1", + kind: "mediaServer", + cancelled: true, + reason: "no-compatible-integration", + }, + }), + ]), + ).toBeUndefined(); + }); + test("does not repeat app creation after the mutation tool has been called", () => { expect( getForcedAssistantToolName([ diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts index 476869cce..c25c44354 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts @@ -19,10 +19,11 @@ export const getForcedAssistantToolName = (messages: UIMessage[]) => { const nextToolByHumanTool = { configure_app: "app_create", configure_board_settings: "board_savePartialBoardSettings", + configure_widget: "board_addItem", } as const; const toolName = getToolName(latestToolPart); if ( - toolName === "configure_board_settings" && + (toolName === "configure_board_settings" || toolName === "configure_widget") && typeof latestToolPart.output === "object" && latestToolPart.output !== null && "cancelled" in latestToolPart.output && diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index dc4f01fc7..26de37531 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -32,6 +32,7 @@ import type { import { extractMcpTools } from "../../mcp/_extract-tools"; import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getAssistantModelLookupStatus } from "./assistant-model-lookup"; +import { withOpenRouterWebSearch } from "./assistant-openrouter"; import { assistantExecutionPolicy } from "./assistant-execution-policy"; import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy"; @@ -99,11 +100,13 @@ Action rules: - Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears. - 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 app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, use the appId returned by app_create in a board_addItem call with kind "app" and options { appId } before reporting success. +- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }. - 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. -- To create a formatted note on a dashboard, call board_addItem with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols. +- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget. +- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols. - 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. - When a Homarr tool returns a usable image or icon URL and a visual summary helps, embed that exact URL with Markdown image syntax. App summary tables must use an Icon column such as ![Discord icon](exact-returned-url). Never replace an available returned icon with emoji, and never invent or transform image URLs. +- When the configured provider is OpenRouter and the user asks for live web research or current external information, use OpenRouter's web search server tool. Ground the answer in its results and preserve useful source links in the response or generated note. Do not claim web search is unavailable when that tool is present. For other providers, only claim live research when an actual search tool is available. - Complete batch requests in the same run. Use parallel independent read-only calls when supported, reuse results, continue calling tools until every requested item has been attempted, and summarize only after the batch is complete. Do not stop after a partial batch or ask the user to type "Continue". - Browser tools can navigate within Homarr, open existing Homarr UI, or refresh the active view after a completed change. Never navigate to an arbitrary external URL and never refresh before a mutation finishes. - Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON. @@ -499,6 +502,7 @@ export async function POST(request: Request) { apiKey: providerApiKey, headers: providerHeaders, includeUsage: true, + transformRequestBody: configuration.provider === "openrouter" ? withOpenRouterWebSearch : undefined, metadataExtractor: createProviderTelemetryExtractor(), }); diff --git a/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts b/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts index 06baab3a9..8dad05e7c 100644 --- a/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts @@ -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( diff --git a/apps/nextjs/src/components/assistant/assistant-pending-action.ts b/apps/nextjs/src/components/assistant/assistant-pending-action.ts index d8b5cd938..b464e9380 100644 --- a/apps/nextjs/src/components/assistant/assistant-pending-action.ts +++ b/apps/nextjs/src/components/assistant/assistant-pending-action.ts @@ -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", ), }; } diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 710b246a1..aa67cde0c 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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, diff --git a/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts b/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts index 7c9e053e1..de784a4ed 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts @@ -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: "

Self-host Plex

Install Docker first.

" }, + 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"); diff --git a/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts b/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts index 0f30dce0a..7d6a2257c 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts @@ -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; +export type ConfigureWidgetResult = + | { + boardId: string; + kind: ConfigureWidgetArgs["kind"]; + options: Record; + integrationIds: string[]; + } + | { + boardId: string; + kind: ConfigureWidgetArgs["kind"]; + cancelled: true; + reason?: "no-compatible-integration" | "user-cancelled"; + }; export const normalizeAssistantAppIconUrl = (value: string | undefined) => { if (!value) return ""; diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts new file mode 100644 index 000000000..252a0fa99 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts @@ -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: "

Self-host Plex

Use Docker.

" }, + }, + settings, + [], + ); + + expect(result.integrationSupport).toBe(false); + expect(result.value.options).toEqual({ + content: "

Self-host Plex

Use Docker.

", + 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); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx new file mode 100644 index 000000000..02bd5aa64 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -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, + 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) => { + 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 ( + + + {cancelled ? : } + + + + {cancelled ? t("cancelled") : t("ready")} + + + {args?.kind ? fullT(`widget.${args.kind}.name`) : t("title")} + + + + ); + } + + if (hasFailedAssistantToolArguments(status)) { + return ( + }> + {t("errorDescription")} + + ); + } + + if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) { + return ( + + + + + + + + ); + } + + if (integrations.isError) { + return ( + }> + + {t("loadErrorDescription")} + + + + ); + } + + if (!configuration) return null; + + if (autoConfirming) { + return ( + + + + ); + } + + if (missingRequiredIntegration) { + return ( + }> + + {t("noIntegrationDescription")} + + + + + + + ); + } + + const WidgetIcon = widgetImports[args.kind].definition.icon; + return ( + + + + + + + {fullT(`widget.${args.kind}.name`)} + + {args.summary} + + + + + + ); +}; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 2ac349c6d..29b9127ee 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -84,7 +84,8 @@ export const ItemSelectModal = createModal(({ actions }) => { ) : []; - const integrationIds = matchingIntegrations.map((i) => i.id); + const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; + const integrationIds = matchingIntegrations.slice(0, maxIntegrations).map((i) => i.id); const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index d8c3cbe17..9655da09d 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -36,6 +36,9 @@ import { getPermissionsWithChildren, getPermissionsWithParents, widgetDefaultSizes, + widgetIntegrationLimits, + widgetIntegrationSupport, + widgetKindsWithOptionalIntegrations, widgetKinds, } from "@homarr/definitions"; import { importOldmarrAsync } from "@homarr/old-import"; @@ -63,6 +66,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; import { generateResponsiveGridFor } from "./board/grid-algorithm"; +import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access"; export const boardRouter = createTRPCRouter({ exists: permissionRequiredProcedure @@ -1485,7 +1489,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. To create a formatted dashboard note, use kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Notebook content may use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images, but must not contain scripts, styles, iframes, event handlers, or unsafe URL protocols. Returns { itemId }", + "Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the first empty section at the next free grid position. 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) @@ -1493,9 +1497,28 @@ export const boardRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + 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)); @@ -1503,6 +1526,20 @@ export const boardRouter = createTRPCRouter({ if (invalid.length > 0) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` }); } + + await Promise.all( + input.integrationIds.map((integrationId) => + throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"), + ), + ); + + const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind)); + if (incompatible.length > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`, + }); + } } const board = await ctx.db.query.boards.findFirst({ diff --git a/packages/api/src/router/test/board-add-item.spec.ts b/packages/api/src/router/test/board-add-item.spec.ts new file mode 100644 index 000000000..4184c2b5d --- /dev/null +++ b/packages/api/src/router/test/board-add-item.spec.ts @@ -0,0 +1,153 @@ +// @vitest-environment node + +import { parse } from "superjson"; +import { describe, expect, test } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import { boards, integrations, integrationUserPermissions, items, layouts, sections, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { boardRouter } from "../board"; + +const createBoardCallerAsync = async () => { + const db = createDb(); + const userId = createId(); + const boardId = createId(); + await db.insert(users).values({ id: userId }); + await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId }); + await db.insert(layouts).values({ + id: createId(), + name: "Base", + boardId, + columnCount: 12, + breakpoint: 0, + }); + await db.insert(sections).values({ id: createId(), boardId, kind: "empty", xOffset: 0, yOffset: 0 }); + const session = { + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), + } satisfies Session; + return { db, userId, boardId, caller: boardRouter.createCaller({ db, deviceType: undefined, session }) }; +}; + +describe("board.addItem", () => { + test("preserves configured notebook content when placing the widget", async () => { + const { db, boardId, caller } = await createBoardCallerAsync(); + const content = "

Self-host Plex

Install Docker and configure your media volumes.

"; + + const result = await caller.addItem({ + boardId, + kind: "notebook", + options: { content, showToolbar: true, allowReadOnlyCheck: true }, + integrationIds: [], + }); + + const item = await db.query.items.findFirst({ where: (table, { eq }) => eq(table.id, result.itemId) }); + expect(item?.kind).toBe("notebook"); + expect(parse(item?.options ?? "{}")).toEqual({ + content, + showToolbar: true, + allowReadOnlyCheck: true, + }); + }); + + test("accepts an integration the current user can use", async () => { + const { db, userId, boardId, caller } = await createBoardCallerAsync(); + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "Plex", + kind: "plex", + url: "https://plex.example.com", + }); + await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" }); + + await expect( + caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }), + ).resolves.toMatchObject({ itemId: expect.any(String) }); + }); + + test("rejects an integration the current user cannot use", async () => { + const { db, boardId, caller } = await createBoardCallerAsync(); + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "Private Plex", + kind: "plex", + url: "https://private.example.com", + }); + + await expect( + caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }), + ).rejects.toThrow("Integration not found"); + + await expect(db.$count(items)).resolves.toBe(0); + }); + + test("rejects a missing required integration", async () => { + const { db, boardId, caller } = await createBoardCallerAsync(); + + await expect(caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [] })).rejects.toThrow( + "mediaServer requires an integration", + ); + + await expect(db.$count(items)).resolves.toBe(0); + }); + + test("allows an explicitly optional integration to be omitted", async () => { + const { boardId, caller } = await createBoardCallerAsync(); + + await expect(caller.addItem({ boardId, kind: "calendar", options: {}, integrationIds: [] })).resolves.toMatchObject( + { itemId: expect.any(String) }, + ); + }); + + test("rejects an incompatible integration kind", async () => { + const { db, userId, boardId, caller } = await createBoardCallerAsync(); + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "Sonarr", + kind: "sonarr", + url: "https://sonarr.example.com", + }); + await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" }); + + await expect( + caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }), + ).rejects.toThrow("mediaServer does not support integration kind: sonarr"); + + await expect(db.$count(items)).resolves.toBe(0); + }); + + test("rejects more integrations than the widget supports", async () => { + const { db, userId, boardId, caller } = await createBoardCallerAsync(); + const navidromeId = createId(); + const audiobookshelfId = createId(); + await db.insert(integrations).values([ + { id: navidromeId, name: "Navidrome", kind: "navidrome", url: "https://music.example.com" }, + { + id: audiobookshelfId, + name: "Audiobookshelf", + kind: "audiobookshelf", + url: "https://books.example.com", + }, + ]); + await db.insert(integrationUserPermissions).values([ + { integrationId: navidromeId, userId, permission: "use" }, + { integrationId: audiobookshelfId, userId, permission: "use" }, + ]); + + await expect( + caller.addItem({ + boardId, + kind: "audioStats", + options: {}, + integrationIds: [navidromeId, audiobookshelfId], + }), + ).rejects.toThrow("audioStats supports at most 1 integration"); + + await expect(db.$count(items)).resolves.toBe(0); + }); +}); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 1844fb662..0f0e9eaf0 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -27,9 +27,13 @@ export const widgetIntegrationSupport: Partial kind !== "patchmon"), firewall: getIntegrationKindsByCategory("firewall"), notifications: getIntegrationKindsByCategory("notifications"), - mediaReleases: ["emby", "jellyfin", "plex"], + mediaReleases: ["mock", "emby", "jellyfin", "plex"], systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], + beszelAlerts: ["beszel", "mock"], + beszelSystemGrid: ["beszel", "mock"], + beszelSystemStats: ["beszel", "mock"], + beszelSystemTable: ["beszel", "mock"], coolify: ["coolify"], "immich-serverStats": ["immich"], "immich-albumCarousel": ["immich"], @@ -44,6 +48,16 @@ export const widgetIntegrationSupport: Partial(["calendar"]); + +/** Widgets that support fewer integrations than the default unlimited selection. */ +export const widgetIntegrationLimits: Partial> = { + audioStats: 1, }; export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index be60b55c3..d4a849a30 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1750,6 +1750,23 @@ "remoteCssTitle": "This CSS loads a remote resource", "remoteCssDescription": "Imported stylesheets and remote URLs contact another server when the board loads. Review them before continuing." }, + "configureWidget": { + "title": "Review widget", + "preparing": "Preparing widget settings", + "review": "Review widget and integrations", + "ready": "Widget configuration ready", + "cancelled": "Widget was not added", + "automaticContinue": "Accepting the proposed widget configuration automatically", + "loadErrorTitle": "Integrations could not be loaded", + "loadErrorDescription": "Homarr could not load the integrations available for this widget.", + "retry": "Try again", + "noIntegrationTitle": "A compatible integration is required", + "noIntegrationDescription": "Configure an integration supported by this widget before adding it to the dashboard.", + "manageIntegrations": "Manage integrations", + "skip": "Do not add", + "errorTitle": "Widget settings could not be prepared", + "errorDescription": "The model did not provide a complete widget configuration. No widget was added." + }, "approvalRequired": "Approval required", "complete": "Complete", "denied": "Denied", diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 3a29609d0..2ed7a3c85 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -128,5 +128,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([]), }); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 3edb6ffe5..bcfb75454 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -54,6 +54,9 @@ export const WidgetEditModal = createModal>(({ actions, i customError: zodErrorMap(t), }); const { definition } = widgetImports[innerProps.kind]; + const integrationSupport = "supportedIntegrations" in definition; + const integrationsRequired = + integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); const options = definition.createOptions(innerProps.settings) as Record; const optionsSuperRefine = (options as Record)[OPTIONS_SUPER_REFINE] as | ((data: Record, ctx: z.RefinementCtx) => void) @@ -78,7 +81,7 @@ export const WidgetEditModal = createModal>(({ actions, i validate: schemaResolver( z.object({ options: optionsSchema, - integrationIds: z.array(z.string()), + integrationIds: integrationsRequired ? z.array(z.string()).min(1) : z.array(z.string()), advancedOptions: z.object({ customCssClasses: z.array(z.string()), borderColor: z.string(), @@ -128,6 +131,7 @@ export const WidgetEditModal = createModal>(({ actions, i ((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1 } + withAsterisk={integrationsRequired} {...form.getInputProps("integrationIds")} /> )}