feat: streamline assistant interactions
This commit is contained in:
1 parent
27448868dd
commit
63699b1610
7 files changed
+721
-222
No files matched your search
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user