Files
homarr/apps/nextjs/src/components/assistant/assistant-panel.tsx
T

433 lines
14 KiB
TypeScript

"use client";
import { useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import {
ActionBarPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePartPrimitive,
MessagePrimitive,
ThreadListItemPrimitive,
ThreadListPrimitive,
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 {
IconActivityHeartbeat,
IconAlertTriangle,
IconApps,
IconArchive,
IconArrowUp,
IconCheck,
IconCopy,
IconCommand,
IconMenu2,
IconHistory,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRestore,
IconRobot,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
interface AssistantPanelProps {
opened: boolean;
onClose: () => void;
}
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
const ToolPart = ({
toolName,
args,
result,
isError,
status,
approval,
respondToApproval,
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
const failed =
!denied &&
(isError === true ||
status?.type === "incomplete" ||
(typeof result === "object" && result !== null && "error" in result));
const successful = completed && !denied && !failed;
return (
<Box className={classes.tool}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon
size="sm"
radius="xl"
variant="light"
color={denied || failed ? "red" : successful ? "green" : "gray"}
>
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
</ThemeIcon>
<Text size="sm" fw={600}>
{toolName.replaceAll("_", " ")}
</Text>
</Group>
<Text size="xs" c="dimmed">
{awaitingApproval
? t("approvalRequired")
: denied
? t("denied")
: failed
? t("failed")
: successful
? t("complete")
: t("working")}
</Text>
</Group>
{awaitingApproval && (
<Stack gap="xs" mt="sm">
<Text size="sm">{t("approvalDescription")}</Text>
<Text size="xs" c="dimmed" ff="monospace">
{JSON.stringify(args)}
</Text>
<Group gap="xs">
<Button size="compact-sm" onClick={() => respondToApproval({ approved: true })}>
{t("allowOnce")}
</Button>
<Button size="compact-sm" variant="default" onClick={() => respondToApproval({ approved: false })}>
{t("deny")}
</Button>
</Group>
</Stack>
)}
{!awaitingApproval && !denied && result !== undefined && (
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
{typeof result === "string" ? result : JSON.stringify(result)}
</Text>
)}
</Box>
);
};
const MessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning>
<Group gap={2}>
<Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconCopy size={14} />
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconRefresh size={14} />
</ActionIcon>
</ActionBarPrimitive.Reload>
</Tooltip>
</Group>
</ActionBarPrimitive.Root>
);
};
const UserMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessage}`}>
<MessagePrimitive.Parts components={{ Text: TextPart }} />
</MessagePrimitive.Root>
);
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className={classes.messageError}>
<Group align="flex-start" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl" size="md">
<IconAlertTriangle size={17} />
</ThemeIcon>
<Stack gap="xs" flex={1}>
<Text size="sm" fw={700}>
{t("responseError.title")}
</Text>
<ErrorPrimitive.Message className={classes.messageErrorText} />
<ActionBarPrimitive.Reload asChild>
<Button
variant="light"
color="red"
size="compact-sm"
w="fit-content"
leftSection={<IconRefresh size={14} />}
>
{t("responseError.retry")}
</Button>
</ActionBarPrimitive.Reload>
</Stack>
</Group>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
components={{
Text: TextPart,
tools: { Fallback: ToolPart },
}}
/>
<RuntimeError />
<MessageActions />
</MessagePrimitive.Root>
);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
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 }}>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<ThreadListItemPrimitive.Archive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("archive")}>
<IconArchive size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Archive>
<ThreadListItemPrimitive.Delete asChild>
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
<IconTrash size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Delete>
</Group>
</ThreadListItemPrimitive.Root>
);
};
const ArchivedThreadListItem = () => {
const t = useScopedI18n("common.assistant");
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 }}>
<ThreadListItemPrimitive.Title fallback={t("archivedConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<ThreadListItemPrimitive.Unarchive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("restore")}>
<IconRestore size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Unarchive>
<ThreadListItemPrimitive.Delete asChild>
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
<IconTrash size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Delete>
</Group>
</ThreadListItemPrimitive.Root>
);
};
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => 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}
>
<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}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("closeHistory")}
>
<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>
);
};
const EmptyThread = () => {
const t = useScopedI18n("common.assistant");
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.command.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconCommand size={18} />}>
{t("suggestions.command.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>
);
};
const Composer = () => {
const t = useScopedI18n("common.assistant");
const running = useAuiState((state) => state.thread.isRunning);
return (
<Box className={classes.composerWrap}>
<ComposerPrimitive.Root className={classes.composer}>
<ComposerPrimitive.Input className={classes.composerInput} placeholder={t("composerPlaceholder")} rows={1} />
{running ? (
<ComposerPrimitive.Cancel asChild>
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
<IconPlayerStop size={18} />
</ActionIcon>
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
<IconArrowUp size={18} />
</ActionIcon>
</ComposerPrimitive.Send>
)}
</ComposerPrimitive.Root>
</Box>
);
};
export const AssistantPanel = ({ opened, onClose }: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const [historyOpened, setHistoryOpened] = useState(false);
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")}
</Text>
<Text size="xs" c="dimmed">
{t("subtitle")}
</Text>
</div>
</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>
);
};