+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
734 lines
25 KiB
TypeScript
734 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import type { MessageStatus } from "@assistant-ui/react";
|
|
import {
|
|
ActionBarMorePrimitive,
|
|
ActionBarPrimitive,
|
|
AuiIf,
|
|
BranchPickerPrimitive,
|
|
ChainOfThoughtByIndicesProvider,
|
|
ComposerPrimitive,
|
|
ErrorPrimitive,
|
|
MessagePrimitive,
|
|
SelectionToolbarPrimitive,
|
|
ThreadListPrimitive,
|
|
ThreadPrimitive,
|
|
useAui,
|
|
useAuiState,
|
|
} from "@assistant-ui/react";
|
|
import { ActionIcon, Badge, Box, Button, Group, Stack, Text, ThemeIcon, Tooltip, UnstyledButton } from "@mantine/core";
|
|
import { useReducedMotion, useWindowEvent } from "@mantine/hooks";
|
|
import {
|
|
IconAlertTriangle,
|
|
IconArrowUp,
|
|
IconArrowsMaximize,
|
|
IconCheck,
|
|
IconChevronLeft,
|
|
IconChevronRight,
|
|
IconChevronUp,
|
|
IconCopy,
|
|
IconDotsVertical,
|
|
IconFileExport,
|
|
IconMinus,
|
|
IconPencil,
|
|
IconPlus,
|
|
IconQuote,
|
|
IconRefresh,
|
|
IconThumbDown,
|
|
IconThumbUp,
|
|
IconVolume,
|
|
IconVolumeOff,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
|
|
import { useI18n } from "@homarr/translation/client";
|
|
|
|
import { getAssistantActivityState } from "./assistant-activity-state";
|
|
import { Composer, PendingActionBanner, PendingQuestionDock, usePendingActionCopy } from "./assistant-composer";
|
|
import type { AssistantConversationControls } from "./assistant-conversation-controls";
|
|
import { ProviderMessageInfo } from "./assistant-conversation-context";
|
|
import { buildAssistantMessageMarkdown } from "./assistant-conversation-export";
|
|
import { AssistantDotMatrix } from "./assistant-dot-matrix";
|
|
import { ConversationHistory, downloadAssistantMarkdown } from "./assistant-history";
|
|
import {
|
|
AssistantDirectiveEntitiesProvider,
|
|
AssistantMessagePending,
|
|
AssistantTextPart,
|
|
FilePart,
|
|
HiddenMessagePart,
|
|
ImagePart,
|
|
ReasoningPart,
|
|
ReasoningVisibilityContext,
|
|
SentAttachment,
|
|
SourcePart,
|
|
UserTextPart,
|
|
} from "./assistant-message-content";
|
|
import { assistantMessageGroupBy } from "./assistant-message-grouping";
|
|
import { AutoApprovalControl, EmptyThread, ViewRefreshAction } from "./assistant-panel-controls";
|
|
import classes from "./assistant-panel.module.css";
|
|
import type { AssistantPendingAction } from "./assistant-pending-action";
|
|
import { AssistantQuestionPortalProvider } from "./assistant-question-portal";
|
|
import { AgentTraceToolGroup, AssistantChainOfThought, ToolPart } from "./assistant-tool-rendering";
|
|
import { isEscapeOwnedByNestedOverlay } from "../board/advanced-focus/escape";
|
|
|
|
export type { AssistantConversationControls } from "./assistant-conversation-controls";
|
|
|
|
interface AssistantPanelProps extends AssistantConversationControls {
|
|
opened: boolean;
|
|
onOpen: () => void;
|
|
onClose: () => void;
|
|
onDismissActivity: () => void;
|
|
activityDismissed: boolean;
|
|
hasVisibleWidget: boolean;
|
|
isRunning: boolean;
|
|
unreadCount: number;
|
|
latestAssistantText: string;
|
|
latestAssistantPartType: string | undefined;
|
|
latestUserText: string;
|
|
latestStatus: MessageStatus | undefined;
|
|
pendingAction: AssistantPendingAction | undefined;
|
|
}
|
|
|
|
const BranchPicker = () => {
|
|
const t = useI18n("assistant");
|
|
return (
|
|
<BranchPickerPrimitive.Root hideWhenSingleBranch className={classes.branchPicker}>
|
|
<BranchPickerPrimitive.Previous asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("previousResponse")}>
|
|
<IconChevronLeft size={14} />
|
|
</ActionIcon>
|
|
</BranchPickerPrimitive.Previous>
|
|
<Text size="xs" c="dimmed">
|
|
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
</Text>
|
|
<BranchPickerPrimitive.Next asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("nextResponse")}>
|
|
<IconChevronRight size={14} />
|
|
</ActionIcon>
|
|
</BranchPickerPrimitive.Next>
|
|
</BranchPickerPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const AssistantMessageActions = () => {
|
|
const t = useI18n("assistant");
|
|
const aui = useAui();
|
|
const exportMessage = () => {
|
|
const message = aui.message().getState();
|
|
const markdown = buildAssistantMessageMarkdown({
|
|
id: message.id,
|
|
parentId: message.parentId,
|
|
format: "assistant-ui/runtime",
|
|
content: message,
|
|
createdAt: message.createdAt,
|
|
});
|
|
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
|
|
};
|
|
return (
|
|
<Box className={classes.messageActions}>
|
|
<ActionBarPrimitive.Root
|
|
hideWhenRunning
|
|
autohide="not-last"
|
|
autohideFloat="single-branch"
|
|
className={classes.messageActionBar}
|
|
>
|
|
<Group gap={2} wrap="nowrap">
|
|
<ProviderMessageInfo />
|
|
<Tooltip label={t("copy")}>
|
|
<ActionBarPrimitive.Copy asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
|
<AuiIf condition={(state) => !state.message.isCopied}>
|
|
<IconCopy size={14} />
|
|
</AuiIf>
|
|
<AuiIf condition={(state) => state.message.isCopied}>
|
|
<IconCheck size={14} />
|
|
</AuiIf>
|
|
</ActionIcon>
|
|
</ActionBarPrimitive.Copy>
|
|
</Tooltip>
|
|
<Tooltip label={t("regenerate")}>
|
|
<ActionBarPrimitive.Reload asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
|
<IconRefresh size={14} />
|
|
</ActionIcon>
|
|
</ActionBarPrimitive.Reload>
|
|
</Tooltip>
|
|
<ActionBarMorePrimitive.Root>
|
|
<ActionBarMorePrimitive.Trigger asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
|
|
<IconDotsVertical size={14} />
|
|
</ActionIcon>
|
|
</ActionBarMorePrimitive.Trigger>
|
|
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
|
|
<ActionBarPrimitive.FeedbackPositive asChild>
|
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
|
<IconThumbUp size={15} />
|
|
{t("helpful")}
|
|
</ActionBarMorePrimitive.Item>
|
|
</ActionBarPrimitive.FeedbackPositive>
|
|
<ActionBarPrimitive.FeedbackNegative asChild>
|
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
|
<IconThumbDown size={15} />
|
|
{t("notHelpful")}
|
|
</ActionBarMorePrimitive.Item>
|
|
</ActionBarPrimitive.FeedbackNegative>
|
|
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
|
|
<ActionBarPrimitive.Speak asChild>
|
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
|
<IconVolume size={15} />
|
|
{t("readAloud")}
|
|
</ActionBarMorePrimitive.Item>
|
|
</ActionBarPrimitive.Speak>
|
|
</AuiIf>
|
|
<AuiIf condition={(state) => state.message.speech !== undefined}>
|
|
<ActionBarPrimitive.StopSpeaking asChild>
|
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
|
<IconVolumeOff size={15} />
|
|
{t("stopReading")}
|
|
</ActionBarMorePrimitive.Item>
|
|
</ActionBarPrimitive.StopSpeaking>
|
|
</AuiIf>
|
|
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
|
|
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
|
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
|
<IconFileExport size={15} />
|
|
{t("exportMarkdown")}
|
|
</ActionBarMorePrimitive.Item>
|
|
</ActionBarPrimitive.ExportMarkdown>
|
|
</ActionBarMorePrimitive.Content>
|
|
</ActionBarMorePrimitive.Root>
|
|
</Group>
|
|
</ActionBarPrimitive.Root>
|
|
<BranchPicker />
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const UserMessageActions = () => {
|
|
const t = useI18n("assistant");
|
|
return (
|
|
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
|
|
<Group gap={2}>
|
|
<Tooltip label={t("copyMessage")}>
|
|
<ActionBarPrimitive.Copy asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copyMessage")}>
|
|
<IconCopy size={14} />
|
|
</ActionIcon>
|
|
</ActionBarPrimitive.Copy>
|
|
</Tooltip>
|
|
<Tooltip label={t("editMessage")}>
|
|
<ActionBarPrimitive.Edit asChild>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("editMessage")}>
|
|
<IconPencil size={14} />
|
|
</ActionIcon>
|
|
</ActionBarPrimitive.Edit>
|
|
</Tooltip>
|
|
</Group>
|
|
</ActionBarPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const EditComposer = () => {
|
|
const t = useI18n("assistant");
|
|
const actionT = useI18n("common.action");
|
|
return (
|
|
<ComposerPrimitive.Root className={classes.editComposer}>
|
|
<ComposerPrimitive.Input className={classes.editComposerInput} rows={2} aria-label={t("editMessage")} />
|
|
<Group gap="xs" justify="flex-end">
|
|
<ComposerPrimitive.Cancel asChild>
|
|
<Button size="compact-sm" variant="default">
|
|
{actionT("cancel")}
|
|
</Button>
|
|
</ComposerPrimitive.Cancel>
|
|
<ComposerPrimitive.Send asChild>
|
|
<Button size="compact-sm">{t("updateMessage")}</Button>
|
|
</ComposerPrimitive.Send>
|
|
</Group>
|
|
</ComposerPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const UserMessage = () => {
|
|
const isEditing = useAuiState((state) => state.composer.isEditing);
|
|
if (isEditing) return <EditComposer />;
|
|
return (
|
|
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
|
|
<Box className={classes.userMessage}>
|
|
<MessagePrimitive.Quote>
|
|
{(quote) => (
|
|
<Text component="blockquote" className={classes.messageQuote} size="sm">
|
|
{quote.text}
|
|
</Text>
|
|
)}
|
|
</MessagePrimitive.Quote>
|
|
<MessagePrimitive.Attachments components={{ Attachment: SentAttachment }} />
|
|
<MessagePrimitive.Parts
|
|
components={{ Text: UserTextPart, File: HiddenMessagePart, Image: HiddenMessagePart }}
|
|
/>
|
|
</Box>
|
|
<Group className={classes.userMessageActions} justify="flex-end" gap="xs" wrap="wrap">
|
|
<UserMessageActions />
|
|
<BranchPicker />
|
|
</Group>
|
|
</MessagePrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const RuntimeError = () => {
|
|
const t = useI18n("assistant");
|
|
const actionT = useI18n("common.action");
|
|
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} />}
|
|
>
|
|
{actionT("tryAgain")}
|
|
</Button>
|
|
</ActionBarPrimitive.Reload>
|
|
</Stack>
|
|
</Group>
|
|
</ErrorPrimitive.Root>
|
|
</MessagePrimitive.Error>
|
|
);
|
|
};
|
|
|
|
const AssistantMessage = () => {
|
|
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
|
|
|
|
return (
|
|
<MessagePrimitive.Root
|
|
className={`${classes.message} ${classes.assistantMessage}`}
|
|
data-static={isComplete || undefined}
|
|
>
|
|
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
|
{({ part, children }) => {
|
|
switch (part.type) {
|
|
case "group-agent-trace": {
|
|
const startIndex = part.indices[0];
|
|
const endIndex = part.indices.at(-1);
|
|
if (startIndex === undefined || endIndex === undefined) return null;
|
|
return (
|
|
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
|
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
|
</ChainOfThoughtByIndicesProvider>
|
|
);
|
|
}
|
|
case "group-reasoning":
|
|
return <>{children}</>;
|
|
case "group-tool":
|
|
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
|
case "indicator":
|
|
return <AssistantMessagePending status={{ type: "running" }} />;
|
|
case "text":
|
|
return <AssistantTextPart />;
|
|
case "reasoning":
|
|
return <ReasoningPart {...part} />;
|
|
case "tool-call":
|
|
return part.toolUI ?? <ToolPart {...part} />;
|
|
case "source":
|
|
return <SourcePart {...part} />;
|
|
case "file":
|
|
return <FilePart {...part} />;
|
|
case "image":
|
|
return <ImagePart {...part} />;
|
|
case "data":
|
|
return part.dataRendererUI;
|
|
default:
|
|
return null;
|
|
}
|
|
}}
|
|
</MessagePrimitive.GroupedParts>
|
|
<RuntimeError />
|
|
<AssistantMessageActions />
|
|
</MessagePrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
|
|
|
|
const getActivityColor = (needsApproval: boolean, failed: boolean, isRunning: boolean) => {
|
|
if (needsApproval) return "yellow";
|
|
if (failed || isRunning) return "red";
|
|
return "green";
|
|
};
|
|
|
|
const AssistantActivityBar = ({
|
|
isRunning,
|
|
unreadCount,
|
|
latestAssistantText,
|
|
latestAssistantPartType,
|
|
latestUserText,
|
|
latestStatus,
|
|
pendingAction,
|
|
onOpen,
|
|
onDismissActivity,
|
|
activityDismissed,
|
|
}: Pick<
|
|
AssistantPanelProps,
|
|
| "isRunning"
|
|
| "unreadCount"
|
|
| "latestAssistantText"
|
|
| "latestAssistantPartType"
|
|
| "latestUserText"
|
|
| "latestStatus"
|
|
| "pendingAction"
|
|
| "onOpen"
|
|
| "onDismissActivity"
|
|
| "activityDismissed"
|
|
>) => {
|
|
const t = useI18n("assistant");
|
|
const pendingCopy = usePendingActionCopy(pendingAction);
|
|
const needsApproval = pendingAction !== undefined || latestStatus?.type === "requires-action";
|
|
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
|
|
const visible =
|
|
!activityDismissed &&
|
|
(isRunning || unreadCount > 0 || needsApproval || latestStatus !== undefined || latestAssistantText.length > 0);
|
|
|
|
if (!visible) return null;
|
|
|
|
let title = t("activity.ready");
|
|
if (failed) title = t("activity.failed");
|
|
if (needsApproval) title = t("activity.approval");
|
|
if (pendingCopy) title = pendingCopy.title;
|
|
if (isRunning) title = t("activity.thinking");
|
|
|
|
let fallbackDetail = t("activity.completed");
|
|
if (failed) fallbackDetail = t("responseError.description");
|
|
|
|
let detail = pendingCopy?.detail || latestAssistantText || fallbackDetail;
|
|
if (isRunning) detail = latestUserText || t("activity.working");
|
|
const activityState = getAssistantActivityState({
|
|
isRunning,
|
|
latestPartType: latestAssistantPartType,
|
|
needsApproval,
|
|
failed,
|
|
});
|
|
const activityColor = getActivityColor(needsApproval, failed, isRunning);
|
|
|
|
return (
|
|
<Box component="output" className={classes.activityBar} aria-live="polite">
|
|
<UnstyledButton className={classes.activityTrigger} onClick={onOpen}>
|
|
<Group gap="sm" wrap="nowrap">
|
|
<ThemeIcon variant="light" color={activityColor} radius="xl">
|
|
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
|
|
</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>
|
|
</Stack>
|
|
{needsApproval ? (
|
|
<Box className={classes.activityReviewAction}>
|
|
<Text component="span" size="xs" fw={700}>
|
|
{t("pendingAction.review")}
|
|
</Text>
|
|
<IconChevronUp size={15} />
|
|
</Box>
|
|
) : (
|
|
<IconChevronUp size={17} className={classes.activityExpandIcon} />
|
|
)}
|
|
</Group>
|
|
</UnstyledButton>
|
|
{!isRunning && !needsApproval && (
|
|
<ActionIcon
|
|
className={classes.activityDismiss}
|
|
variant="subtle"
|
|
color="gray"
|
|
onClick={onDismissActivity}
|
|
aria-label={t("activity.dismiss")}
|
|
>
|
|
<IconX size={15} />
|
|
</ActionIcon>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
interface AssistantConversationSurfaceProps extends AssistantConversationControls {
|
|
variant?: "widget" | "panel";
|
|
isRunning: boolean;
|
|
pendingAction: AssistantPendingAction | undefined;
|
|
onExpand?: () => void;
|
|
onMinimize?: () => void;
|
|
onDismiss?: () => void;
|
|
}
|
|
|
|
export const AssistantConversationSurface = ({
|
|
variant = "panel",
|
|
isRunning,
|
|
pendingAction,
|
|
modelId,
|
|
models,
|
|
modelOptionsLoading,
|
|
reasoning,
|
|
isRefreshing,
|
|
autoFocusComposer,
|
|
onRefresh,
|
|
onModelChange,
|
|
onReasoningChange,
|
|
onExpand,
|
|
onMinimize,
|
|
onDismiss,
|
|
}: AssistantConversationSurfaceProps) => {
|
|
const t = useI18n("assistant");
|
|
const reducedMotion = useReducedMotion();
|
|
const [questionPortalTarget, setQuestionPortalTarget] = useState<HTMLDivElement | null>(null);
|
|
const [reasoningCollapsed, setReasoningCollapsed] = useState(false);
|
|
const reasoningVisibility = useMemo(
|
|
() => ({ collapsed: reasoningCollapsed, setCollapsed: setReasoningCollapsed }),
|
|
[reasoningCollapsed],
|
|
);
|
|
const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth";
|
|
|
|
return (
|
|
<AssistantDirectiveEntitiesProvider>
|
|
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
|
{variant === "widget" && (
|
|
<Text className={classes.widgetPanelTitle} size="sm" fw={600}>
|
|
{t("title")}
|
|
</Text>
|
|
)}
|
|
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
|
<ConversationHistory compact={variant === "widget"} />
|
|
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
|
{variant !== "widget" && <AutoApprovalControl />}
|
|
<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={17} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
{onDismiss && (
|
|
<Tooltip label={t("close")}>
|
|
<ActionIcon
|
|
className={classes.panelAction}
|
|
variant="subtle"
|
|
color="gray"
|
|
onClick={onDismiss}
|
|
aria-label={t("close")}
|
|
>
|
|
<IconX size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
|
<ReasoningVisibilityContext.Provider value={reasoningVisibility}>
|
|
<ThreadPrimitive.Root
|
|
className={classes.thread}
|
|
data-pending-question={pendingAction?.kind === "question" || undefined}
|
|
>
|
|
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
|
<Box className={classes.messages}>
|
|
<EmptyThread compact={variant === "widget"} />
|
|
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
|
|
</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 behavior={scrollToLatestBehavior} 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>
|
|
<PendingQuestionDock pendingAction={pendingAction} setTarget={setQuestionPortalTarget} />
|
|
<PendingActionBanner pendingAction={pendingAction} />
|
|
<Composer
|
|
compact={variant === "widget"}
|
|
modelId={modelId}
|
|
models={models}
|
|
modelOptionsLoading={modelOptionsLoading}
|
|
reasoning={reasoning}
|
|
isRefreshing={isRefreshing}
|
|
onRefresh={onRefresh}
|
|
onModelChange={onModelChange}
|
|
onReasoningChange={onReasoningChange}
|
|
autoFocusComposer={autoFocusComposer}
|
|
pendingAction={pendingAction}
|
|
/>
|
|
</ThreadPrimitive.Root>
|
|
</ReasoningVisibilityContext.Provider>
|
|
</AssistantQuestionPortalProvider>
|
|
</AssistantDirectiveEntitiesProvider>
|
|
);
|
|
};
|
|
|
|
export const AssistantPanel = ({
|
|
opened,
|
|
onOpen,
|
|
onClose,
|
|
onDismissActivity,
|
|
activityDismissed,
|
|
hasVisibleWidget,
|
|
isRunning,
|
|
unreadCount,
|
|
latestAssistantText,
|
|
latestAssistantPartType,
|
|
latestUserText,
|
|
latestStatus,
|
|
pendingAction,
|
|
modelId,
|
|
models,
|
|
modelOptionsLoading,
|
|
reasoning,
|
|
isRefreshing,
|
|
onRefresh,
|
|
onModelChange,
|
|
onReasoningChange,
|
|
}: AssistantPanelProps) => {
|
|
const t = useI18n("assistant");
|
|
const aui = useAui();
|
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
const panelRef = useRef<HTMLDialogElement>(null);
|
|
const stopSpeechWithoutSurface = () => {
|
|
if (hasVisibleWidget) return;
|
|
const thread = aui.thread();
|
|
if (thread.getState().speech !== undefined) thread.stopSpeaking();
|
|
};
|
|
const minimize = () => {
|
|
stopSpeechWithoutSurface();
|
|
onClose();
|
|
};
|
|
|
|
useWindowEvent("keydown", (event) => {
|
|
if (
|
|
!opened ||
|
|
event.key !== "Escape" ||
|
|
event.defaultPrevented ||
|
|
event.isComposing ||
|
|
isEscapeOwnedByNestedOverlay(event.target, panelRef.current)
|
|
)
|
|
return;
|
|
event.preventDefault();
|
|
minimize();
|
|
});
|
|
useEffect(() => {
|
|
if (!opened) {
|
|
const rememberOutsideFocus = () => {
|
|
if (document.activeElement instanceof HTMLElement) {
|
|
previousFocusRef.current = document.activeElement;
|
|
}
|
|
};
|
|
rememberOutsideFocus();
|
|
document.addEventListener("focusin", rememberOutsideFocus);
|
|
return () => document.removeEventListener("focusin", rememberOutsideFocus);
|
|
}
|
|
const restoreTarget = previousFocusRef.current;
|
|
return () => {
|
|
restoreTarget?.focus();
|
|
previousFocusRef.current = null;
|
|
};
|
|
}, [opened]);
|
|
|
|
return (
|
|
<>
|
|
{!opened && !hasVisibleWidget && (
|
|
<AssistantActivityBar
|
|
onOpen={onOpen}
|
|
onDismissActivity={onDismissActivity}
|
|
activityDismissed={activityDismissed}
|
|
isRunning={isRunning}
|
|
unreadCount={unreadCount}
|
|
latestAssistantText={latestAssistantText}
|
|
latestAssistantPartType={latestAssistantPartType}
|
|
latestUserText={latestUserText}
|
|
latestStatus={latestStatus}
|
|
pendingAction={pendingAction}
|
|
/>
|
|
)}
|
|
{opened && (
|
|
<dialog ref={panelRef} className={classes.floatingPanel} aria-label={t("title")} open>
|
|
<AssistantConversationSurface
|
|
isRunning={isRunning}
|
|
pendingAction={pendingAction}
|
|
modelId={modelId}
|
|
models={models}
|
|
modelOptionsLoading={modelOptionsLoading}
|
|
reasoning={reasoning}
|
|
isRefreshing={isRefreshing}
|
|
onRefresh={onRefresh}
|
|
onModelChange={onModelChange}
|
|
onReasoningChange={onReasoningChange}
|
|
autoFocusComposer
|
|
onMinimize={minimize}
|
|
onDismiss={() => {
|
|
stopSpeechWithoutSurface();
|
|
onDismissActivity();
|
|
onClose();
|
|
}}
|
|
/>
|
|
</dialog>
|
|
)}
|
|
</>
|
|
);
|
|
};
|