fix: address assistant capability review

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 40c7b1523b
commit b5162a9b95
8 files changed
+260 -141

No files matched your search

@@ -2,7 +2,7 @@
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: max(1rem, env(safe-area-inset-bottom));
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
display: flex;
width: min(31rem, calc(100vw - 1rem));
min-height: 3.5rem;
@@ -540,14 +540,14 @@
@media (max-width: 48em) {
.activityBar {
bottom: max(0.5rem, env(safe-area-inset-bottom));
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
}
.floatingPanel {
bottom: max(0.5rem, env(safe-area-inset-bottom));
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
width: calc(100vw - 1rem);
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom)));
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom)));
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
}
.panelHeader {
@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useMemo, useState } from "react";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import type {
FileMessagePartProps,
ImageMessagePartProps,
@@ -35,6 +35,7 @@ import {
Button,
Collapse,
Divider,
FocusTrap,
Group,
Loader,
Popover,
@@ -184,7 +185,24 @@ const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useScopedI18n("common.assistant");
return <Box component="img" src={image} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
const source = (() => {
if (/^data:image\/(?:gif|jpeg|png|webp);base64,/u.test(image)) return image;
try {
const url = new URL(image, window.location.origin);
return url.origin === window.location.origin ? url.href : null;
} catch {
return null;
}
})();
if (!source) {
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconAlertTriangle size={16} />
<Text size="xs">{t("externalImageBlocked")}</Text>
</Group>
);
}
return <Box component="img" src={source} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
};
const ToolPart = ({
@@ -1007,6 +1025,7 @@ const Composer = () => {
className={classes.composerInput}
placeholder={t("composerPlaceholder")}
rows={1}
data-autofocus
/>
{running ? (
<ComposerPrimitive.Cancel asChild>
@@ -1128,10 +1147,19 @@ export const AssistantPanel = ({
latestStatus,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const previousFocusRef = useRef<HTMLElement | null>(null);
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") onClose();
});
useEffect(() => {
if (!opened) return;
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
return () => {
previousFocusRef.current?.focus();
previousFocusRef.current = null;
};
}, [opened]);
return (
<>
@@ -1147,73 +1175,75 @@ export const AssistantPanel = ({
/>
)}
{opened && (
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div className={classes.panelIdentityText}>
<Text fw={700} lh={1.1} lineClamp={1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<ActionIcon
className={classes.panelAction}
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>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<FocusTrap active>
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div className={classes.panelIdentityText}>
<Text fw={700} lh={1.1} lineClamp={1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("close")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
<IconX size={18} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>
</dialog>
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>
</dialog>
</FocusTrap>
)}
</>
);
@@ -29,6 +29,7 @@ import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -99,7 +100,7 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
if (file.size > sizeLimit) {
throw new Error(isImage ? "Images must be smaller than 1 MB." : "Documents must be smaller than 350 KB.");
}
const id = crypto.randomUUID();
const id = createId();
pendingAttachmentIds.add(id);
return {
id,
@@ -286,10 +287,12 @@ const AssistantThreadRuntime = () => {
() => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
if (!threadId) return;
void fetchApi.assistant.submitFeedback.mutate({ threadId, messageId: message.id, type });
void fetchApi.assistant.submitFeedback
.mutate({ threadId, messageId: message.id, type })
.catch(() => showErrorNotification({ title: t("responseError.title"), message: t("feedbackError") }));
},
}),
[threadId],
[t, threadId],
);
const onError = useCallback(
() =>