feat: add assistant conversation widget
This commit is contained in:
1 parent
c7b0c1bcf0
commit
00fa1192d7
17 files changed
+779
-93
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ".";
|
||||
|
||||
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
|
||||
|
||||
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
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
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
|
||||
|
||||
<WidgetConfig configuration={assistantWidget.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.
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="red" radius="xl">
|
||||
<IconRobot size={18} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.panelIdentityText}>
|
||||
<Text fw={700} lh={1.1} lineClamp={1}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{isRunning ? t("activity.thinking") : t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
<Tooltip label={t("activity.expand")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onExpand}
|
||||
aria-label={t("activity.expand")}
|
||||
>
|
||||
<IconArrowsMaximize size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onMinimize && (
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMinimize}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<ThreadPrimitive.Root className={classes.thread}>
|
||||
<ThreadPrimitive.Viewport className={classes.viewport}>
|
||||
<Box className={classes.messages}>
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
</Box>
|
||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||
<SelectionToolbarPrimitive.Quote asChild>
|
||||
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
|
||||
{t("quoteSelection")}
|
||||
</Button>
|
||||
</SelectionToolbarPrimitive.Quote>
|
||||
</SelectionToolbarPrimitive.Root>
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<ActionIcon
|
||||
className={classes.scrollToBottom}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
aria-label={t("scrollToLatest")}
|
||||
>
|
||||
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
|
||||
</ActionIcon>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<PendingActionBanner pendingAction={pendingAction} />
|
||||
<Composer
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ThreadPrimitive.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantPanel = ({
|
||||
opened,
|
||||
onOpen,
|
||||
@@ -1683,82 +1802,17 @@ export const AssistantPanel = ({
|
||||
{opened && (
|
||||
<FocusTrap active>
|
||||
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="red" radius="xl">
|
||||
<IconRobot size={18} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.panelIdentityText}>
|
||||
<Text fw={700} lh={1.1} lineClamp={1}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{isRunning ? t("activity.thinking") : t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<ThreadPrimitive.Root className={classes.thread}>
|
||||
<ThreadPrimitive.Viewport className={classes.viewport}>
|
||||
<Box className={classes.messages}>
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
</Box>
|
||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||
<SelectionToolbarPrimitive.Quote asChild>
|
||||
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
|
||||
{t("quoteSelection")}
|
||||
</Button>
|
||||
</SelectionToolbarPrimitive.Quote>
|
||||
</SelectionToolbarPrimitive.Root>
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<ActionIcon
|
||||
className={classes.scrollToBottom}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
aria-label={t("scrollToLatest")}
|
||||
>
|
||||
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
|
||||
</ActionIcon>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<PendingActionBanner pendingAction={pendingAction} />
|
||||
<Composer
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ThreadPrimitive.Root>
|
||||
<AssistantConversationSurface
|
||||
isRunning={isRunning}
|
||||
pendingAction={pendingAction}
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onMinimize={onClose}
|
||||
/>
|
||||
</dialog>
|
||||
</FocusTrap>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<AssistantContext.Provider value={value}>
|
||||
{children}
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
@@ -692,6 +705,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
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 <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
@@ -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 <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||
}
|
||||
|
||||
return <EnabledAssistantBoardWidget {...props} />;
|
||||
};
|
||||
|
||||
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<string | null>(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 (
|
||||
<AssistantWidgetState
|
||||
icon={IconMessage}
|
||||
title={t("widget.assistant.pinned.selectTitle")}
|
||||
description={t("widget.assistant.pinned.selectDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pinnedConversationIsActive && pinnedConversation) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={hasSwitchError ? IconAlertTriangle : IconMessage}
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
title={t("widget.assistant.pinned.title")}
|
||||
description={
|
||||
hasSwitchError
|
||||
? t("widget.assistant.pinned.error")
|
||||
: t("widget.assistant.pinned.description", { conversation: pinnedConversation.label })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
onClick={() => void openPinnedConversation()}
|
||||
loading={switching}
|
||||
leftSection={switching ? <Loader type="bars" size="xs" /> : <IconMessage size={16} />}
|
||||
>
|
||||
{switching ? t("widget.assistant.pinned.opening") : t("widget.assistant.pinned.open")}
|
||||
</Button>
|
||||
</AssistantWidgetState>
|
||||
);
|
||||
}
|
||||
|
||||
if (width < 300 || height < 280) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes.widgetPanel}
|
||||
data-compact={width < 420 || height < 420 || undefined}
|
||||
data-editing={isEditMode || undefined}
|
||||
aria-busy={isLoading || assistant.isRunning}
|
||||
>
|
||||
<AssistantConversationSurface
|
||||
isRunning={assistant.isRunning}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
onExpand={assistant.open}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Box className={classes.widgetPanel}>
|
||||
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="xs" miw={0}>
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
|
||||
<IconRobot size={19} />
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||
{t("widget.assistant.compact.title")}
|
||||
</Text>
|
||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||
{t("widget.assistant.compact.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={onOpen}
|
||||
aria-label={t("widget.assistant.compact.open")}
|
||||
>
|
||||
<IconArrowsMaximize size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface AssistantWidgetStateProps {
|
||||
icon: typeof IconRobot;
|
||||
title: string;
|
||||
description: string;
|
||||
color?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const AssistantWidgetState = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
color = "red",
|
||||
children,
|
||||
}: AssistantWidgetStateProps) => (
|
||||
<Center className={classes.widgetState} h="100%" p="md">
|
||||
<Stack align="center" gap="sm" ta="center" maw={360}>
|
||||
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
||||
<Icon size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={3} align="center">
|
||||
<Text fw={700}>{title}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
const UnavailableAssistantWidget = ({ description }: { description: string | null }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={IconRobot}
|
||||
title={t("widget.assistant.unavailable.title")}
|
||||
description={description ?? t("widget.assistant.unavailable.description")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -55,5 +55,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
assistant: "assistant",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -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<Record<WidgetKind, { width: number; hei
|
||||
patchmon: { width: 2, height: 2 },
|
||||
mediaMissing: { width: 4, height: 3 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
assistant: { width: 10, height: 4 },
|
||||
};
|
||||
@@ -4873,6 +4873,42 @@
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
"name": "Assistant",
|
||||
"description": "Have a complete assistant conversation directly on your board.",
|
||||
"option": {
|
||||
"conversationMode": {
|
||||
"label": "Conversation",
|
||||
"description": "Follow the conversation used elsewhere in Homarr, or keep this widget attached to a specific conversation.",
|
||||
"option": {
|
||||
"current": "Current conversation",
|
||||
"pinned": "Pinned conversation"
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"label": "Pinned conversation",
|
||||
"description": "This selector stays hidden while the widget follows the current conversation."
|
||||
}
|
||||
},
|
||||
"pinned": {
|
||||
"title": "Pinned conversation",
|
||||
"description": "This widget is attached to {conversation}. Open it to continue here without silently changing your active conversation.",
|
||||
"selectTitle": "Choose a conversation",
|
||||
"selectDescription": "Open the widget settings and select the conversation this widget should keep available.",
|
||||
"open": "Open conversation",
|
||||
"opening": "Opening conversation...",
|
||||
"error": "This conversation could not be opened. It may have been deleted."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Assistant unavailable",
|
||||
"description": "Sign in and ask an administrator to configure the Assistant from the management panel."
|
||||
},
|
||||
"compact": {
|
||||
"title": "Assistant",
|
||||
"description": "Resize this widget to chat here, or open the full conversation.",
|
||||
"open": "Open assistant"
|
||||
}
|
||||
},
|
||||
"customApi": {
|
||||
"name": "Custom widget",
|
||||
"description": "Render a configured Custom JSX definition.",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useAssistantWidgetRenderer } from "./context";
|
||||
|
||||
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
|
||||
const Renderer = useAssistantWidgetRenderer();
|
||||
const t = useI18n();
|
||||
|
||||
if (Renderer) return <Renderer {...props} />;
|
||||
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap="xs" ta="center">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
||||
<IconRobot size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("widget.assistant.unavailable.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.assistant.unavailable.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
type AssistantWidgetRenderer = ComponentType<WidgetComponentProps<"assistant">>;
|
||||
|
||||
const AssistantWidgetRendererContext = createContext<AssistantWidgetRenderer | null>(null);
|
||||
|
||||
interface AssistantWidgetRendererProviderProps extends PropsWithChildren {
|
||||
renderer: AssistantWidgetRenderer;
|
||||
}
|
||||
|
||||
export const AssistantWidgetRendererProvider = ({ children, renderer }: AssistantWidgetRendererProviderProps) => (
|
||||
<AssistantWidgetRendererContext.Provider value={renderer}>{children}</AssistantWidgetRendererContext.Provider>
|
||||
);
|
||||
|
||||
export const useAssistantWidgetRenderer = () => useContext(AssistantWidgetRendererContext);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { definition } from ".";
|
||||
|
||||
describe("assistant widget definition", () => {
|
||||
it("follows the current conversation by default", () => {
|
||||
const options = definition.createOptions();
|
||||
|
||||
expect(options.conversationMode.defaultValue).toBe("current");
|
||||
expect(options.conversation.shouldHide?.({ conversationMode: "current", conversation: null }, [])).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals the conversation selector only when a conversation is pinned", () => {
|
||||
const options = definition.createOptions();
|
||||
|
||||
expect(options.conversation.shouldHide?.({ conversationMode: "pinned", conversation: null }, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("assistant", {
|
||||
icon: IconRobot,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
conversationMode: factory.select({
|
||||
options: (["current", "pinned"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.assistant.option.conversationMode.option.${value}`),
|
||||
})),
|
||||
defaultValue: "current",
|
||||
withDescription: true,
|
||||
}),
|
||||
conversation: factory.dynamicSelect({
|
||||
withDescription: true,
|
||||
useOptions(query) {
|
||||
const { data: threads = [], isPending } = clientApi.assistant.listThreads.useQuery();
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return {
|
||||
isPending,
|
||||
options: threads
|
||||
.filter((thread) => {
|
||||
if (normalizedQuery.length === 0) return true;
|
||||
return (thread.title ?? "").toLocaleLowerCase().includes(normalizedQuery);
|
||||
})
|
||||
.map((thread) => ({
|
||||
value: thread.id,
|
||||
label: thread.title?.trim() || thread.id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
conversation: {
|
||||
shouldHide: (options) => options.conversationMode !== "pinned",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
export { AssistantWidgetRendererProvider } from "./context";
|
||||
@@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
import * as customApi from "./custom-api";
|
||||
import * as assistant from "./assistant";
|
||||
import * as wud from "./wud";
|
||||
|
||||
export type {
|
||||
@@ -88,6 +89,7 @@ export {
|
||||
} from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
export { AssistantWidgetRendererProvider } from "./assistant/context";
|
||||
|
||||
export const widgetImports = {
|
||||
clock,
|
||||
@@ -144,6 +146,7 @@ export const widgetImports = {
|
||||
beszelSystemStats,
|
||||
traefik,
|
||||
customApi,
|
||||
assistant,
|
||||
wud,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
|
||||
Reference in new issue
Block a user