From 00fa1192d786bb1a4ea7df73ac6f73606916962a Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 14:51:20 +0200 Subject: [PATCH] feat: add assistant conversation widget --- apps/docs/docs/management/assistant.mdx | 4 + apps/docs/docs/widgets/assistant/index.mdx | 54 +++++ apps/docs/docs/widgets/assistant/index.ts | 25 ++ .../assistant/assistant-panel.module.css | 146 +++++++++++ .../components/assistant/assistant-panel.tsx | 228 +++++++++++------- .../assistant/assistant-provider.tsx | 28 ++- .../components/assistant/assistant-widget.tsx | 203 ++++++++++++++++ .../items/actions/test/create-item.spec.ts | 20 ++ .../components/board/items/item-content.tsx | 2 +- .../definitions/src/docs/widget-doc-slugs.ts | 1 + packages/definitions/src/widget.ts | 2 + packages/translation/src/lang/en.json | 36 +++ packages/widgets/src/assistant/component.tsx | 32 +++ packages/widgets/src/assistant/context.tsx | 20 ++ .../widgets/src/assistant/definition.spec.ts | 18 ++ packages/widgets/src/assistant/index.ts | 50 ++++ packages/widgets/src/index.tsx | 3 + 17 files changed, 779 insertions(+), 93 deletions(-) create mode 100644 apps/docs/docs/widgets/assistant/index.mdx create mode 100644 apps/docs/docs/widgets/assistant/index.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-widget.tsx create mode 100644 packages/widgets/src/assistant/component.tsx create mode 100644 packages/widgets/src/assistant/context.tsx create mode 100644 packages/widgets/src/assistant/definition.spec.ts create mode 100644 packages/widgets/src/assistant/index.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 725a08ce6..25c1497c1 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -24,6 +24,10 @@ conversation has a minimize action that returns it to the activity bar without i `Shift + A` opens the compact conversation panel directly. The shortcut is ignored while typing in text fields, selectors, and editable content. +The **Assistant** board widget embeds the same persisted conversation and full interaction surface directly +on a dashboard. By default it follows the current conversation. Widget settings can instead pin a specific +conversation; the conversation picker stays hidden until **Pinned conversation** is selected. + When the assistant is unavailable, its command-menu result remains visible but disabled and explains that an administrator must configure and enable it. diff --git a/apps/docs/docs/widgets/assistant/index.mdx b/apps/docs/docs/widgets/assistant/index.mdx new file mode 100644 index 000000000..9f7d174b7 --- /dev/null +++ b/apps/docs/docs/widgets/assistant/index.mdx @@ -0,0 +1,54 @@ +--- +title: "Assistant" +description: "Have a complete assistant conversation directly on a board." +hide_title: true +--- + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { assistantWidget } from "."; + + + +The Assistant widget brings the same conversation available from the user menu and command menu onto a +board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls, +usage details, and response actions remain synchronized with the app-wide assistant. + +The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users +on an instance without an enabled model see an unavailable state instead of a broken composer. + +### Adding the widget + + + +The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls +and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on +touch devices. When a board layout does not provide enough height for conversation controls, the widget shows +a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height +in that layout to keep the complete conversation inline. + +### Configuration + + + +**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or +the command menu. This is the default and keeps every assistant entry point synchronized. + +**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the +active conversation silently: select **Open conversation** inside the widget before continuing that thread. + +### Conversation features + +- Create, switch, rename, and delete conversations. +- Send text and supported file or image attachments. +- Mention permitted apps, integrations, boards, widgets, and tools with `@`. +- Use `/` shortcuts for common dashboard tasks. +- Review tool calls, approve mutations, and answer structured assistant questions inline. +- Edit user messages and copy, regenerate, branch, quote, export, or rate responses. +- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing, + speed, provider routing, and cost. +- Change the model and reasoning effort for the active conversation. + +The expanded assistant and board widget use the same persisted thread runtime. A response continues in the +background when the widget leaves the viewport or the expanded panel is minimized. diff --git a/apps/docs/docs/widgets/assistant/index.ts b/apps/docs/docs/widgets/assistant/index.ts new file mode 100644 index 000000000..ace552186 --- /dev/null +++ b/apps/docs/docs/widgets/assistant/index.ts @@ -0,0 +1,25 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconRobot } from "@tabler/icons-react"; + +export const assistantWidget: WidgetDefinition = { + icon: IconRobot, + name: "Assistant", + description: "Have a complete assistant conversation directly on a board.", + path: "../../widgets/assistant", + configuration: { + items: [ + { + name: "Conversation", + description: "Follow the active conversation or attach the widget to one persisted conversation.", + values: ["Current conversation", "Pinned conversation"], + defaultValue: "Current conversation", + }, + { + name: "Pinned conversation", + description: "Search for a conversation. This field is hidden when Current conversation is selected.", + values: { type: "string" }, + defaultValue: "Hidden", + }, + ], + }, +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index a0b4cdf9f..2077217c5 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -114,6 +114,38 @@ animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1); } +.widgetPanel { + container-name: assistant-widget; + container-type: size; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; + color: var(--mantine-color-text); +} + +.widgetState { + container-type: inline-size; + overflow: auto; +} + +.compactState { + width: 100%; + height: 100%; + padding: 0.75rem; +} + +.compactCopy { + min-width: 0; +} + +.widgetPanel[data-editing] .panelActions { + padding-inline-end: 2.75rem; +} + .panelHeader { min-height: 3.75rem; flex: 0 0 auto; @@ -139,6 +171,120 @@ height: 2.625rem; } +@container assistant-widget (max-width: 30rem) { + .panelHeader { + min-height: 3.25rem; + padding: 0.45rem 0.5rem 0.45rem 0.65rem; + } + + .panelIdentityText > :last-child, + .composerHints { + display: none; + } + + .historyButton { + width: 2.625rem; + max-width: 2.625rem; + height: 2.625rem; + padding-inline: 0.75rem; + } + + .historyButtonSection { + margin-inline-end: 0; + } + + .historyButtonLabel { + display: none; + } + + .messages { + padding: 0.5rem 0.65rem 1rem; + } + + .composerWrap { + padding: 0.45rem; + } + + .composer { + gap: 0.25rem; + padding: 0.25rem 0.3rem; + } + + .composerInput { + min-height: 2.625rem; + padding: 0.6rem 0.5rem; + } + + .runtimeButtonGroup, + .runtimeControls { + width: 100%; + } + + .modelButton { + min-width: 0; + max-width: none; + } + + .reasoningButton { + min-width: 2.5rem; + max-width: 7.75rem; + } + + .suggestions { + grid-template-columns: 1fr; + } +} + +@container assistant-widget (max-width: 22rem) { + .panelIdentityText { + display: none; + } + + .panelActions { + margin-inline-start: auto; + } + + .panelAction, + .historyButton { + width: 2.25rem; + max-width: 2.25rem; + height: 2.25rem; + padding-inline: 0.55rem; + } + + .composerFooter { + overflow: hidden; + } + + .runtimeButton { + padding-inline: 0.5rem; + } +} + +@container assistant-widget (max-width: 18rem) { + .compactDescription { + display: none; + } +} + +@container assistant-widget (max-width: 10rem) { + .compactTitle { + display: none; + } +} + +@container assistant-widget (max-height: 24rem) { + .empty { + min-height: 16rem; + padding-block: 1rem; + } + + .suggestion { + min-height: 2.5rem; + padding-block: 0.45rem; + } +} + .historyMenu { background: var(--mantine-color-body); } diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index b8e9ebd43..9e28ab3a5 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -59,6 +59,7 @@ import { IconAlertTriangle, IconApps, IconArrowUp, + IconArrowsMaximize, IconAt, IconBrain, IconCheck, @@ -105,7 +106,16 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; import { getToolResultPresentation } from "./assistant-tool-result"; -interface AssistantPanelProps { +export interface AssistantConversationControls { + modelId: string | null; + models: AssistantRuntimeModelOption[]; + modelOptionsLoading: boolean; + reasoning: AssistantReasoningMode; + onModelChange: (modelId: string) => void; + onReasoningChange: (reasoning: AssistantReasoningMode) => void; +} + +interface AssistantPanelProps extends AssistantConversationControls { opened: boolean; onOpen: () => void; onClose: () => void; @@ -117,12 +127,6 @@ interface AssistantPanelProps { latestUserText: string; latestStatus: MessageStatus | undefined; pendingAction: AssistantPendingAction | undefined; - modelId: string | null; - models: AssistantRuntimeModelOption[]; - modelOptionsLoading: boolean; - reasoning: AssistantReasoningMode; - onModelChange: (modelId: string) => void; - onReasoningChange: (reasoning: AssistantReasoningMode) => void; } const markdownRemarkPlugins = [remarkGfm, remarkBreaks]; @@ -1255,10 +1259,7 @@ const ComposerTriggers = () => { ); }; -type ComposerProps = Pick< - AssistantPanelProps, - "modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange" -> & { pendingAction: AssistantPendingAction | undefined }; +type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; const RuntimeControls = ({ modelId, @@ -1622,6 +1623,124 @@ const AssistantActivityBar = ({ ); }; +interface AssistantConversationSurfaceProps extends AssistantConversationControls { + isRunning: boolean; + pendingAction: AssistantPendingAction | undefined; + onExpand?: () => void; + onMinimize?: () => void; +} + +export const AssistantConversationSurface = ({ + isRunning, + pendingAction, + modelId, + models, + modelOptionsLoading, + reasoning, + onModelChange, + onReasoningChange, + onExpand, + onMinimize, +}: AssistantConversationSurfaceProps) => { + const t = useScopedI18n("common.assistant"); + + return ( + <> + + + + + +
+ + {t("title")} + + + {isRunning ? t("activity.thinking") : t("subtitle")} + +
+
+ + + + + + + + + + {onExpand && ( + + + + + + )} + {onMinimize && ( + + + + + + )} + +
+ + + + + + + + + + + + + + + + + + + + + + ); +}; + export const AssistantPanel = ({ opened, onOpen, @@ -1683,82 +1802,17 @@ export const AssistantPanel = ({ {opened && ( - - - - - -
- - {t("title")} - - - {isRunning ? t("activity.thinking") : t("subtitle")} - -
-
- - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - +
)} diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 7b2efeeb6..d8d973456 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -23,6 +23,7 @@ import { hotkeys } from "@homarr/definitions"; import { showErrorNotification, showWarningNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight"; +import { AssistantWidgetRendererProvider } from "@homarr/widgets"; import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit"; import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools"; @@ -35,9 +36,11 @@ 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"; +import { AssistantBoardWidget } from "./assistant-widget"; interface AssistantContextValue { enabled: boolean; + unavailableDescription: string | null; opened: boolean; isRunning: boolean; unreadCount: number; @@ -148,7 +151,7 @@ export const useHomarrAssistant = () => { export const useOptionalHomarrAssistant = () => useContext(AssistantContext); -const useAssistantPreferences = () => { +export const useAssistantPreferences = () => { const value = useContext(AssistantPreferencesContext); if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider"); return value; @@ -636,13 +639,23 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( - () => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }), + () => ({ + enabled: true, + unavailableDescription: null, + opened, + isRunning: assistantIsRunning, + unreadCount, + open, + close, + toggle, + sendPrompt, + }), [assistantIsRunning, close, open, opened, sendPrompt, toggle, unreadCount], ); return ( - {children} + {children} ({ enabled: false, + unavailableDescription: description, opened: false, isRunning: false, unreadCount: 0, @@ -700,10 +714,14 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP toggle: () => undefined, sendPrompt: () => undefined, }), - [], + [description], ); - return {children}; + return ( + + {children} + + ); }; export const AssistantProvider = ({ children }: PropsWithChildren) => { diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx new file mode 100644 index 000000000..cedd55018 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx @@ -0,0 +1,203 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import { useAui, useAuiState } from "@assistant-ui/react"; +import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; +import type { WidgetComponentProps } from "@homarr/widgets"; + +import classes from "./assistant-panel.module.css"; +import { AssistantConversationSurface } from "./assistant-panel"; +import { getPendingAssistantAction } from "./assistant-pending-action"; +import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider"; + +export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => { + const assistant = useHomarrAssistant(); + + if (!assistant.enabled) { + return ; + } + + return ; +}; + +const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => { + const t = useI18n(); + const assistant = useHomarrAssistant(); + const preferences = useAssistantPreferences(); + const aui = useAui(); + const currentThreadId = useAuiState((state) => state.threadListItem.remoteId); + const messages = useAuiState((state) => state.thread.messages); + const isLoading = useAuiState((state) => state.thread.isLoading); + const latestAssistantMessage = messages.findLast((message) => message.role === "assistant"); + const pendingAction = getPendingAssistantAction(latestAssistantMessage); + const pinnedConversation = options.conversationMode === "pinned" ? options.conversation : null; + const pinnedConversationIsActive = pinnedConversation === null || pinnedConversation.value === currentThreadId; + const [switching, setSwitching] = useState(false); + const [switchErrorConversationId, setSwitchErrorConversationId] = useState(null); + const hasSwitchError = switchErrorConversationId === pinnedConversation?.value; + + const selectModel = useCallback( + (modelId: string) => { + preferences.setModelId(modelId); + const threadListItem = aui.threadListItem(); + if (!threadListItem.getState().remoteId) return; + threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); + }, + [aui, preferences], + ); + + const openPinnedConversation = async () => { + if (!pinnedConversation) return; + setSwitchErrorConversationId(null); + setSwitching(true); + try { + await aui.threads().switchToThread(pinnedConversation.value); + } catch { + setSwitchErrorConversationId(pinnedConversation.value); + } finally { + setSwitching(false); + } + }; + + if (options.conversationMode === "pinned" && pinnedConversation === null) { + return ( + + ); + } + + if (!pinnedConversationIsActive && pinnedConversation) { + return ( + + + + ); + } + + if (width < 300 || height < 280) { + return ; + } + + return ( + + + + ); +}; + +const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => { + const t = useI18n(); + + return ( + + + + + + + + + {t("widget.assistant.compact.title")} + + + {t("widget.assistant.compact.description")} + + + + + + + + + + + ); +}; + +interface AssistantWidgetStateProps { + icon: typeof IconRobot; + title: string; + description: string; + color?: string; + children?: ReactNode; +} + +const AssistantWidgetState = ({ + icon: Icon, + title, + description, + color = "red", + children, +}: AssistantWidgetStateProps) => ( +
+ + + + + + {title} + + {description} + + + {children} + +
+); + +const UnavailableAssistantWidget = ({ description }: { description: string | null }) => { + const t = useI18n(); + return ( + + ); +}; diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index 7f97054ba..9378a3dea 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -129,6 +129,26 @@ describe("item actions create-item", () => { ); }); + test("should fit a wide widget to every board layout", () => { + const layoutId = "compact-layout"; + const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build(); + const board = new BoardMockBuilder().addLayout(layout).addEmptySection({ id: "section" }).build(); + const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts"); + layoutsSpy.mockReturnValue([layoutId]); + const emptyPositionSpy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition"); + emptyPositionSpy.mockReturnValue({ xOffset: 0, yOffset: 0 }); + + const result = createItemCallback({ kind: "assistant" })(board); + + expect(result.items.at(0)?.layouts.at(0)).toEqual( + expect.objectContaining({ width: layout.columnCount, height: 4 }), + ); + expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { + width: layout.columnCount, + height: 4, + }); + }); + test("clamps wide defaults and places below a full automatic canvas", () => { const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build(); const layout = board.layouts[0]; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 614f9017e..84385e5a1 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -47,7 +47,7 @@ interface BoardItemContentProps { const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => { if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const; - if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const; + if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const; if (hasCustomCssClasses) return {}; return { overflowX: "hidden", overflowY: "auto" } as const; }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index cf72dbda4..dd3f02e37 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -55,5 +55,6 @@ export const widgetDocSlugs: Record = { beszelSystemStats: "beszel-system-stats", traefik: "traefik", customApi: "custom-api", + assistant: "assistant", wud: "whats-up-docker", }; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index e95dbca77..90274f3d8 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -53,6 +53,7 @@ export const widgetKinds = [ "beszelSystemStats", "traefik", "customApi", + "assistant", "wud", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; @@ -64,4 +65,5 @@ export const widgetDefaultSizes: Partial