feat: streamline assistant interactions

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 27448868dd
commit 63699b1610
7 files changed
+721 -222

No files matched your search

+12 -4
View File
@@ -9,8 +9,15 @@ tags:
# Assistant
Administrators can configure the instance-wide AI assistant from **Management → Assistant**.
It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu.
`Cmd/Ctrl + Shift + K` opens it directly.
It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu. Selecting
**Ask Homarr Assistant** in the command menu turns its search field into a prompt composer. Press
`Enter` to send the prompt in the background without opening the conversation.
While a response is running, a compact activity bar appears at the bottom of the page and the user-menu
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
responses add an unread count to the user menu until they are opened or dismissed.
`Cmd/Ctrl + Shift + K` opens the compact conversation panel directly.
When the assistant is unavailable, its command-menu result remains visible but disabled and explains that
an administrator must configure and enable it.
@@ -56,8 +63,9 @@ uses. Choose OpenRouter or another provider when you prefer a provider-neutral p
## Conversations and tools
Conversations and messages are stored per user in the Homarr database. Users can start, revisit, archive,
and delete conversations from the assistant panel.
Conversations and messages are stored per user in the Homarr database. The assistant stays out of the
page layout until it is opened. Users can start, revisit, archive, and delete conversations from the
conversation menu in the floating panel.
The assistant can use the same internal tool definitions as the [Homarr MCP server](./mcp).
Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only
@@ -1,28 +1,75 @@
.content {
height: 100dvh;
.activityBar {
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: 1rem;
display: flex;
width: min(31rem, calc(100vw - 1rem));
min-height: 3.5rem;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0.4rem 0.35rem 0.5rem;
transform: translateX(-50%);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
background: var(--mantine-color-body);
animation: assistant-activity-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.body {
display: flex;
height: calc(100dvh - 4.25rem);
padding: 0;
overflow: hidden;
}
.shell {
position: relative;
display: flex;
width: 100%;
.activityTrigger {
min-width: 0;
flex: 1;
padding: 0.15rem;
border-radius: var(--mantine-radius-md);
text-align: start;
}
.history {
width: 15rem;
flex: 0 0 15rem;
border-inline-end: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 92%, var(--mantine-color-gray-6));
transition: transform 180ms ease;
.activityTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.activityCopy {
min-width: 0;
flex: 1;
}
.activityExpandIcon {
flex: 0 0 auto;
color: var(--mantine-color-dimmed);
}
.floatingPanel {
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: 1rem;
display: flex;
width: min(46rem, calc(100vw - 2rem));
height: min(36rem, calc(100dvh - 5rem));
min-height: 22rem;
margin: 0;
transform: translateX(-50%);
flex-direction: column;
overflow: hidden;
padding: 0;
border: 0;
border-radius: var(--mantine-radius-lg);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-xl);
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.panelHeader {
min-height: 3.75rem;
flex: 0 0 auto;
padding: 0.65rem 0.75rem 0.65rem 0.9rem;
border-bottom: 1px solid var(--mantine-color-default-border);
}
.historyMenu {
background: var(--mantine-color-body);
}
.historyItem {
@@ -37,6 +84,7 @@
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
}
@@ -52,21 +100,21 @@
.messages {
width: 100%;
max-width: 52rem;
max-width: 44rem;
margin-inline: auto;
padding: 1.25rem 1rem 8.75rem;
padding: 0.75rem 1rem 1.5rem;
}
.message {
margin-block: 1rem;
margin-block: 0.9rem;
}
.userMessage {
width: fit-content;
max-width: 90%;
max-width: 86%;
margin-inline-start: auto;
padding: 0.7rem 0.9rem;
border-radius: 1rem 1rem 0.3rem 1rem;
padding: 0.65rem 0.85rem;
border-radius: 0.9rem 0.9rem 0.25rem 0.9rem;
background: var(--mantine-color-red-light);
}
@@ -75,7 +123,7 @@
}
.messageText {
line-height: 1.65;
line-height: 1.6;
overflow-wrap: anywhere;
}
@@ -102,32 +150,32 @@
}
.composerWrap {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 2.5rem 1rem 1rem;
background: var(--mantine-color-body);
flex: 0 0 auto;
padding: 0.65rem 0.75rem 0.75rem;
border-top: 1px solid var(--mantine-color-default-border);
pointer-events: none;
background: var(--mantine-color-body);
}
.composer {
display: flex;
width: 100%;
max-width: 52rem;
max-width: 44rem;
margin-inline: auto;
align-items: flex-end;
gap: 0.5rem;
padding: 0.45rem;
border-radius: 1rem;
padding: 0.4rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
background: var(--mantine-color-default);
box-shadow: var(--mantine-shadow-sm);
pointer-events: auto;
}
.composer:focus-within {
border-color: var(--mantine-primary-color-filled);
}
.composerInput {
min-height: 2.5rem;
max-height: 12rem;
max-height: 9rem;
flex: 1;
resize: none;
border: 0;
@@ -140,10 +188,10 @@
.empty {
display: flex;
min-height: 60vh;
min-height: 23rem;
align-items: center;
justify-content: center;
padding: 3rem 1rem 8rem;
padding: 2rem 1rem;
text-align: center;
}
@@ -151,67 +199,98 @@
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
gap: 0.55rem;
}
.suggestion {
height: auto;
min-height: 3.25rem;
min-height: 3rem;
justify-content: flex-start;
padding: 0.7rem 0.9rem;
padding: 0.65rem 0.8rem;
text-align: start;
white-space: normal;
}
.mobileHistoryButton,
.historyClose {
display: none;
.scrollToBottom {
position: absolute;
inset-inline-start: 50%;
bottom: 0.75rem;
transform: translateX(-50%);
}
@keyframes assistant-activity-in {
from {
opacity: 0;
transform: translate(-50%, 0.5rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes assistant-panel-in {
from {
opacity: 0;
transform: translate(-50%, 0.75rem) scale(0.985);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
@media (max-width: 48em) {
.history {
position: absolute;
z-index: 4;
inset-block: 0;
inset-inline-start: 0;
width: min(20rem, 88vw);
transform: translateX(-105%);
visibility: hidden;
transition:
transform 180ms ease,
visibility 180ms ease;
box-shadow: var(--mantine-shadow-xl);
.activityBar {
bottom: 0.5rem;
}
[dir="rtl"] .history {
transform: translateX(105%);
.floatingPanel {
bottom: 0.5rem;
width: calc(100vw - 1rem);
height: min(44rem, calc(100dvh - 1rem));
min-height: 20rem;
}
.history[data-opened="true"] {
transform: translateX(0);
visibility: visible;
}
.mobileHistoryButton {
position: absolute;
z-index: 3;
top: 0.65rem;
inset-inline-start: 0.65rem;
display: inline-flex;
}
.historyClose {
display: inline-flex;
.panelHeader {
padding-inline-start: 0.7rem;
}
.suggestions {
grid-template-columns: 1fr;
}
.empty {
min-height: 32rem;
padding-block: 1.5rem;
}
}
@media (max-width: 30em) {
.historyButton {
max-width: 2.25rem;
padding-inline: 0.5rem;
}
.historyButton :global(.mantine-Button-section) {
margin-inline-end: 0;
}
.historyButton :global(.mantine-Button-label) {
display: none;
}
.messages {
padding-inline: 0.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.history {
transition: none;
.activityBar,
.floatingPanel {
animation: none;
}
.viewport {
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { createContext, useContext, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import type { MessageStatus } from "@assistant-ui/react";
import {
ActionBarPrimitive,
ComposerPrimitive,
@@ -13,8 +14,22 @@ import {
ThreadPrimitive,
useAuiState,
} from "@assistant-ui/react";
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Popover,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import {
IconActivityHeartbeat,
IconAlertTriangle,
@@ -22,9 +37,9 @@ import {
IconArchive,
IconArrowUp,
IconCheck,
IconChevronUp,
IconCopy,
IconCommand,
IconMenu2,
IconHistory,
IconPlayerStop,
IconPlus,
@@ -42,7 +57,14 @@ import classes from "./assistant-panel.module.css";
interface AssistantPanelProps {
opened: boolean;
onOpen: () => void;
onClose: () => void;
onMarkRead: () => void;
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
latestUserText: string;
latestStatus: MessageStatus | undefined;
}
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
@@ -194,13 +216,24 @@ const AssistantMessage = () => (
</MessagePrimitive.Root>
);
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button variant="subtle" color="gray" size="compact-sm" justify="flex-start" flex={1} style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
@@ -221,11 +254,20 @@ const ThreadListItem = () => {
const ArchivedThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button variant="subtle" color="gray" size="compact-sm" justify="flex-start" flex={1} style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
>
<ThreadListItemPrimitive.Title fallback={t("archivedConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
@@ -244,51 +286,66 @@ const ArchivedThreadListItem = () => {
);
};
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => void }) => {
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant");
const isMobile = useMediaQuery("(max-width: 48em)");
const hidden = isMobile && !opened;
return (
<Stack
className={classes.history}
gap="xs"
p="sm"
data-opened={opened}
inert={hidden ? true : undefined}
aria-hidden={hidden || undefined}
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
<Group gap="xs" wrap="nowrap" px={4} pt={4}>
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onSelect}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea h="min(24rem, 55dvh)" type="auto" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<Text size="sm" fw={600} c="dimmed" px="xs" mt="sm">
{t("archived")}
</Text>
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
</Stack>
</ScrollArea>
</Stack>
</HistorySelectContext.Provider>
);
};
const ConversationHistory = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Group gap="xs" wrap="nowrap">
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onClose}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
<ActionIcon
className={classes.historyClose}
<Popover.Target>
<Button
className={classes.historyButton}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("closeHistory")}
size="compact-sm"
leftSection={<IconHistory size={16} />}
onClick={() => setOpened((current) => !current)}
>
<IconX size={17} />
</ActionIcon>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea flex={1} type="auto">
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<Text size="sm" fw={600} c="dimmed" px="xs" mt="sm">
{t("archived")}
</Text>
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
</Stack>
</ScrollArea>
</Stack>
</Button>
</Popover.Target>
<Popover.Dropdown p={0}>
<ThreadHistory onSelect={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
};
@@ -366,67 +423,160 @@ const Composer = () => {
);
};
export const AssistantPanel = ({ opened, onClose }: AssistantPanelProps) => {
const AssistantActivityBar = ({
isRunning,
unreadCount,
latestAssistantText,
latestUserText,
latestStatus,
onOpen,
onMarkRead,
}: Omit<AssistantPanelProps, "opened" | "onClose">) => {
const t = useScopedI18n("common.assistant");
const [historyOpened, setHistoryOpened] = useState(false);
const needsApproval = latestStatus?.type === "requires-action";
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
const visible = isRunning || unreadCount > 0 || needsApproval;
if (!visible) return null;
const title = isRunning
? t("activity.thinking")
: needsApproval
? t("activity.approval")
: failed
? t("activity.failed")
: t("activity.ready");
const detail = isRunning
? latestUserText || t("activity.working")
: latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="min(52rem, 100vw)"
zIndex={300}
className={classes.drawer}
classNames={{ content: classes.content, body: classes.body }}
title={
<Group gap="xs">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div>
<Text fw={700} lh={1.1}>
{t("title")}
<Box component="output" className={classes.activityBar} aria-live="polite">
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
<Group gap="sm" wrap="nowrap">
{isRunning ? (
<ThemeIcon variant="light" color="red" radius="xl">
<Loader type="bars" size="sm" color="red" />
</ThemeIcon>
) : (
<ThemeIcon variant="light" color={failed ? "red" : needsApproval ? "yellow" : "green"} radius="xl">
{failed ? (
<IconAlertTriangle size={17} />
) : needsApproval ? (
<IconRobot size={17} />
) : (
<IconCheck size={17} />
)}
</ThemeIcon>
)}
<Stack gap={0} className={classes.activityCopy}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={700}>
{title}
</Text>
{unreadCount > 0 && (
<Badge size="xs" variant="filled" color="red" circle>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" lineClamp={1}>
{detail}
</Text>
<Text size="xs" c="dimmed">
{t("subtitle")}
</Text>
</div>
</Stack>
<IconChevronUp size={17} className={classes.activityExpandIcon} />
</Group>
}
closeButtonProps={{ icon: <IconX size={18} />, "aria-label": t("close") }}
>
<Box className={classes.shell}>
<ThreadHistory opened={historyOpened} onClose={() => setHistoryOpened(false)} />
<ThreadPrimitive.Root className={classes.thread}>
<ActionIcon
className={classes.mobileHistoryButton}
variant="default"
onClick={() => setHistoryOpened(true)}
aria-label={t("openHistory")}
>
<IconMenu2 size={17} />
</ActionIcon>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
pos="absolute"
bottom={110}
left="50%"
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>
</Box>
</Drawer>
</UnstyledButton>
{!isRunning && !needsApproval && (
<ActionIcon variant="subtle" color="gray" size="sm" onClick={onMarkRead} aria-label={t("activity.dismiss")}>
<IconX size={15} />
</ActionIcon>
)}
</Box>
);
};
export const AssistantPanel = ({
opened,
onOpen,
onClose,
onMarkRead,
isRunning,
unreadCount,
latestAssistantText,
latestUserText,
latestStatus,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") onClose();
});
return (
<>
{!opened && (
<AssistantActivityBar
onOpen={onOpen}
onMarkRead={onMarkRead}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={latestUserText}
latestStatus={latestStatus}
/>
)}
{opened && (
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div>
<Text fw={700} lh={1.1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon variant="subtle" color="gray" aria-label={t("newConversation")}>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<ActionIcon variant="subtle" color="gray" onClick={onClose} aria-label={t("close")}>
<IconX size={18} />
</ActionIcon>
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<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>
<Composer />
</ThreadPrimitive.Root>
</dialog>
)}
</>
);
};
@@ -1,9 +1,15 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, Toolkit } from "@assistant-ui/react";
import type {
MessageFormatAdapter,
RemoteThreadListAdapter,
ThreadHistoryAdapter,
ThreadMessage,
Toolkit,
} from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
defineToolkit,
@@ -21,7 +27,7 @@ import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import {
openMediaRequestSearch,
@@ -31,14 +37,18 @@ import {
} from "@homarr/spotlight";
import { AssistantPanel } from "./assistant-panel";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
interface AssistantContextValue {
enabled: boolean;
opened: boolean;
isRunning: boolean;
unreadCount: number;
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => void;
}
const AssistantContext = createContext<AssistantContextValue | null>(null);
@@ -267,58 +277,189 @@ const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
return null;
};
export const AssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
const [opened, setOpened] = useState(false);
const { data, isLoading: isAvailabilityLoading } = clientApi.assistant.getAvailability.useQuery(undefined, {
enabled: session.status === "authenticated",
staleTime: 60_000,
});
const enabled = Boolean(data?.enabled && session.status === "authenticated");
const getMessageText = (message: ThreadMessage | undefined) =>
message?.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim() ?? "";
const open = useCallback(() => setOpened(true), []);
const getLatestMessage = (messages: readonly ThreadMessage[], role: ThreadMessage["role"]) =>
messages.findLast((message) => message.role === role);
const getNotificationKey = (message: ThreadMessage | undefined) => {
if (message?.role !== "assistant") return null;
if (message.status.type === "running") return null;
return `${message.id}:${message.status.type}:${"reason" in message.status ? message.status.reason : ""}`;
};
const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const aui = useAui();
const messages = useAuiState((state) => state.thread.messages);
const isRunning = useAuiState((state) => state.thread.isRunning);
const isLoading = useAuiState((state) => state.thread.isLoading);
const latestAssistantMessage = getLatestMessage(messages, "assistant");
const latestUserMessage = getLatestMessage(messages, "user");
const latestAssistantText = getMessageText(latestAssistantMessage);
const latestUserText = getMessageText(latestUserMessage);
const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined;
const notificationKey = getNotificationKey(latestAssistantMessage);
const initializedRef = useRef(false);
const lastNotifiedKeyRef = useRef<string | null>(null);
const markRead = useCallback(() => {
lastNotifiedKeyRef.current = notificationKey;
setUnreadCount(0);
}, [notificationKey]);
const open = useCallback(() => {
markRead();
setOpened(true);
}, [markRead]);
const close = useCallback(() => setOpened(false), []);
const toggle = useCallback(() => setOpened((current) => !current), []);
const openIfEnabled = useCallback(() => {
if (enabled) {
open();
const toggle = useCallback(() => {
setOpened((current) => {
if (!current) markRead();
return !current;
});
}, [markRead]);
const sendPrompt = useCallback(
(prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return;
if (isRunning || latestStatus?.type === "requires-action") {
showWarningNotification({
title: t("busy.title"),
message: t("busy.description"),
});
return;
}
aui.thread().append({ role: "user", content: [{ type: "text", text }] });
},
[aui, isRunning, latestStatus?.type, t],
);
useEffect(() => {
if (isLoading) return;
if (!initializedRef.current) {
initializedRef.current = true;
lastNotifiedKeyRef.current = notificationKey;
return;
}
}, [enabled, open]);
useHotkeys([[hotkeys.openAssistant, openIfEnabled]]);
if (notificationKey === null || notificationKey === lastNotifiedKeyRef.current) return;
if (opened) {
lastNotifiedKeyRef.current = notificationKey;
setUnreadCount(0);
return;
}
lastNotifiedKeyRef.current = notificationKey;
setUnreadCount((current) => current + 1);
}, [isLoading, notificationKey, opened]);
useHotkeys([[hotkeys.openAssistant, open]]);
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description: enabled
? t("spotlightDescription")
: session.status !== "authenticated"
? t("unavailable.signIn")
: isAvailabilityLoading
? t("unavailable.checking")
: t("unavailable.notConfigured"),
unavailable: !enabled,
interaction: () => (enabled ? { type: "javaScript" as const, onSelect: open } : { type: "none" as const }),
description: t("spotlightDescription"),
interaction: () => createAssistantPromptInteraction({ sendPrompt }),
}),
[enabled, isAvailabilityLoading, open, session.status, t],
[sendPrompt, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(() => ({ enabled, opened, open, close, toggle }), [close, enabled, open, opened, toggle]);
const value = useMemo(
() => ({ enabled: true, opened, isRunning, unreadCount, open, close, toggle, sendPrompt }),
[close, isRunning, open, opened, sendPrompt, toggle, unreadCount],
);
return (
<AssistantContext.Provider value={value}>
{enabled ? (
<AssistantRuntime>
{children}
<AssistantPanel opened={opened} onClose={close} />
</AssistantRuntime>
) : (
children
)}
{children}
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onMarkRead={markRead}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={latestUserText}
latestStatus={latestStatus}
/>
</AssistantContext.Provider>
);
};
interface DisabledAssistantProviderProps extends PropsWithChildren {
description: string;
}
const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => {
const t = useScopedI18n("common.assistant");
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description,
unavailable: true,
interaction: () => ({ type: "none" as const }),
}),
[description, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(
() => ({
enabled: false,
opened: false,
isRunning: false,
unreadCount: 0,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => undefined,
}),
[],
);
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
};
export const AssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
const { data, isLoading } = clientApi.assistant.getAvailability.useQuery(undefined, {
enabled: session.status === "authenticated",
staleTime: 60_000,
});
const enabled = Boolean(data?.enabled && session.status === "authenticated");
if (enabled) {
return (
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
);
}
const unavailableDescription =
session.status !== "authenticated"
? t("unavailable.signIn")
: isLoading
? t("unavailable.checking")
: t("unavailable.notConfigured");
return <DisabledAssistantProvider description={unavailableDescription}>{children}</DisabledAssistantProvider>;
};
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
describe("createAssistantPromptInteraction", () => {
it("keeps Spotlight open when the prompt is empty", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt });
const [action] = interaction.useActions({}, " ");
expect(action).toBeDefined();
if (!action) return;
const sendInteraction = action.useInteraction();
expect(sendInteraction.type).toBe("javaScript");
if (sendInteraction.type !== "javaScript") return;
sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).not.toHaveBeenCalled();
});
it("trims and sends a prompt in the background", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt });
const [action] = interaction.useActions({}, " Check my services ");
expect(action).toBeDefined();
if (!action) return;
const sendInteraction = action.useInteraction();
expect(sendInteraction.type).toBe("javaScript");
if (sendInteraction.type !== "javaScript") return;
sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(true);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
});
});
@@ -0,0 +1,63 @@
"use client";
import { Group, Kbd, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconArrowUp, IconRobot } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface AssistantPromptInteractionOptions {
sendPrompt: (prompt: string) => void;
}
const AssistantPromptDetail = () => {
const t = useScopedI18n("common.assistant");
return (
<Group px="md" py="sm" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl">
<IconRobot size={17} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600}>{t("spotlightPrompt.title")}</Text>
<Text size="xs" c="dimmed">
{t("spotlightPrompt.description")}
</Text>
</Stack>
</Group>
);
};
const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => {
const t = useScopedI18n("common.assistant");
return (
<Group w="100%" px="md" py="xs" justify="space-between" wrap="nowrap" opacity={hasPrompt ? 1 : 0.6}>
<Group gap="sm" wrap="nowrap">
<IconArrowUp size={18} />
<Text>{hasPrompt ? t("spotlightPrompt.send") : t("spotlightPrompt.empty")}</Text>
</Group>
<Kbd size="sm">Enter</Kbd>
</Group>
);
};
export const createAssistantPromptInteraction = ({ sendPrompt }: AssistantPromptInteractionOptions) =>
({
type: "children",
option: {},
DetailComponent: AssistantPromptDetail,
useActions: (_options: Record<string, unknown>, query: string) => {
const prompt = query.trim();
return [
{
key: "send-assistant-prompt",
Component: () => <AssistantPromptAction hasPrompt={prompt.length > 0} />,
useInteraction: () => ({
type: "javaScript" as const,
onSelect: () => {
if (prompt.length > 0) sendPrompt(prompt);
},
closeSpotlightOnTrigger: prompt.length > 0,
}),
},
];
},
}) as const;
+22 -1
View File
@@ -1969,6 +1969,7 @@
"switchToLightMode": "Switch to light mode",
"management": "Management",
"assistant": "Homarr Assistant",
"assistantThinking": "Thinking",
"docker": "Docker",
"preferences": "Your preferences",
"logout": "Logout",
@@ -1984,7 +1985,13 @@
"open": "Open Homarr Assistant",
"close": "Close assistant",
"spotlight": "Ask Homarr Assistant",
"spotlightDescription": "Start or continue a conversation with your Homarr instance.",
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
"spotlightPrompt": {
"title": "Ask Assistant",
"description": "Type a prompt above. Enter sends it in the background.",
"send": "Send prompt",
"empty": "Type a prompt above"
},
"unavailable": {
"checking": "Checking whether the assistant is available…",
"notConfigured": "An administrator must configure and enable the assistant first.",
@@ -2029,6 +2036,20 @@
"description": "Try again. If the problem continues, ask an administrator to verify the configured model endpoint.",
"retry": "Try again"
},
"busy": {
"title": "The assistant is already working",
"description": "Wait for the current response or open it to stop the run."
},
"activity": {
"thinking": "Assistant is thinking",
"working": "Working on your prompt",
"ready": "Response ready",
"completed": "Open the response",
"approval": "Approval needed",
"failed": "Assistant needs attention",
"expand": "Open the assistant response",
"dismiss": "Dismiss assistant activity"
},
"scrollToLatest": "Scroll to latest",
"approvalRequired": "Approval required",
"complete": "Complete",