feat: expand assistant interaction capabilities

This commit is contained in:
ajnart committed 2026-07-30 14:09:15 +02:00
1 parent 9e6a0c7748
commit 33e23021ae
12 files changed
+2046 -81

No files matched your search

@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
describe("getAssistantTelemetry", () => {
test("extracts persisted request telemetry", () => {
expect(
getAssistantTelemetry({
custom: {
telemetry: {
requestId: "request-1",
provider: "openrouter",
modelId: "example/model",
startedAt: "2026-07-30T12:00:00.000Z",
completedAt: "2026-07-30T12:00:01.000Z",
durationMs: 1000,
contextLength: 128_000,
contextUsed: 2000,
contextUtilization: 0.015625,
cost: 0.001,
costType: "reported",
steps: [{ index: 1, durationMs: 900, modelDurationMs: 800, toolDurationMs: 100 }],
},
},
}),
).toMatchObject({
requestId: "request-1",
provider: "openrouter",
durationMs: 1000,
contextLength: 128_000,
costType: "reported",
});
});
test("rejects malformed metadata without throwing", () => {
expect(getAssistantTelemetry(null)).toBeNull();
expect(getAssistantTelemetry({ custom: { telemetry: { requestId: 42 } } })).toBeNull();
expect(
getAssistantTelemetry({
custom: {
telemetry: {
requestId: "request-1",
provider: "openrouter",
modelId: "example/model",
startedAt: "invalid-but-display-safe",
durationMs: Number.NaN,
contextUtilization: 4,
steps: [{ index: 1, durationMs: "fast" }],
},
},
}),
).toMatchObject({ contextUtilization: 1, steps: [] });
});
test("only returns finite usage values", () => {
expect(
getAssistantUsage({
usage: {
inputTokens: 120,
outputTokens: Number.POSITIVE_INFINITY,
totalTokens: "120",
cachedInputTokens: 24,
},
}),
).toEqual({ inputTokens: 120, cachedInputTokens: 24 });
});
});
@@ -0,0 +1,177 @@
import type { UIMessage } from "ai";
export type AssistantUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cachedInputTokens?: number;
cacheWriteTokens?: number;
};
export type AssistantRequestStep = {
index: number;
durationMs: number;
modelDurationMs: number;
toolDurationMs: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
inputTokens?: number;
outputTokens?: number;
cost?: number;
upstreamCost?: number;
generationId?: string;
routedProvider?: string;
};
export type AssistantRequestTelemetry = {
requestId: string;
provider: string;
modelId: string;
startedAt: string;
completedAt?: string;
durationMs?: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
contextLength?: number;
contextUsed?: number;
contextUtilization?: number;
cost?: number;
upstreamCost?: number;
costType?: "reported" | "estimated";
finishReason?: string;
steps: AssistantRequestStep[];
};
export type AssistantMessageMetadata = {
usage?: AssistantUsage;
custom: {
telemetry: AssistantRequestTelemetry;
};
};
export type AssistantUIMessage = UIMessage<AssistantMessageMetadata>;
const getFiniteNonNegativeNumber = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
export const getAssistantUsage = (metadata: unknown): AssistantUsage | null => {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || !("usage" in metadata)) return null;
const usage = metadata.usage;
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
const value = usage as Record<string, unknown>;
return {
...(getFiniteNonNegativeNumber(value.inputTokens) !== undefined
? { inputTokens: getFiniteNonNegativeNumber(value.inputTokens) }
: {}),
...(getFiniteNonNegativeNumber(value.outputTokens) !== undefined
? { outputTokens: getFiniteNonNegativeNumber(value.outputTokens) }
: {}),
...(getFiniteNonNegativeNumber(value.totalTokens) !== undefined
? { totalTokens: getFiniteNonNegativeNumber(value.totalTokens) }
: {}),
...(getFiniteNonNegativeNumber(value.reasoningTokens) !== undefined
? { reasoningTokens: getFiniteNonNegativeNumber(value.reasoningTokens) }
: {}),
...(getFiniteNonNegativeNumber(value.cachedInputTokens) !== undefined
? { cachedInputTokens: getFiniteNonNegativeNumber(value.cachedInputTokens) }
: {}),
...(getFiniteNonNegativeNumber(value.cacheWriteTokens) !== undefined
? { cacheWriteTokens: getFiniteNonNegativeNumber(value.cacheWriteTokens) }
: {}),
};
};
export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTelemetry | null => {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
const custom = (metadata as { custom?: unknown }).custom;
if (!custom || typeof custom !== "object" || Array.isArray(custom)) return null;
const telemetry = (custom as { telemetry?: unknown }).telemetry;
if (!telemetry || typeof telemetry !== "object" || Array.isArray(telemetry)) return null;
const value = telemetry as Partial<AssistantRequestTelemetry>;
if (
typeof value.requestId !== "string" ||
typeof value.provider !== "string" ||
typeof value.modelId !== "string" ||
typeof value.startedAt !== "string"
) {
return null;
}
const steps = Array.isArray(value.steps)
? value.steps.flatMap((step): AssistantRequestStep[] => {
if (!step || typeof step !== "object" || Array.isArray(step)) return [];
const candidate = step as Record<string, unknown>;
const index = getFiniteNonNegativeNumber(candidate.index);
const durationMs = getFiniteNonNegativeNumber(candidate.durationMs);
const modelDurationMs = getFiniteNonNegativeNumber(candidate.modelDurationMs);
const toolDurationMs = getFiniteNonNegativeNumber(candidate.toolDurationMs);
if (
index === undefined ||
durationMs === undefined ||
modelDurationMs === undefined ||
toolDurationMs === undefined
)
return [];
return [
{
index,
durationMs,
modelDurationMs,
toolDurationMs,
...(getFiniteNonNegativeNumber(candidate.timeToFirstOutputMs) !== undefined
? { timeToFirstOutputMs: getFiniteNonNegativeNumber(candidate.timeToFirstOutputMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.outputTokensPerSecond) !== undefined
? { outputTokensPerSecond: getFiniteNonNegativeNumber(candidate.outputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(candidate.inputTokens) !== undefined
? { inputTokens: getFiniteNonNegativeNumber(candidate.inputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.outputTokens) !== undefined
? { outputTokens: getFiniteNonNegativeNumber(candidate.outputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cost) !== undefined
? { cost: getFiniteNonNegativeNumber(candidate.cost) }
: {}),
...(getFiniteNonNegativeNumber(candidate.upstreamCost) !== undefined
? { upstreamCost: getFiniteNonNegativeNumber(candidate.upstreamCost) }
: {}),
...(typeof candidate.generationId === "string" ? { generationId: candidate.generationId } : {}),
...(typeof candidate.routedProvider === "string" ? { routedProvider: candidate.routedProvider } : {}),
},
];
})
: [];
return {
requestId: value.requestId,
provider: value.provider,
modelId: value.modelId,
startedAt: value.startedAt,
steps,
...(typeof value.completedAt === "string" ? { completedAt: value.completedAt } : {}),
...(getFiniteNonNegativeNumber(value.durationMs) !== undefined
? { durationMs: getFiniteNonNegativeNumber(value.durationMs) }
: {}),
...(getFiniteNonNegativeNumber(value.timeToFirstOutputMs) !== undefined
? { timeToFirstOutputMs: getFiniteNonNegativeNumber(value.timeToFirstOutputMs) }
: {}),
...(getFiniteNonNegativeNumber(value.outputTokensPerSecond) !== undefined
? { outputTokensPerSecond: getFiniteNonNegativeNumber(value.outputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(value.contextLength) !== undefined
? { contextLength: getFiniteNonNegativeNumber(value.contextLength) }
: {}),
...(getFiniteNonNegativeNumber(value.contextUsed) !== undefined
? { contextUsed: getFiniteNonNegativeNumber(value.contextUsed) }
: {}),
...(getFiniteNonNegativeNumber(value.contextUtilization) !== undefined
? { contextUtilization: Math.min(getFiniteNonNegativeNumber(value.contextUtilization) ?? 0, 1) }
: {}),
...(getFiniteNonNegativeNumber(value.cost) !== undefined ? { cost: getFiniteNonNegativeNumber(value.cost) } : {}),
...(getFiniteNonNegativeNumber(value.upstreamCost) !== undefined
? { upstreamCost: getFiniteNonNegativeNumber(value.upstreamCost) }
: {}),
...(value.costType === "reported" || value.costType === "estimated" ? { costType: value.costType } : {}),
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
};
};
@@ -133,10 +133,13 @@
margin-block: 0.9rem;
}
.userMessage {
.userMessageWrap {
width: fit-content;
max-width: 86%;
margin-inline-start: auto;
}
.userMessage {
padding: 0.65rem 0.85rem;
border-radius: 0.9rem 0.9rem 0.25rem 0.9rem;
background: var(--mantine-color-red-light);
@@ -151,6 +154,14 @@
overflow-wrap: anywhere;
}
.messageQuote {
margin: 0 0 0.55rem;
padding-inline-start: 0.65rem;
border-inline-start: 2px solid var(--mantine-color-red-filled);
color: var(--mantine-color-dimmed);
white-space: pre-wrap;
}
.messageError {
margin-top: 0.5rem;
padding: 0.65rem 0.75rem;
@@ -173,6 +184,170 @@
background: var(--mantine-color-default);
}
.toolHeader {
width: 100%;
text-align: start;
}
.disclosureIcon {
flex: 0 0 auto;
transition: transform 150ms ease;
}
.disclosureIcon[data-opened] {
transform: rotate(180deg);
}
.codeBlock {
max-height: 12rem;
margin: 0.25rem 0 0;
overflow: auto;
padding: 0.5rem;
border-radius: var(--mantine-radius-sm);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--mantine-color-body);
}
.reasoning {
margin-block: 0.45rem;
padding: 0.45rem 0.6rem;
border-inline-start: 2px solid var(--mantine-color-default-border);
}
.reasoning summary,
.telemetry summary {
cursor: pointer;
list-style: none;
}
.reasoning summary::-webkit-details-marker,
.telemetry summary::-webkit-details-marker {
display: none;
}
.reasoningText {
margin-top: 0.45rem;
white-space: pre-wrap;
}
.source {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-inline-end: 0.4rem;
}
.messageFile,
.attachment {
width: fit-content;
max-width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-default);
}
.messageImage {
display: block;
width: auto;
max-width: min(100%, 28rem);
max-height: 22rem;
margin-block: 0.5rem;
border-radius: var(--mantine-radius-md);
object-fit: contain;
}
.attachment {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0 0.35rem 0.35rem 0;
}
.attachmentThumb {
display: grid;
width: 1.7rem;
height: 1.7rem;
place-items: center;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-dimmed);
background: var(--mantine-color-body);
font-size: 0.62rem;
font-weight: 700;
}
.attachmentName {
max-width: 12rem;
}
.messageActions {
min-height: 1.75rem;
margin-top: 0.3rem;
}
.userActions {
min-height: 1.75rem;
}
.branchPicker {
display: flex;
min-height: 1.75rem;
align-items: center;
gap: 0.1rem;
}
.editComposer {
margin-block: 0.9rem;
padding: 0.65rem;
border: 1px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default);
}
.editComposerInput {
width: 100%;
min-height: 4.5rem;
resize: vertical;
border: 0;
outline: 0;
color: var(--mantine-color-text);
background: transparent;
font: inherit;
}
.telemetry {
margin-top: 0.65rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-default) 60%, transparent);
}
.telemetrySummary {
user-select: none;
}
.usageGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 0.75rem;
}
.stepRow {
padding: 0.3rem 0.4rem;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.generationId {
overflow: hidden;
margin-top: 0.2rem;
font-family: var(--mantine-font-family-monospace);
text-overflow: ellipsis;
white-space: nowrap;
}
.composerWrap {
flex: 0 0 auto;
padding: 0.65rem 0.75rem 0.75rem;
@@ -181,11 +356,13 @@
}
.composer {
position: relative;
display: flex;
width: 100%;
max-width: 44rem;
margin-inline: auto;
align-items: flex-end;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.4rem;
border: 1px solid var(--mantine-color-default-border);
@@ -193,6 +370,45 @@
background: var(--mantine-color-default);
}
.composerDropzone[data-dragging] .composer {
border-color: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
}
.composerQuote {
display: flex;
min-width: 0;
align-items: center;
gap: 0.45rem;
padding: 0.4rem 0.5rem;
border-inline-start: 2px solid var(--mantine-color-red-filled);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.composerQuoteText {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.selectionToolbar {
z-index: 400;
padding: 0.2rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-md);
}
.composerRow {
width: 100%;
}
.composer:focus-within {
border-color: var(--mantine-primary-color-filled);
}
@@ -210,6 +426,53 @@
font: inherit;
}
.composerHints {
padding-inline: 0.45rem;
}
.triggerPopover {
position: absolute;
z-index: 5;
inset-inline: 0;
bottom: calc(100% + 0.45rem);
max-height: min(22rem, 45dvh);
overflow: auto;
padding: 0.35rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
}
.triggerList {
display: grid;
gap: 0.15rem;
}
.triggerItem {
display: flex;
width: 100%;
min-height: 2.75rem;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.55rem;
border: 0;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-text);
background: transparent;
text-align: start;
}
.triggerItem:hover,
.triggerItem[data-highlighted] {
background: var(--mantine-color-default-hover);
}
.triggerItemText {
min-width: 0;
flex: 1;
}
.composerInput::placeholder {
color: var(--mantine-color-dimmed);
opacity: 1;
@@ -342,8 +605,20 @@
}
.userMessage {
max-width: 100%;
}
.userMessageWrap {
max-width: 92%;
}
.usageGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.composerHints {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1,27 +1,44 @@
"use client";
import { createContext, useContext, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { createContext, useContext, useMemo, useState } from "react";
import type {
FileMessagePartProps,
ImageMessagePartProps,
ReasoningMessagePartProps,
SourceMessagePartProps,
ToolCallMessagePartProps,
Unstable_TriggerItem,
} from "@assistant-ui/react";
import type { MessageStatus } from "@assistant-ui/react";
import {
ActionBarPrimitive,
AttachmentPrimitive,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePartPrimitive,
MessagePrimitive,
SelectionToolbarPrimitive,
ThreadListItemPrimitive,
ThreadListPrimitive,
ThreadPrimitive,
useAui,
useAuiState,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
} from "@assistant-ui/react";
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Collapse,
Divider,
Group,
Loader,
Popover,
Progress,
ScrollArea,
Stack,
Text,
@@ -35,23 +52,42 @@ import {
IconAlertTriangle,
IconApps,
IconArrowUp,
IconAt,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCopy,
IconDownload,
IconCommand,
IconFile,
IconFileExport,
IconLink,
IconMessage,
IconMicrophone,
IconPaperclip,
IconPencil,
IconHistory,
IconPlayerStop,
IconPlus,
IconQuote,
IconRefresh,
IconRobot,
IconSearch,
IconSpeakerphone,
IconThumbDown,
IconThumbUp,
IconTool,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
interface AssistantPanelProps {
opened: boolean;
@@ -67,6 +103,90 @@ interface AssistantPanelProps {
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useScopedI18n("common.assistant");
return (
<AttachmentPrimitive.Root className={classes.attachment}>
<AttachmentPrimitive.unstable_Thumb className={classes.attachmentThumb} />
<Text size="xs" lineClamp={1} className={classes.attachmentName}>
<AttachmentPrimitive.Name />
</Text>
{removable && (
<AttachmentPrimitive.Remove asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("removeAttachment")}>
<IconX size={12} />
</ActionIcon>
</AttachmentPrimitive.Remove>
)}
</AttachmentPrimitive.Root>
);
};
const SentAttachment = () => <Attachment />;
const ReasoningPart = ({ text, status }: ReasoningMessagePartProps) => {
const t = useScopedI18n("common.assistant");
return (
<details className={classes.reasoning} open={status.type === "running"}>
<summary>
<Group gap="xs" wrap="nowrap">
<Loader type="bars" size="xs" color="gray" style={{ opacity: status.type === "running" ? 1 : 0 }} />
<Text size="xs" fw={600} c="dimmed">
{t("reasoning")}
</Text>
</Group>
</summary>
<Text size="sm" c="dimmed" className={classes.reasoningText}>
{text}
</Text>
</details>
);
};
const SourcePart = (source: SourceMessagePartProps) => (
<Anchor
className={classes.source}
href={source.url}
target={source.url ? "_blank" : undefined}
rel={source.url ? "noreferrer" : undefined}
size="xs"
>
<IconLink size={13} />
{source.title}
</Anchor>
);
const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const downloadable = data.startsWith("data:");
const displayName = filename ?? mimeType;
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconFile size={16} />
<Text size="xs" lineClamp={1} flex={1}>
{displayName}
</Text>
{downloadable && (
<ActionIcon
component="a"
href={data}
download={filename ?? "assistant-file"}
variant="subtle"
color="gray"
size="xs"
aria-label={t("downloadFile", { name: displayName })}
>
<IconDownload size={13} />
</ActionIcon>
)}
</Group>
);
};
const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useScopedI18n("common.assistant");
return <Box component="img" src={image} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
};
const ToolPart = ({
toolName,
args,
@@ -75,8 +195,10 @@ const ToolPart = ({
status,
approval,
respondToApproval,
timing,
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
@@ -86,40 +208,56 @@ const ToolPart = ({
status?.type === "incomplete" ||
(typeof result === "object" && result !== null && "error" in result));
const successful = completed && !denied && !failed;
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
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>
<UnstyledButton
className={classes.toolHeader}
onClick={() => setOpened((current) => !current)}
aria-expanded={opened || awaitingApproval}
>
<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>
<Group gap={6} wrap="nowrap">
{duration !== undefined && (
<Text size="xs" c="dimmed">
{duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`}
</Text>
)}
<Text size="xs" c="dimmed">
{awaitingApproval
? t("approvalRequired")
: denied
? t("denied")
: failed
? t("failed")
: successful
? t("complete")
: t("working")}
</Text>
<IconChevronDown
size={14}
className={classes.disclosureIcon}
data-opened={opened || awaitingApproval || undefined}
/>
</Group>
</Group>
<Text size="xs" c="dimmed">
{awaitingApproval
? t("approvalRequired")
: denied
? t("denied")
: failed
? t("failed")
: successful
? t("complete")
: t("working")}
</Text>
</Group>
</UnstyledButton>
{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")}
@@ -130,44 +268,366 @@ const ToolPart = ({
</Group>
</Stack>
)}
{!awaitingApproval && !denied && result !== undefined && (
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
{typeof result === "string" ? result : JSON.stringify(result)}
</Text>
)}
<Collapse expanded={opened || awaitingApproval}>
<Stack gap="xs" mt="sm">
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolInput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{JSON.stringify(args, null, 2)}
</Text>
</Box>
{!denied && result !== undefined && (
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolOutput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</Text>
</Box>
)}
</Stack>
</Collapse>
</Box>
);
};
const MessageActions = () => {
const BranchPicker = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning>
<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 = useScopedI18n("common.assistant");
return (
<Group className={classes.messageActions} gap="xs" justify="space-between">
<BranchPicker />
<ActionBarPrimitive.Root hideWhenRunning>
<Group gap={2}>
<Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
<IconCopy size={14} />
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("speak")}>
<ActionBarPrimitive.Speak asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("speak")}>
<IconSpeakerphone size={14} />
</ActionIcon>
</ActionBarPrimitive.Speak>
</Tooltip>
<Tooltip label={t("stopSpeaking")}>
<ActionBarPrimitive.StopSpeaking asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("stopSpeaking")}>
<IconPlayerStop size={14} />
</ActionIcon>
</ActionBarPrimitive.StopSpeaking>
</Tooltip>
<Tooltip label={t("helpful")}>
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
<IconThumbUp size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackPositive>
</Tooltip>
<Tooltip label={t("notHelpful")}>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
<IconThumbDown size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackNegative>
</Tooltip>
<Tooltip label={t("exportMarkdown")}>
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("exportMarkdown")}>
<IconFileExport size={14} />
</ActionIcon>
</ActionBarPrimitive.ExportMarkdown>
</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>
</Group>
</ActionBarPrimitive.Root>
</Group>
);
};
const UserMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
<Group gap={2}>
<Tooltip label={t("copy")}>
<Tooltip label={t("copyMessage")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm">
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copyMessage")}>
<IconCopy size={14} />
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconRefresh size={14} />
<Tooltip label={t("editMessage")}>
<ActionBarPrimitive.Edit asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("editMessage")}>
<IconPencil size={14} />
</ActionIcon>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Edit>
</Tooltip>
</Group>
</ActionBarPrimitive.Root>
);
};
const UserMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessage}`}>
<MessagePrimitive.Parts components={{ Text: TextPart }} />
</MessagePrimitive.Root>
);
const EditComposer = () => {
const t = useScopedI18n("common.assistant");
return (
<ComposerPrimitive.Root className={classes.editComposer}>
<ComposerPrimitive.Input className={classes.editComposerInput} rows={2} />
<Group gap="xs" justify="flex-end">
<ComposerPrimitive.Cancel asChild>
<Button size="compact-sm" variant="default">
{t("cancelEdit")}
</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}`}>
<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: TextPart, File: FilePart, Image: ImagePart }} />
</Box>
<Group justify="flex-end" gap="xs">
<BranchPicker />
<UserMessageActions />
</Group>
</MessagePrimitive.Root>
);
};
const formatDuration = (milliseconds: number) =>
milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
const formatCost = (cost: number) => {
if (cost === 0) return "$0";
if (cost < 0.0001) return `<$0.0001`;
return `$${cost.toFixed(cost < 0.01 ? 4 : 3)}`;
};
const RequestTelemetry = () => {
const t = useScopedI18n("common.assistant");
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
const usage = getAssistantUsage(metadata);
if (!telemetry) return null;
return (
<details className={classes.telemetry}>
<summary className={classes.telemetrySummary}>
<Group gap="xs" wrap="wrap">
<Badge size="xs" variant="light" color="gray">
{telemetry.modelId}
</Badge>
<Badge size="xs" variant="outline" color="gray">
{telemetry.provider}
</Badge>
{telemetry.durationMs !== undefined && (
<Text size="xs" c="dimmed">
{formatDuration(telemetry.durationMs)}
</Text>
)}
{telemetry.outputTokensPerSecond !== undefined && (
<Text size="xs" c="dimmed">
{telemetry.outputTokensPerSecond.toFixed(1)} tok/s
</Text>
)}
{telemetry.cost !== undefined && (
<Text size="xs" c="dimmed">
{formatCost(telemetry.cost)}
</Text>
)}
{usage?.totalTokens !== undefined && (
<Text size="xs" c="dimmed">
{usage.totalTokens.toLocaleString()} {t("usage.tokens")}
</Text>
)}
<IconChevronDown size={13} />
</Group>
</summary>
<Stack gap="sm" mt="xs">
{telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined && (
<Box>
<Group justify="space-between" gap="xs">
<Text size="xs" fw={600}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{telemetry.contextUsed.toLocaleString()} / {telemetry.contextLength.toLocaleString()}
</Text>
</Group>
<Progress value={(telemetry.contextUtilization ?? 0) * 100} size="sm" mt={5} />
</Box>
)}
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
{t("usage.input")}
</Text>
<Text size="sm" fw={600}>
{usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.output")}
</Text>
<Text size="sm" fw={600}>
{usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cached")}
</Text>
<Text size="sm" fw={600}>
{usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.reasoning")}
</Text>
<Text size="sm" fw={600}>
{usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cacheWrite")}
</Text>
<Text size="sm" fw={600}>
{usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
<Divider />
<Group gap="lg" align="flex-start">
<div>
<Text size="xs" c="dimmed">
{t("usage.firstOutput")}
</Text>
<Text size="sm" fw={600}>
{telemetry.timeToFirstOutputMs !== undefined
? formatDuration(telemetry.timeToFirstOutputMs)
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={600}>
{telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")}
</Text>
{telemetry.costType && (
<Text size="xs" c="dimmed">
{t(`usage.${telemetry.costType}`)}
</Text>
)}
</div>
{telemetry.upstreamCost !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.upstreamCost")}
</Text>
<Text size="sm" fw={600}>
{formatCost(telemetry.upstreamCost)}
</Text>
</div>
)}
{telemetry.finishReason && (
<div>
<Text size="xs" c="dimmed">
{t("usage.finishReason")}
</Text>
<Text size="sm" fw={600}>
{telemetry.finishReason}
</Text>
</div>
)}
</Group>
{telemetry.steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600}>
{t("usage.agentSteps")}
</Text>
{telemetry.steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs">
<Text size="xs">
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</Text>
)}
</Box>
))}
</Stack>
)}
</Stack>
</details>
);
};
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
@@ -206,11 +666,16 @@ const AssistantMessage = () => (
<MessagePrimitive.Parts
components={{
Text: TextPart,
Reasoning: ReasoningPart,
Source: SourcePart,
File: FilePart,
Image: ImagePart,
tools: { Fallback: ToolPart },
}}
/>
<RuntimeError />
<MessageActions />
<RequestTelemetry />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
@@ -355,27 +820,219 @@ const EmptyThread = () => {
);
};
const contextIcons = {
app: IconApps,
integration: IconLink,
board: IconMessage,
widget: IconTool,
tools: IconTool,
};
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
const Icon =
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
? contextIcons[item.metadata.icon as keyof typeof contextIcons]
: IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<div className={classes.triggerItemText}>
<Text size="sm" fw={600} lineClamp={1}>
{item.label}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</div>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
);
};
const ComposerTriggers = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
const categories = useMemo(
() =>
(["app", "integration", "board", "widget"] as const).map((type) => ({
id: type,
label: t(`mentions.${type}`),
items: entities
.filter((entity) => entity.type === type)
.map((entity) => ({
id: entity.id,
type: entity.type,
label: entity.label,
description: entity.description,
icon: entity.type,
})),
})),
[entities, t],
);
const mention = unstable_useMentionAdapter({
categories,
includeModelContextTools: {
category: { id: "tools", label: t("mentions.tools") },
formatLabel: (name) => name.replaceAll("_", " "),
icon: "tools",
},
iconMap: contextIcons,
fallbackIcon: IconAt,
});
const slash = unstable_useSlashCommandAdapter({
removeOnExecute: true,
commands: [
{
id: "health",
label: "/health",
description: t("commands.health"),
execute: () => aui.composer().setText(t("suggestions.health.prompt")),
},
{
id: "explore",
label: "/explore",
description: t("commands.explore"),
execute: () => aui.composer().setText(t("suggestions.explore.prompt")),
},
{
id: "media",
label: "/media",
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
],
});
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
{(items) =>
items.map((category) => {
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={category.id}
categoryId={category.id}
className={classes.triggerItem}
>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<Text size="sm" fw={600} flex={1}>
{category.label}
</Text>
<IconChevronRight size={14} />
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
);
})
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) =>
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Unstable_TriggerPopover
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</>
);
};
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>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
<ComposerPrimitive.Root className={classes.composer}>
<ComposerTriggers />
<ComposerPrimitive.Quote className={classes.composerQuote}>
<IconQuote size={15} />
<ComposerPrimitive.QuoteText className={classes.composerQuoteText} />
<ComposerPrimitive.QuoteDismiss asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("dismissQuote")}>
<IconX size={12} />
</ActionIcon>
</ComposerPrimitive.QuoteDismiss>
</ComposerPrimitive.Quote>
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="flex-end">
<Group gap={2} wrap="nowrap">
<Tooltip label={t("attachments.add")}>
<ComposerPrimitive.AddAttachment asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("attachments.add")}>
<IconPaperclip size={17} />
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
<Tooltip label={t("dictate")}>
<ComposerPrimitive.Dictate asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("dictate")}>
<IconMicrophone size={17} />
</ActionIcon>
</ComposerPrimitive.Dictate>
</Tooltip>
<ComposerPrimitive.StopDictation asChild>
<ActionIcon variant="light" color="red" size="lg" aria-label={t("stopDictation")}>
<IconPlayerStop size={17} />
</ActionIcon>
</ComposerPrimitive.StopDictation>
</Group>
<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>
)}
</Group>
<Group className={classes.composerHints} gap="xs">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
</Group>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
</Box>
);
};
@@ -536,6 +1193,13 @@ export const AssistantPanel = ({
<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}
@@ -4,6 +4,7 @@ import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type {
AttachmentAdapter,
MessageFormatAdapter,
RemoteThreadListAdapter,
ThreadHistoryAdapter,
@@ -12,16 +13,18 @@ import type {
} from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
WebSpeechDictationAdapter,
WebSpeechSynthesisAdapter,
defineToolkit,
Tools,
useAui,
useAuiEvent,
useAuiState,
useRemoteThreadListRuntime,
} from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
import type { UIMessage } from "ai";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { clientApi, fetchApi } from "@homarr/api/client";
@@ -39,6 +42,7 @@ import {
import { AssistantPanel } from "./assistant-panel";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
import type { AssistantUIMessage } from "./assistant-message-metadata";
interface AssistantContextValue {
enabled: boolean;
@@ -53,6 +57,82 @@ interface AssistantContextValue {
const AssistantContext = createContext<AssistantContextValue | null>(null);
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
const assistantDocumentAttachmentTypes = [
"application/json",
"text/csv",
"text/html",
"text/markdown",
"text/plain",
"text/xml",
];
const fileToDataUrlAsync = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
reader.addEventListener("error", () => reject(new Error("The attachment could not be read.")), { once: true });
reader.readAsDataURL(file);
});
const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapter => {
const pendingAttachmentIds = new Set<string>();
return {
accept: [...(allowImages ? assistantImageAttachmentTypes : []), ...assistantDocumentAttachmentTypes].join(","),
async add({ file }) {
if (pendingAttachmentIds.size >= 5) {
throw new Error("A message can include up to 5 attachments.");
}
const isImage = file.type.startsWith("image/");
if (isImage && !allowImages) {
throw new Error("The selected model does not support image input.");
}
if (
(!isImage && !assistantDocumentAttachmentTypes.includes(file.type)) ||
(isImage && !assistantImageAttachmentTypes.includes(file.type))
) {
throw new Error("This file type is not supported.");
}
const sizeLimit = isImage ? 1_000_000 : 350_000;
if (file.size === 0) throw new Error("Empty files cannot be attached.");
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();
pendingAttachmentIds.add(id);
return {
id,
type: isImage ? "image" : "document",
name: file.name,
file,
contentType: file.type,
status: { type: "requires-action", reason: "composer-send" },
};
},
async send(attachment) {
try {
return {
...attachment,
status: { type: "complete" },
content: [
{
type: "file",
filename: attachment.name,
mimeType: attachment.contentType ?? "application/octet-stream",
data: await fileToDataUrlAsync(attachment.file),
},
],
};
} finally {
pendingAttachmentIds.delete(attachment.id);
}
},
async remove(attachment) {
pendingAttachmentIds.delete(attachment.id);
},
};
};
export const useHomarrAssistant = () => {
const value = useContext(AssistantContext);
@@ -190,6 +270,27 @@ const AssistantThreadRuntime = () => {
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const { data: modelCapabilities } = clientApi.assistant.getModelCapabilities.useQuery(undefined, {
staleTime: 10 * 60_000,
});
const attachments = useMemo(
() => createAssistantAttachmentAdapter(modelCapabilities?.imageInput !== "unsupported"),
[modelCapabilities?.imageInput],
);
const speech = useMemo(() => new WebSpeechSynthesisAdapter(), []);
const dictation = useMemo(
() => (WebSpeechDictationAdapter.isSupported() ? new WebSpeechDictationAdapter() : undefined),
[],
);
const feedback = useMemo(
() => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
if (!threadId) return;
void fetchApi.assistant.submitFeedback.mutate({ threadId, messageId: message.id, type });
},
}),
[threadId],
);
const onError = useCallback(
() =>
showErrorNotification({
@@ -200,11 +301,17 @@ const AssistantThreadRuntime = () => {
[t],
);
return useChatRuntime<UIMessage>({
return useChatRuntime<AssistantUIMessage>({
transport,
onError,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
adapters: { history },
adapters: {
history,
attachments,
speech,
...(dictation ? { dictation } : {}),
feedback,
},
});
};
@@ -265,6 +372,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
return (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantTools toolkit={toolkit} />
<AssistantRuntimeEvents />
{children}
</AssistantRuntimeProvider>
);
@@ -275,6 +383,17 @@ const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
return null;
};
const AssistantRuntimeEvents = () => {
const t = useScopedI18n("common.assistant");
useAuiEvent("composer.attachmentAddError", ({ message }) => {
showErrorNotification({
title: t("attachments.errorTitle"),
message,
});
});
return null;
};
const getMessageText = (message: ThreadMessage | undefined) =>
message?.content
.filter((part) => part.type === "text")