feat!: release v2 (#6545)

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>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
import type { ButtonProps } from "@mantine/core";
export interface AccessPickerOption<T> {
value: string;
label: string;
keywords?: readonly string[];
content: ReactNode;
item: T;
}
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
interface AccessPickerProps<T> {
label: string;
searchPlaceholder: string;
emptyMessage: string;
triggerLabel: string;
options: AccessPickerOption<T>[];
isPending: boolean;
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
triggerProps?: AccessPickerTriggerProps;
}
export const AccessPicker = <T,>({
label,
searchPlaceholder,
emptyMessage,
triggerLabel,
options,
isPending,
onSelect,
triggerProps,
}: AccessPickerProps<T>) => {
const [search, setSearch] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
requestAnimationFrame(() => combobox.focusTarget());
},
onDropdownOpen: () => {
combobox.selectFirstOption();
requestAnimationFrame(() => combobox.focusSearchInput());
},
});
const normalizedSearch = search.trim().toLowerCase();
const filteredOptions = options.filter((option) => {
if (!normalizedSearch) return true;
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
return searchableText.includes(normalizedSearch);
});
const visibleOptions = filteredOptions.slice(0, 5);
const handleSelect = (value: string) => {
const option = options.find((item) => item.value === value);
if (!option || isSubmitting) return;
combobox.closeDropdown();
setIsSubmitting(true);
const submit = async () => {
try {
await onSelect(option);
} finally {
setIsSubmitting(false);
}
};
void submit().catch(() => undefined);
};
return (
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
<Combobox.Target targetType="button">
<Button
{...triggerProps}
onClick={() => combobox.toggleDropdown()}
rightSection={
<Group gap={4} wrap="nowrap">
{triggerProps?.rightSection}
<Combobox.Chevron />
</Group>
}
loading={isSubmitting}
disabled={isSubmitting || triggerProps?.disabled}
>
{triggerLabel}
</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
placeholder={searchPlaceholder}
aria-label={label}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Combobox.Options>
{isPending && (
<Center p="md">
<LoaderPlaceholder />
</Center>
)}
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
{!isPending &&
visibleOptions.map((option) => (
<Combobox.Option value={option.value} key={option.value}>
{option.content}
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
const LoaderPlaceholder = () => <Loader size="sm" />;
@@ -3,7 +3,7 @@ import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
@@ -178,7 +178,7 @@ interface TabItemProps {
}
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
const t = useScopedI18n("permission");
const t = useI18n("permission");
return (
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
@@ -18,7 +18,7 @@ interface AccessSelectRowProps {
}
export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => {
const tRoot = useI18n();
const tCommon = useI18n("common");
const { icons, getSelectData } = useAccessContext();
const form = useFormContext();
@@ -48,7 +48,7 @@ export const AccessSelectRow = ({ itemContent, permission, index, handleCountCha
/>
<Button size="xs" variant="subtle" onClick={handleRemove}>
{tRoot("common.action.remove")}
{tCommon("action.remove")}
</Button>
</Flex>
</TableTd>
@@ -2,8 +2,7 @@ import { useState } from "react";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { AccessQueryData } from "./access-settings";
@@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import { GroupSelect } from "./group-select";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
@@ -24,9 +23,7 @@ export const GroupAccessForm = <TPermission extends string>({
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("permission");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
items: accessQueryData.groups.map(({ group, permission }) => ({
@@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
@@ -79,11 +59,23 @@ export const GroupAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
</Button>
<GroupSelect
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
onSelect={(group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{t("permission.action.saveGroup")}
{tPermissions("action.saveGroup")}
</Button>
</Group>
</Stack>
@@ -1,72 +0,0 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={t("group.action.select.label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});
@@ -0,0 +1,66 @@
"use client";
import { Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
interface GroupSelectProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const GroupSelect = ({
withPermissions,
presentGroupIds,
onSelect,
triggerLabel,
triggerProps,
}: GroupSelectProps) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
value: group.id,
label: group.name,
keywords: getGroupPermissions(group) ?? [],
item: group,
content: <Text size="sm">{group.name}</Text>,
}));
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
onSelect({
id: group.id,
name: group.name,
permissions: getGroupPermissions(group),
});
return (
<AccessPicker
label={tGroup("label")}
searchPlaceholder={tGroup("label")}
emptyMessage={tGroup("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
const getGroupPermissions = (group: SelectableGroup) => {
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
return undefined;
};
@@ -2,7 +2,7 @@ import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine
import type { GroupPermissionKey } from "@homarr/definitions";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { AccessQueryData } from "./access-settings";
import { AccessDisplayRow } from "./access-table-rows";
@@ -19,7 +19,7 @@ export const InheritAccessTable = <TPermission extends string>({
mapPermissions,
fullAccessGroupPermission,
}: InheritTableProps<TPermission>) => {
const tPermissions = useScopedI18n("permission");
const tPermissions = useI18n("permission");
return (
<Stack pt="sm">
<Table>
@@ -2,8 +2,7 @@ import { useState } from "react";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import type { AccessQueryData } from "./access-settings";
@@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
import { UserSelect } from "./user-select";
export interface FormProps<TPermission extends string> {
entity: {
@@ -41,9 +40,7 @@ export const UsersAccessForm = <TPermission extends string>({
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("permission");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
items: accessQueryData.users.map(({ user, permission }) => ({
@@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({
})),
},
});
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
if (entity.ownerId) presentUserIds.push(entity.ownerId);
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
@@ -101,11 +81,23 @@ export const UsersAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
</Button>
<UserSelect
presentUserIds={presentUserIds}
onSelect={(user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{t("permission.action.saveUser")}
{tPermissions("action.saveUser")}
</Button>
</Group>
</Stack>
@@ -1,102 +0,0 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
email: currentUser.email ?? null,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={t("user.action.select.label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={t("user.action.select.notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
@@ -0,0 +1,75 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableUser = RouterOutputs["user"]["selectable"][number];
interface UserSelectProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const UserSelect = ({
presentUserIds,
allowedProviders,
onSelect,
triggerLabel,
triggerProps,
}: UserSelectProps) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
value: user.id,
label: user.name ?? "",
keywords: [user.email ?? ""],
item: user,
content: (
<Group wrap="nowrap" gap="xs">
<UserAvatar user={user} size="xs" />
<Stack gap={0}>
<Text size="sm">{user.name}</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</Stack>
</Group>
),
}));
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
onSelect({
id: user.id,
name: user.name ?? "",
image: user.image ?? "",
email: user.email ?? null,
});
return (
<AccessPicker
label={tUser("label")}
searchPlaceholder={tUser("label")}
emptyMessage={tUser("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
@@ -1,32 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback } from "react";
import { usePathname } from "next/navigation";
import type { AccordionProps } from "@mantine/core";
import { Accordion } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
type ActiveTabAccordionProps = PropsWithChildren<Omit<AccordionProps<false>, "onChange">>;
// Replace state without fetchign new data
const replace = (newUrl: string) => {
window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl);
};
export const ActiveTabAccordion = ({ children, ...props }: ActiveTabAccordionProps) => {
const pathname = usePathname();
const onChange = useCallback((tab: string | null) => (tab ? replace(`?tab=${tab}`) : replace(pathname)), [pathname]);
useShallowEffect(() => {
if (props.defaultValue) {
replace(`?tab=${props.defaultValue}`);
}
}, [props.defaultValue]);
return (
<Accordion {...props} onChange={onChange}>
{children}
</Accordion>
);
};
@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import { getAssistantActivityState, getRunningAssistantPartType } from "./assistant-activity-state";
describe("getAssistantActivityState", () => {
test("does not treat output from the previous completed response as current streaming", () => {
expect(getRunningAssistantPartType("complete", "text")).toBeUndefined();
expect(getRunningAssistantPartType("running", "text")).toBe("text");
});
test("distinguishes thinking from a response that is already streaming", () => {
expect(
getAssistantActivityState({ isRunning: true, latestPartType: undefined, needsApproval: false, failed: false }),
).toBe("thinking");
expect(
getAssistantActivityState({ isRunning: true, latestPartType: "reasoning", needsApproval: false, failed: false }),
).toBe("thinking");
expect(
getAssistantActivityState({ isRunning: true, latestPartType: "text", needsApproval: false, failed: false }),
).toBe("streaming");
});
test("shows a waiting pattern while a tool is running", () => {
expect(
getAssistantActivityState({
isRunning: true,
latestPartType: "tool-call",
needsApproval: false,
failed: false,
}),
).toBe("waiting");
});
test("returns to thinking after the latest tool completes and the next model step starts", () => {
expect(getRunningAssistantPartType("running", "tool-call", true)).toBeUndefined();
expect(
getAssistantActivityState({
isRunning: true,
latestPartType: getRunningAssistantPartType("running", "tool-call", true),
needsApproval: false,
failed: false,
}),
).toBe("thinking");
});
test("maps settled states to waiting, failure, and success glyphs", () => {
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: true, failed: false }),
).toBe("waiting");
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: false, failed: true }),
).toBe("error");
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: false, failed: false }),
).toBe("success");
});
test("keeps a required action visible even if the last run also failed", () => {
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: true, failed: true }),
).toBe("waiting");
});
});
@@ -0,0 +1,34 @@
import type { AssistantDotMatrixState } from "./assistant-dot-matrix";
interface AssistantActivityStateInput {
isRunning: boolean;
latestPartType: string | undefined;
needsApproval: boolean;
failed: boolean;
}
export const getRunningAssistantPartType = (
messageStatusType: string | undefined,
latestPartType: string | undefined,
latestToolComplete = false,
) => {
if (messageStatusType !== "running") return undefined;
if (latestPartType === "tool-call" && latestToolComplete) return undefined;
return latestPartType;
};
export const getAssistantActivityState = ({
isRunning,
latestPartType,
needsApproval,
failed,
}: AssistantActivityStateInput): AssistantDotMatrixState => {
if (isRunning) {
if (latestPartType === "tool-call") return "waiting";
if (latestPartType === undefined || latestPartType === "reasoning") return "thinking";
return "streaming";
}
if (needsApproval) return "waiting";
if (failed) return "error";
return "success";
};
@@ -0,0 +1,180 @@
// @vitest-environment node
import { AssistantChatTransport } from "@assistant-ui/react-ai-sdk";
import { jsonSchema, readUIMessageStream, simulateReadableStream, stepCountIs, streamText, tool } from "ai";
import { MockLanguageModelV4 } from "ai/test";
import { describe, expect, test, vi } from "vitest";
const usage = {
inputTokens: { total: 8, noCache: 8, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 4, text: 4, reasoning: 0 },
};
const finish = (unified: "stop" | "tool-calls") => ({
type: "finish" as const,
finishReason: { unified, raw: unified },
usage,
});
describe("assistant-ui AI SDK v7 transport", () => {
test("round-trips a multi-step server tool response into one UI message", async () => {
const model = new MockLanguageModelV4({
doStream: [
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{
type: "tool-call" as const,
toolCallId: "lookup-call",
toolName: "test_lookup",
input: '{"query":"homarr"}',
},
finish("tool-calls"),
],
chunkDelayInMs: null,
}),
},
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{ type: "text-start" as const, id: "answer" },
{ type: "text-delta" as const, id: "answer", delta: "Migration stream complete." },
{ type: "text-end" as const, id: "answer" },
finish("stop"),
],
chunkDelayInMs: null,
}),
},
],
});
const result = streamText({
model,
prompt: "Verify the migrated assistant stream.",
tools: {
test_lookup: tool({
inputSchema: jsonSchema({
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
}),
execute: ({ query }) => ({ query, value: "ok" }),
}),
},
stopWhen: stepCountIs(2),
});
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
expect(JSON.parse(String(init?.body))).toMatchObject({
id: "thread-1",
messages: [{ id: "user-1", role: "user", parts: [{ type: "text", text: "Run migration test" }] }],
});
return result.toUIMessageStreamResponse({
sendReasoning: true,
messageMetadata: ({ part }) => (part.type === "finish" ? { runtime: "ai-sdk-v7" } : undefined),
});
});
const transport = new AssistantChatTransport({
api: "https://homarr.test/api/assistant/chat",
fetch: fetchMock,
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "thread-1",
messageId: undefined,
messages: [{ id: "user-1", role: "user", parts: [{ type: "text", text: "Run migration test" }] }],
abortSignal: undefined,
});
let finalMessage: unknown;
for await (const message of readUIMessageStream({ stream })) finalMessage = message;
expect(fetchMock).toHaveBeenCalledOnce();
expect(finalMessage).toMatchObject({
role: "assistant",
metadata: { runtime: "ai-sdk-v7" },
});
expect(JSON.stringify(finalMessage)).toContain('"type":"tool-test_lookup"');
expect(JSON.stringify(finalMessage)).toContain('"state":"output-available"');
expect(JSON.stringify(finalMessage)).toContain('"value":"ok"');
expect(JSON.stringify(finalMessage)).toContain("Migration stream complete.");
});
test("streams reasoning emitted after a completed tool call before the final answer", async () => {
const model = new MockLanguageModelV4({
doStream: [
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{
type: "tool-call" as const,
toolCallId: "lookup-call",
toolName: "test_lookup",
input: '{"query":"homarr"}',
},
finish("tool-calls"),
],
chunkDelayInMs: null,
}),
},
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{ type: "reasoning-start" as const, id: "post-tool-reasoning" },
{
type: "reasoning-delta" as const,
id: "post-tool-reasoning",
delta: "Checking the returned value.",
},
{ type: "reasoning-end" as const, id: "post-tool-reasoning" },
{ type: "text-start" as const, id: "answer" },
{ type: "text-delta" as const, id: "answer", delta: "Done." },
{ type: "text-end" as const, id: "answer" },
finish("stop"),
],
chunkDelayInMs: null,
}),
},
],
});
const result = streamText({
model,
prompt: "Use the tool and explain the result.",
tools: {
test_lookup: tool({
inputSchema: jsonSchema({
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
}),
execute: ({ query }) => ({ query, value: "ok" }),
}),
},
stopWhen: stepCountIs(2),
});
const transport = new AssistantChatTransport({
api: "https://homarr.test/api/assistant/chat",
fetch: () => Promise.resolve(result.toUIMessageStreamResponse({ sendReasoning: true })),
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "thread-1",
messageId: undefined,
messages: [{ id: "user-1", role: "user", parts: [{ type: "text", text: "Run the tool" }] }],
abortSignal: undefined,
});
const snapshots: string[] = [];
for await (const message of readUIMessageStream({ stream })) snapshots.push(JSON.stringify(message));
const reasoningSnapshotIndex = snapshots.findIndex((snapshot) => snapshot.includes("Checking the returned value."));
const answerSnapshotIndex = snapshots.findIndex((snapshot) => snapshot.includes("Done."));
expect(snapshots.join("\n")).toContain("Checking the returned value.");
expect(reasoningSnapshotIndex).toBeGreaterThanOrEqual(0);
expect(answerSnapshotIndex).toBeGreaterThan(reasoningSnapshotIndex);
});
});
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import {
getAssistantAffirmativeOption,
getAssistantAffirmativeResult,
getAssistantAskUserOptionKind,
} from "./assistant-ask-user";
describe("assistant structured choice categories", () => {
test("uses the explicit category instead of button order or wording", () => {
const options = [
{ id: "home", label: "Home", kind: "alternative" as const },
{ id: "continue", label: "Use the proposed board", kind: "affirmative" as const },
{ id: "cancel", label: "Keep the current board", kind: "negative" as const },
];
expect(getAssistantAffirmativeOption(options)).toEqual(options[1]);
expect(getAssistantAffirmativeResult(options)).toEqual({
answer: "Use the proposed board",
optionId: "continue",
optionKind: "affirmative",
source: "option",
});
});
test("does not guess among neutral selections", () => {
expect(
getAssistantAffirmativeOption([
{ id: "home", label: "Home", kind: "alternative" },
{ id: "media", label: "Media", kind: "alternative" },
]),
).toBeUndefined();
});
test("recognizes affirmative and negative legacy choices", () => {
expect(getAssistantAskUserOptionKind({ id: "yes", label: "Yes, create it" })).toBe("affirmative");
expect(getAssistantAskUserOptionKind({ id: "no", label: "No, keep it" })).toBe("negative");
expect(getAssistantAskUserOptionKind({ id: "home", label: "Home" })).toBe("alternative");
});
});
@@ -0,0 +1,37 @@
import type { AskUserArgs, AskUserResult, AssistantAskUserOptionKind } from "./assistant-tool-contracts";
export type AssistantAskUserOption = AskUserArgs["options"][number];
const affirmativeTokens = new Set(["accept", "agree", "allow", "approve", "confirm", "continue", "proceed", "yes"]);
const negativeTokens = new Set(["cancel", "decline", "deny", "disagree", "no", "reject", "stop"]);
const inferOptionKind = (option: Pick<AssistantAskUserOption, "id" | "label">): AssistantAskUserOptionKind => {
const tokens = `${option.id} ${option.label}`
.toLocaleLowerCase("en")
.split(/[^a-z]+/u)
.filter(Boolean);
if (tokens.some((token) => affirmativeTokens.has(token))) return "affirmative";
if (tokens.some((token) => negativeTokens.has(token))) return "negative";
return "alternative";
};
export const getAssistantAskUserOptionKind = (
option: Pick<AssistantAskUserOption, "id" | "label"> & { kind?: AssistantAskUserOptionKind },
) => option.kind ?? inferOptionKind(option);
export const getAssistantAffirmativeOption = (options: readonly AssistantAskUserOption[]) =>
options.find((option) => getAssistantAskUserOptionKind(option) === "affirmative");
export const getAssistantAffirmativeResult = (
options: readonly AssistantAskUserOption[],
): AskUserResult | undefined => {
const option = getAssistantAffirmativeOption(options);
if (!option) return undefined;
return {
answer: option.label,
optionId: option.id,
optionKind: "affirmative",
source: "option",
};
};
@@ -0,0 +1,102 @@
import type { UIMessage } from "ai";
import { describe, expect, test } from "vitest";
import { prepareAssistantMessagesForTransport, prepareAssistantRequestBody } from "./assistant-attachment-payload";
const routeRequestBudget = 12_000_000;
// A valid padded base64 payload that decodes to exactly the composer's 1,000,000-byte image limit.
const oneMegabyteBase64 = `${"A".repeat(1_333_334)}==`;
const createAttachmentTurn = (id: string, payloadMarker: string): UIMessage => ({
id,
role: "user",
parts: [
{ type: "text", text: `Review attachments from ${id}` },
...Array.from({ length: 5 }, (_, index) => ({
type: "file" as const,
filename: `${id}-${index}.png`,
mediaType: "image/png",
url: `data:image/png;base64,${payloadMarker}${oneMegabyteBase64.slice(payloadMarker.length)}`,
})),
],
});
describe("assistant attachment payload", () => {
test("keeps five current attachments but removes historical bytes before a second heavy turn", () => {
const firstMarker = "FIRSTPAYLOAD";
const secondMarker = "SECONDPAYLOAD";
const messages: UIMessage[] = [
createAttachmentTurn("user-1", firstMarker),
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "I reviewed the first set." }] },
createAttachmentTurn("user-2", secondMarker),
];
const preparedMessages = prepareAssistantMessagesForTransport(messages);
const body = JSON.stringify({ id: "thread-1", messages: preparedMessages });
expect(new TextEncoder().encode(body).byteLength).toBeLessThan(routeRequestBudget);
expect(body).not.toContain(firstMarker);
expect(body.match(/data:image\/png;base64,/gu)).toHaveLength(5);
expect(body).toContain(secondMarker);
expect(preparedMessages[0]?.parts).toContainEqual(
expect.objectContaining({ type: "text", text: expect.stringContaining('"filename":"user-1-0.png"') }),
);
expect(messages[0]?.parts.filter((part) => part.type === "file")).toHaveLength(5);
expect(JSON.stringify(messages[0])).toContain(firstMarker);
});
test("prunes the serialized transport envelope without changing its initialized thread id", () => {
const originalBody = JSON.stringify({
id: "remote-thread-id",
reasoning: "auto",
messages: [
createAttachmentTurn("user-1", "OLDINLINEBYTES"),
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "Done" }] },
{ id: "user-2", role: "user", parts: [{ type: "text", text: "Follow up" }] },
],
});
const prepared = prepareAssistantRequestBody(originalBody);
expect(typeof prepared).toBe("string");
expect(prepared).not.toContain("OLDINLINEBYTES");
expect(JSON.parse(prepared as string)).toMatchObject({ id: "remote-thread-id", reasoning: "auto" });
});
test("keeps the active user turn available during an automatic assistant continuation", () => {
const messages: UIMessage[] = [
createAttachmentTurn("user-1", "ORIGINALTURNBYTES"),
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "Calling a tool" }] },
];
const prepared = prepareAssistantMessagesForTransport(messages);
expect(JSON.stringify(prepared)).toContain("ORIGINALTURNBYTES");
expect(prepared[0]?.parts.filter((part) => part.type === "file")).toHaveLength(5);
});
test("preserves hosted and provider-referenced files in historical messages", () => {
const hostedPart = {
type: "file" as const,
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://files.example.test/report.pdf",
};
const providerPart = {
type: "file" as const,
filename: "provider-report.pdf",
mediaType: "application/pdf",
url: "",
providerReference: { openai: "file-1" },
};
const messages: UIMessage[] = [
{ id: "user-1", role: "user", parts: [hostedPart, providerPart] },
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "Done" }] },
{ id: "user-2", role: "user", parts: [{ type: "text", text: "Follow up" }] },
];
const prepared = prepareAssistantMessagesForTransport(messages);
expect(prepared[0]?.parts).toEqual([hostedPart, providerPart]);
});
});
@@ -0,0 +1,85 @@
import type { UIMessage } from "ai";
import { isRecord } from "@homarr/common";
const inlineAttachmentUrlPattern = /^data:[^,]+;base64,/u;
const isFilePart = (value: unknown): value is Record<string, unknown> & { type: "file" } =>
isRecord(value) && value.type === "file";
const isUIMessage = (value: unknown): value is UIMessage =>
isRecord(value) &&
typeof value.id === "string" &&
(value.role === "user" || value.role === "assistant" || value.role === "system") &&
Array.isArray(value.parts);
const replaceControlCharacters = (value: string) =>
[...value]
.map((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f) ? " " : character;
})
.join("");
const getAttachmentMetadataPart = (part: Record<string, unknown>) => {
const filename =
typeof part.filename === "string" && part.filename.trim().length > 0
? replaceControlCharacters(part.filename).trim().slice(0, 160)
: "attachment";
const mediaType = typeof part.mediaType === "string" ? part.mediaType.slice(0, 100) : "application/octet-stream";
return {
type: "text" as const,
text: `[Historical attachment; binary omitted after its original turn: ${JSON.stringify({ filename, mediaType })}]`,
};
};
/**
* Keep inline bytes only for the most recent user message. The provider receives those bytes on
* the attachment's original turn; replaying every prior data URL on later turns wastes the route's
* bounded request budget and can make two otherwise valid five-image turns fail with HTTP 413.
*/
export const prepareAssistantMessagesForTransport = <TMessage extends UIMessage>(
messages: readonly TMessage[],
): TMessage[] => {
const currentAttachmentMessageIndex = messages.findLastIndex((message) => message.role === "user");
return messages.map((message, messageIndex) => {
let changed = false;
const parts: UIMessage["parts"] = [];
for (const part of message.parts) {
if (!isFilePart(part)) {
parts.push(part);
continue;
}
const url = typeof part.url === "string" ? part.url : "";
if (!inlineAttachmentUrlPattern.test(url) || messageIndex === currentAttachmentMessageIndex) {
parts.push(part);
continue;
}
changed = true;
parts.push(getAttachmentMetadataPart(part));
}
return changed ? ({ ...message, parts } as TMessage) : message;
});
};
/**
* AssistantChatTransport has already initialized the remote thread id when its fetch middleware is
* called. Prune the serialized envelope here so changing the message body cannot accidentally
* replace that remote id with assistant-ui's pre-initialization local id.
*/
export const prepareAssistantRequestBody = (body: BodyInit | null | undefined): BodyInit | null | undefined => {
if (typeof body !== "string") return body;
try {
const parsed = JSON.parse(body) as unknown;
if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body;
if (!parsed.messages.every(isUIMessage)) return body;
return JSON.stringify({ ...parsed, messages: prepareAssistantMessagesForTransport(parsed.messages) });
} catch {
return body;
}
};
@@ -0,0 +1,285 @@
// @vitest-environment jsdom
import { act, createElement, Fragment } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AssistantAutoApprovalProvider,
createAssistantAutoApprovalTracker,
useAssistantAutoApproval,
useAssistantAutomaticAction,
} from "./assistant-auto-approval";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type AutoApproval = ReturnType<typeof useAssistantAutoApproval>;
let currentAutoApproval: AutoApproval | null = null;
let automaticActionInProgress = false;
const containers: HTMLDivElement[] = [];
const AutoApprovalProbe = () => {
currentAutoApproval = useAssistantAutoApproval();
return null;
};
const AutomaticActionProbe = ({
ready,
completed,
confirm,
}: {
ready: boolean;
completed: boolean;
confirm: () => void;
}) => {
automaticActionInProgress = useAssistantAutomaticAction({
toolCallId: "configure-app-1",
ready,
completed,
confirm,
});
return null;
};
afterEach(() => {
currentAutoApproval = null;
automaticActionInProgress = false;
for (const container of containers.splice(0)) container.remove();
});
describe("assistant automatic approval tracker", () => {
test("claims a tool call only once across duplicate conversation renderers", () => {
const tracker = createAssistantAutoApprovalTracker();
expect(tracker.claim("app-create-1")).toBe(true);
expect(tracker.claim("app-create-1")).toBe(false);
expect(tracker.claim("board-save-1")).toBe(true);
});
test("can retry a failed response and clears claims for another conversation", () => {
const tracker = createAssistantAutoApprovalTracker();
expect(tracker.claim("app-create-1")).toBe(true);
tracker.release("app-create-1");
expect(tracker.claim("app-create-1")).toBe(true);
tracker.clear();
expect(tracker.claim("app-create-1")).toBe(true);
});
test("bounds automatic retries and permits a fresh claim after completion", () => {
const tracker = createAssistantAutoApprovalTracker(2);
expect(tracker.claim("custom-widget-create-1")).toBe(true);
tracker.release("custom-widget-create-1");
expect(tracker.claim("custom-widget-create-1")).toBe(true);
tracker.release("custom-widget-create-1");
expect(tracker.claim("custom-widget-create-1")).toBe(false);
tracker.complete("custom-widget-create-1");
expect(tracker.claim("custom-widget-create-1")).toBe(true);
});
test("keeps the setting for the same local conversation and resets it on a real switch", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
const renderConversation = async (conversationId: string) => {
await act(async () => {
root.render(createElement(AssistantAutoApprovalProvider, { conversationId }, createElement(AutoApprovalProbe)));
});
};
await renderConversation("local-thread-1");
act(() => currentAutoApproval?.setEnabled(true));
expect(currentAutoApproval?.enabled).toBe(true);
await renderConversation("local-thread-1");
expect(currentAutoApproval?.enabled).toBe(true);
await renderConversation("local-thread-2");
expect(currentAutoApproval?.enabled).toBe(false);
await act(async () => root.unmount());
});
test("does not approve a ready action while switching conversations", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let confirmationCount = 0;
const renderConversation = async (conversationId: string, ready: boolean) => {
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId },
createElement(
Fragment,
null,
createElement(AutoApprovalProbe),
createElement(AutomaticActionProbe, {
ready,
completed: false,
confirm: () => {
confirmationCount += 1;
},
}),
),
),
);
});
};
await renderConversation("local-thread-1", false);
act(() => currentAutoApproval?.setEnabled(true));
await renderConversation("local-thread-2", true);
expect(confirmationCount).toBe(0);
expect(currentAutoApproval?.enabled).toBe(false);
expect(automaticActionInProgress).toBe(false);
await act(async () => root.unmount());
});
test("clears claimed calls when automatic approvals are turned off", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId: "local-thread-1" },
createElement(AutoApprovalProbe),
),
);
});
act(() => currentAutoApproval?.setEnabled(true));
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true);
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(false);
act(() => currentAutoApproval?.setEnabled(false));
act(() => currentAutoApproval?.setEnabled(true));
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true);
await act(async () => root.unmount());
});
test("returns a failed approval to the caller and releases its claim", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId: "local-thread-1" },
createElement(AutoApprovalProbe),
),
);
});
act(() => currentAutoApproval?.setEnabled(true));
expect(
currentAutoApproval?.requestAction("app-create-1", () => {
throw new Error("Approval transport failed");
}),
).toBe(false);
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true);
await act(async () => root.unmount());
});
test("confirms a ready human-tool action exactly once and clears progress on completion", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let confirmationCount = 0;
const confirm = () => {
confirmationCount += 1;
};
const renderAction = async (ready: boolean, completed: boolean) => {
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId: "local-thread-1" },
createElement(
Fragment,
null,
createElement(AutoApprovalProbe),
createElement(AutomaticActionProbe, { ready, completed, confirm }),
),
),
);
});
};
await renderAction(false, false);
act(() => currentAutoApproval?.setEnabled(true));
await renderAction(true, false);
expect(confirmationCount).toBe(1);
expect(automaticActionInProgress).toBe(true);
await renderAction(true, false);
expect(confirmationCount).toBe(1);
await renderAction(false, true);
expect(automaticActionInProgress).toBe(false);
await act(async () => root.unmount());
});
test("retries an approval response that never settles and restores manual controls after two attempts", async () => {
vi.useFakeTimers();
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let confirmationCount = 0;
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId: "local-thread-1" },
createElement(
Fragment,
null,
createElement(AutoApprovalProbe),
createElement(AutomaticActionProbe, {
ready: true,
completed: false,
confirm: () => {
confirmationCount += 1;
},
}),
),
),
);
});
act(() => currentAutoApproval?.setEnabled(true));
expect(confirmationCount).toBe(1);
expect(automaticActionInProgress).toBe(true);
await act(async () => vi.advanceTimersByTime(5_000));
expect(confirmationCount).toBe(2);
expect(automaticActionInProgress).toBe(true);
await act(async () => vi.advanceTimersByTime(5_000));
expect(confirmationCount).toBe(2);
expect(automaticActionInProgress).toBe(false);
await act(async () => root.unmount());
vi.useRealTimers();
});
});
@@ -0,0 +1,168 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
const AUTO_APPROVAL_RETRY_TIMEOUT_MS = 5_000;
const AUTO_APPROVAL_MAX_ATTEMPTS = 2;
export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSITIVE_INFINITY) => {
const claimedToolCalls = new Set<string>();
const attemptsByToolCall = new Map<string, number>();
return {
claim(toolCallId: string) {
if (claimedToolCalls.has(toolCallId)) return false;
const attempts = attemptsByToolCall.get(toolCallId) ?? 0;
if (attempts >= maximumAttempts) return false;
claimedToolCalls.add(toolCallId);
attemptsByToolCall.set(toolCallId, attempts + 1);
return true;
},
release(toolCallId: string) {
claimedToolCalls.delete(toolCallId);
},
complete(toolCallId: string) {
claimedToolCalls.delete(toolCallId);
attemptsByToolCall.delete(toolCallId);
},
clear() {
claimedToolCalls.clear();
attemptsByToolCall.clear();
},
};
};
interface AssistantAutoApprovalContextValue {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
requestAction: (toolCallId: string, confirm: () => void) => boolean;
completeAction: (toolCallId: string) => void;
retryRevision: number;
}
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
export const AssistantAutoApprovalProvider = ({
children,
conversationId,
}: PropsWithChildren<{ conversationId: string }>) => {
const [enabled, setEnabledState] = useState(false);
const [retryRevision, setRetryRevision] = useState(0);
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, number>());
const previousConversationIdRef = useRef(conversationId);
const conversationMatches = previousConversationIdRef.current === conversationId;
const effectiveEnabled = conversationMatches && enabled;
const clearRetryTimer = useCallback((toolCallId: string) => {
const timer = retryTimersRef.current.get(toolCallId);
if (timer !== undefined) window.clearTimeout(timer);
retryTimersRef.current.delete(toolCallId);
}, []);
const clearAutomaticActions = useCallback(() => {
for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer);
retryTimersRef.current.clear();
trackerRef.current.clear();
}, []);
const setEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions();
setEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
useEffect(() => {
const previousConversationId = previousConversationIdRef.current;
previousConversationIdRef.current = conversationId;
if (previousConversationId === conversationId) return;
clearAutomaticActions();
setEnabledState(false);
}, [clearAutomaticActions, conversationId]);
useEffect(() => clearAutomaticActions, [clearAutomaticActions]);
const requestAction = useCallback(
(toolCallId: string, confirm: () => void) => {
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
try {
confirm();
clearRetryTimer(toolCallId);
retryTimersRef.current.set(
toolCallId,
window.setTimeout(() => {
retryTimersRef.current.delete(toolCallId);
trackerRef.current.release(toolCallId);
setRetryRevision((revision) => revision + 1);
}, AUTO_APPROVAL_RETRY_TIMEOUT_MS),
);
return true;
} catch {
trackerRef.current.release(toolCallId);
return false;
}
},
[clearRetryTimer, effectiveEnabled],
);
const completeAction = useCallback(
(toolCallId: string) => {
clearRetryTimer(toolCallId);
trackerRef.current.complete(toolCallId);
},
[clearRetryTimer],
);
const value = useMemo(() => {
return {
enabled: effectiveEnabled,
setEnabled,
requestAction,
completeAction,
retryRevision,
};
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
export const useAssistantAutoApproval = () => {
const value = useContext(AssistantAutoApprovalContext);
if (value === null) throw new Error("useAssistantAutoApproval must be used within AssistantAutoApprovalProvider");
return value;
};
export const useAssistantAutomaticAction = ({
toolCallId,
ready,
completed,
confirm,
}: {
toolCallId: string;
ready: boolean;
completed: boolean;
confirm: () => void;
}) => {
const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval();
const [inProgress, setInProgress] = useState(false);
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
useEffect(() => {
if (!ready || completed || !enabled) {
if (completed) completeAction(toolCallId);
setInProgress(false);
return;
}
const requested = requestAction(toolCallId, () => confirmRef.current());
setInProgress(requested);
}, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]);
return inProgress;
};
@@ -0,0 +1,169 @@
import { describe, expect, test } from "vitest";
import type { UIMessage } from "ai";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
const messagesWithToolPart = (part: UIMessage["parts"][number]): UIMessage[] => [
{
id: "assistant-message",
role: "assistant",
parts: [{ type: "step-start" }, part],
},
];
describe("shouldAutomaticallyContinueAssistant", () => {
test("continues after a human or frontend tool supplies its result", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: messagesWithToolPart({
type: "dynamic-tool",
toolName: "ask_user",
toolCallId: "ask-1",
input: { question: "Continue?" },
state: "output-available",
output: { answer: "Yes" },
}),
}),
).toBe(true);
});
test("continues after a mutation approval response", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: messagesWithToolPart({
type: "dynamic-tool",
toolName: "app_create",
toolCallId: "create-1",
input: { name: "YouTube" },
state: "approval-responded",
approval: { id: "approval-1", approved: true },
}),
}),
).toBe(true);
});
test("does not continue after an unmarked server-only tool result", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: messagesWithToolPart({
type: "dynamic-tool",
toolName: "customWidget_previewQuery",
toolCallId: "preview-1",
input: { previewSessionId: "preview-1" },
state: "output-available",
output: { data: {} },
}),
}),
).toBe(false);
});
test("waits for a pending server approval alongside a completed browser tool", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: [
{
id: "assistant-message",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "refresh_current_view",
toolCallId: "refresh-1",
input: {},
state: "output-available",
output: { success: true },
},
{
type: "dynamic-tool",
toolName: "customWidget_createFromPreview",
toolCallId: "create-1",
input: { previewSessionId: "preview-1" },
state: "approval-requested",
approval: { id: "approval-1" },
},
],
},
],
}),
).toBe(false);
});
test("continues an approved mutation when the same step also reports a provider-executed server tool", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: [
{
id: "assistant-message",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "openrouter_web_search",
toolCallId: "search-1",
input: { query: "PokéAPI" },
state: "input-available",
providerExecuted: true,
},
{
type: "dynamic-tool",
toolName: "customWidget_createFromPreview",
toolCallId: "create-1",
input: { previewSessionId: "preview-1" },
state: "approval-responded",
approval: { id: "approval-1", approved: true },
},
],
},
],
}),
).toBe(true);
});
test("waits while a human tool has no result", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: messagesWithToolPart({
type: "dynamic-tool",
toolName: "ask_user",
toolCallId: "ask-1",
input: { question: "Continue?" },
state: "input-available",
}),
}),
).toBe(false);
});
test("continues a completed browser-tool step without treating an earlier agent step as user input", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: [
{
id: "assistant-message",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "icon_findIcons",
toolCallId: "icon-1",
input: { searchText: "homarr" },
state: "input-available",
},
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "refresh_current_view",
toolCallId: "refresh-1",
input: {},
state: "output-available",
output: { success: true },
},
],
},
],
}),
).toBe(true);
});
});
@@ -0,0 +1,55 @@
import { getToolName, isToolUIPart, lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import type { UIMessage } from "ai";
import { browserToolContracts } from "./assistant-tool-contracts";
type AutoSubmitOptions = Parameters<typeof lastAssistantMessageIsCompleteWithApprovalResponses>[0];
const browserToolNames = new Set(Object.keys(browserToolContracts));
const lastAssistantMessageHasCompleteBrowserToolCalls = (messages: UIMessage[]) => {
const message = messages.at(-1);
if (message?.role !== "assistant") return false;
const lastStepStartIndex = message.parts.reduce(
(lastIndex, part, index) => (part.type === "step-start" ? index : lastIndex),
-1,
);
const localToolParts = message.parts
.slice(lastStepStartIndex + 1)
.filter(isToolUIPart)
.filter((part) => !part.providerExecuted);
const browserToolParts = localToolParts.filter((part) => browserToolNames.has(getToolName(part)));
return (
browserToolParts.length > 0 &&
localToolParts.every((part) => part.state === "output-available" || part.state === "output-error")
);
};
const lastAssistantMessageHasCompleteLocalApprovalResponses = (messages: UIMessage[]) => {
const message = messages.at(-1);
if (message?.role !== "assistant") return false;
const lastStepStartIndex = message.parts.reduce(
(lastIndex, part, index) => (part.type === "step-start" ? index : lastIndex),
-1,
);
const localToolParts = message.parts
.slice(lastStepStartIndex + 1)
.filter(isToolUIPart)
.filter((part) => !part.providerExecuted);
return (
localToolParts.some((part) => part.state === "approval-responded") &&
localToolParts.every(
(part) =>
part.state === "output-available" || part.state === "output-error" || part.state === "approval-responded",
)
);
};
export const shouldAutomaticallyContinueAssistant = (options: AutoSubmitOptions) =>
lastAssistantMessageIsCompleteWithApprovalResponses(options) ||
lastAssistantMessageHasCompleteLocalApprovalResponses(options.messages) ||
lastAssistantMessageHasCompleteBrowserToolCalls(options.messages);
@@ -0,0 +1,12 @@
import { Group, Loader, Text } from "@mantine/core";
import classes from "./assistant-panel.module.css";
export const AssistantAutomaticActionProgress = ({ label }: { label: string }) => (
<Group component="output" className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" />
<Text size="sm" fw={600}>
{label}
</Text>
</Group>
);
@@ -0,0 +1,434 @@
"use client";
import { useMemo, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import {
Alert,
Badge,
Box,
Button,
ColorInput,
CopyButton,
Group,
InputWrapper,
Select,
SimpleGrid,
Skeleton,
Slider,
Stack,
Switch,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconFileTypeCss,
IconPalette,
IconPhoto,
IconRefresh,
IconSettings,
} from "@tabler/icons-react";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import {
getAssistantBoardSettingsResult,
getAssistantBoardSettingsDefaultTab,
getChangedBoardSettings,
getCustomCssWarnings,
} from "./assistant-board-settings";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import { AssistantHumanToolError } from "./assistant-human-tools";
import classes from "./assistant-panel.module.css";
import type {
AssistantBoardSettingsChanges,
ConfigureBoardSettingsArgs,
ConfigureBoardSettingsResult,
} from "./assistant-tool-contracts";
type BoardSettings = RouterOutputs["board"]["getBoardSettings"];
const completeSettingsSchema = boardSavePartialSettingsSchema.required();
type BoardSettingsFormValues = z.infer<typeof completeSettingsSchema>;
const normalizeFormValues = (
current: BoardSettings,
proposed: AssistantBoardSettingsChanges,
): BoardSettingsFormValues => {
const values = { ...current, ...proposed };
return {
pageTitle: values.pageTitle ?? "",
metaTitle: values.metaTitle ?? "",
logoImageUrl: values.logoImageUrl ?? "",
faviconImageUrl: values.faviconImageUrl ?? "",
backgroundImageUrl: values.backgroundImageUrl ?? "",
backgroundImageAttachment: values.backgroundImageAttachment,
backgroundImageRepeat: values.backgroundImageRepeat,
backgroundImageSize: values.backgroundImageSize,
primaryColor: values.primaryColor,
secondaryColor: values.secondaryColor,
opacity: values.opacity,
customCss: values.customCss,
iconColor: values.iconColor ?? "",
itemRadius: values.itemRadius,
disableStatus: values.disableStatus,
};
};
const toCurrentValues = (current: BoardSettings): BoardSettingsFormValues => normalizeFormValues(current, {});
export const AssistantConfigureBoardSettingsTool = ({
args,
result,
addResult,
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
const t = useI18n("assistant.configureBoardSettings");
const actionT = useI18n("common.action");
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const boardId = args?.boardId ?? "";
const settings = clientApi.board.getBoardSettings.useQuery(
{ id: boardId },
{ enabled: result === undefined && hasCompleteArguments && boardId.length > 0, retry: false },
);
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && settings.data !== undefined && hasCompleteArguments,
completed: result !== undefined,
confirm: () => {
if (!settings.data || !args) return;
const currentValues = toCurrentValues(settings.data);
const proposedValues = normalizeFormValues(settings.data, args.changes);
addResult(getAssistantBoardSettingsResult(settings.data.id, currentValues, proposedValues));
},
});
if (result) {
const cancelled = "cancelled" in result && result.cancelled;
const changedCount = cancelled ? 0 : Math.max(0, Object.keys(result).length - 1);
return (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color="green">
<IconCheck size={13} />
</ThemeIcon>
<Box miw={0}>
<Text size="xs" c="dimmed">
{cancelled ? t("cancelled") : t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{cancelled ? args?.boardName : t("changeCount", { count: changedCount })}
</Text>
</Box>
</Box>
);
}
if (hasFailedAssistantToolArguments(status)) return <AssistantHumanToolError />;
if (!hasCompleteArguments) {
return <BoardSettingsSkeleton label={t("loading")} />;
}
if (!args?.boardId || !args.boardName) return <AssistantHumanToolError />;
if (settings.isError) {
return (
<Alert color="red" variant="light" title={t("loadErrorTitle")} icon={<IconAlertTriangle size={18} />}>
<Stack gap="sm">
<Text size="sm">{t("loadErrorDescription")}</Text>
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={() => settings.refetch()}>
{actionT("tryAgain")}
</Button>
</Stack>
</Alert>
);
}
if (!settings.data) return <BoardSettingsSkeleton label={t("loading")} />;
if (autoConfirming) {
return (
<Box className={classes.boardSettingsTool}>
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
</Box>
);
}
return (
<BoardSettingsForm
current={settings.data}
args={args}
onSubmit={(changes) => addResult({ id: settings.data.id, ...changes })}
onCancel={() => addResult({ id: settings.data.id, cancelled: true })}
/>
);
};
const BoardSettingsSkeleton = ({ label }: { label: string }) => (
<Box className={classes.boardSettingsTool} aria-label={label}>
<Stack gap="sm">
<Skeleton height={20} width="45%" />
<Skeleton height={14} width="78%" />
<Skeleton height={38} />
<Skeleton height={180} />
</Stack>
</Box>
);
const BoardSettingsForm = ({
current,
args,
onSubmit,
onCancel,
}: {
current: BoardSettings;
args: ConfigureBoardSettingsArgs;
onSubmit: (changes: AssistantBoardSettingsChanges) => void;
onCancel: () => void;
}) => {
const t = useI18n("assistant.configureBoardSettings");
const actionT = useI18n("common.action");
const boardT = useI18n();
const [submitting, setSubmitting] = useState(false);
const currentValues = useMemo(() => toCurrentValues(current), [current]);
const form = useZodForm(completeSettingsSchema, {
initialValues: normalizeFormValues(current, args.changes),
});
const changes = getChangedBoardSettings(currentValues, form.values);
const changedCount = Object.keys(changes).length;
const cssWarnings = getCustomCssWarnings(form.values.customCss);
const proposedKeys = Object.keys(args.changes);
const defaultTab = getAssistantBoardSettingsDefaultTab(args.changes);
const optionData = <TValue extends string>(
values: readonly TValue[],
key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize",
) =>
values.map((value) => ({
value,
label: boardT(`board.field.${key}.option.${value}.label` as never),
}));
return (
<Box className={classes.boardSettingsTool}>
<Group className={classes.boardSettingsHeader} align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">
<IconSettings size={18} />
</ThemeIcon>
<Box flex={1} miw={0}>
<Group gap="xs" wrap="wrap">
<Text fw={700}>{t("title")}</Text>
<Badge variant="light" color="gray" size="sm">
{args.boardName}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={3}>
{args.summary}
</Text>
{proposedKeys.length > 0 && (
<Text size="xs" c="dimmed" mt="xs">
{t("proposedCount", { count: proposedKeys.length })}
</Text>
)}
</Box>
</Group>
<form
onSubmit={form.onSubmit((values) => {
if (submitting) return;
const nextChanges = getChangedBoardSettings(currentValues, values);
setSubmitting(true);
if (Object.keys(nextChanges).length === 0) {
onCancel();
return;
}
onSubmit(nextChanges);
})}
>
<Tabs defaultValue={defaultTab} mt="md" className={classes.boardSettingsTabs}>
<Tabs.List grow>
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
{t("tabs.css")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
{t("tabs.appearance")}
</Tabs.Tab>
<Tabs.Tab value="background" leftSection={<IconPhoto size={15} />}>
{t("tabs.background")}
</Tabs.Tab>
<Tabs.Tab value="general" leftSection={<IconSettings size={15} />}>
{t("tabs.general")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="css" pt="md">
<Stack gap="sm">
<Textarea
label={boardT("board.field.customCss.label")}
description={t("cssDescription")}
autosize
minRows={10}
maxRows={20}
classNames={{ input: classes.boardCssEditor }}
{...form.getInputProps("customCss")}
/>
<Group justify="space-between" gap="xs" wrap="wrap">
<Text size="xs" c={form.values.customCss.length > 15_500 ? "orange" : "dimmed"}>
{t("cssCharacters", { count: form.values.customCss.length })}
</Text>
<Group gap="xs">
<CopyButton value={form.values.customCss}>
{({ copied, copy }) => (
<Tooltip label={copied ? t("copied") : t("copyCss")}>
<Button
variant="subtle"
size="compact-sm"
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? t("copied") : actionT("copy")}
</Button>
</Tooltip>
)}
</CopyButton>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconRefresh size={14} />}
disabled={form.values.customCss === currentValues.customCss}
onClick={() => form.setFieldValue("customCss", currentValues.customCss)}
>
{t("restoreCss")}
</Button>
</Group>
</Group>
{(cssWarnings.importsStylesheet || cssWarnings.loadsRemoteResource) && (
<Alert
variant="light"
color="yellow"
icon={<IconAlertTriangle size={18} />}
title={t("remoteCssTitle")}
>
{t("remoteCssDescription")}
</Alert>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="appearance" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<ColorInput
label={boardT("board.field.primaryColor.label")}
format="hex"
{...form.getInputProps("primaryColor")}
/>
<ColorInput
label={boardT("board.field.secondaryColor.label")}
format="hex"
{...form.getInputProps("secondaryColor")}
/>
<ColorInput
label={boardT("board.field.iconColor.label")}
format="hex"
{...form.getInputProps("iconColor")}
/>
<Select
label={boardT("board.field.itemRadius.label")}
data={(["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
value,
label: boardT(`board.field.itemRadius.option.${value}`),
}))}
{...form.getInputProps("itemRadius")}
/>
</SimpleGrid>
<InputWrapper label={boardT("board.field.opacity.label")}>
<Slider min={0} max={100} step={5} label={(value) => `${value}%`} {...form.getInputProps("opacity")} />
</InputWrapper>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="background" pt="md">
<Stack gap="md">
<TextInput
label={boardT("board.field.backgroundImageUrl.label")}
placeholder={boardT("board.field.backgroundImageUrl.placeholder")}
{...form.getInputProps("backgroundImageUrl")}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Select
label={boardT("board.field.backgroundImageSize.label")}
data={optionData(backgroundImageSizes.values, "backgroundImageSize")}
{...form.getInputProps("backgroundImageSize")}
/>
<Select
label={boardT("board.field.backgroundImageRepeat.label")}
data={optionData(backgroundImageRepeats.values, "backgroundImageRepeat")}
{...form.getInputProps("backgroundImageRepeat")}
/>
<Select
label={boardT("board.field.backgroundImageAttachment.label")}
data={optionData(backgroundImageAttachments.values, "backgroundImageAttachment")}
{...form.getInputProps("backgroundImageAttachment")}
/>
</SimpleGrid>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="general" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput label={boardT("board.field.pageTitle.label")} {...form.getInputProps("pageTitle")} />
<TextInput label={boardT("board.field.metaTitle.label")} {...form.getInputProps("metaTitle")} />
<TextInput label={boardT("board.field.logoImageUrl.label")} {...form.getInputProps("logoImageUrl")} />
<TextInput
label={boardT("board.field.faviconImageUrl.label")}
{...form.getInputProps("faviconImageUrl")}
/>
</SimpleGrid>
<Switch
label={boardT("board.field.disableStatus.label")}
description={boardT("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</Stack>
</Tabs.Panel>
</Tabs>
<Group className={classes.boardSettingsActions} justify="space-between" gap="sm" mt="md" wrap="wrap">
<Button
variant="subtle"
color="gray"
leftSection={<IconRefresh size={15} />}
disabled={changedCount === 0 || submitting}
onClick={() => form.setValues(currentValues)}
>
{t("resetAll")}
</Button>
<Group gap="sm" wrap="nowrap" className={classes.boardSettingsSubmitGroup}>
<Text size="xs" c="dimmed">
{changedCount === 0 ? t("noChanges") : t("changeCount", { count: changedCount })}
</Text>
<Button type="submit" loading={submitting}>
{changedCount === 0 ? t("keepCurrent") : t("continue")}
</Button>
</Group>
</Group>
</form>
</Box>
);
};
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import {
getAssistantBoardSettingsResult,
getAssistantBoardSettingsDefaultTab,
getChangedBoardSettings,
getCustomCssWarnings,
} from "./assistant-board-settings";
describe("getChangedBoardSettings", () => {
test("returns only changed settings and treats empty nullable values equally", () => {
expect(
getChangedBoardSettings(
{ pageTitle: null, primaryColor: "#112233", opacity: 85 },
{ pageTitle: "", primaryColor: "#445566", opacity: 85 },
),
).toEqual({ primaryColor: "#445566" });
});
test("keeps an intentional custom CSS removal", () => {
expect(getChangedBoardSettings({ customCss: ".item { color: red; }" }, { customCss: "" })).toEqual({
customCss: "",
});
});
test("builds the exact result used by automatic board approval", () => {
expect(
getAssistantBoardSettingsResult(
"board-1",
{ pageTitle: "Home", primaryColor: "#112233" },
{ pageTitle: "Home", primaryColor: "#445566" },
),
).toEqual({ id: "board-1", primaryColor: "#445566" });
expect(getAssistantBoardSettingsResult("board-1", { pageTitle: "Home" }, { pageTitle: "Home" })).toEqual({
id: "board-1",
cancelled: true,
});
});
});
describe("getCustomCssWarnings", () => {
test("detects imported and remote CSS resources", () => {
expect(getCustomCssWarnings('@import "theme.css"; .item { background: url(https://example.com/a.png); }')).toEqual({
importsStylesheet: true,
loadsRemoteResource: true,
});
});
});
describe("getAssistantBoardSettingsDefaultTab", () => {
test("opens the tab containing the proposed settings", () => {
expect(getAssistantBoardSettingsDefaultTab({ pageTitle: "Home" })).toBe("general");
expect(getAssistantBoardSettingsDefaultTab({ primaryColor: "#112233" })).toBe("appearance");
expect(getAssistantBoardSettingsDefaultTab({ backgroundImageSize: "cover" })).toBe("background");
expect(getAssistantBoardSettingsDefaultTab({ customCss: ".item { opacity: 0.9; }" })).toBe("css");
});
});
@@ -0,0 +1,66 @@
import type { AssistantBoardSettingsChanges, ConfigureBoardSettingsResult } from "./assistant-tool-contracts";
export const assistantBoardSettingKeys = [
"pageTitle",
"metaTitle",
"logoImageUrl",
"faviconImageUrl",
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
"primaryColor",
"secondaryColor",
"opacity",
"customCss",
"iconColor",
"itemRadius",
"disableStatus",
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
const comparableValue = (value: unknown) => (value === null ? "" : value);
export const getChangedBoardSettings = (current: AssistantBoardSettingsChanges, next: AssistantBoardSettingsChanges) =>
Object.fromEntries(
assistantBoardSettingKeys.flatMap((key) =>
comparableValue(current[key]) === comparableValue(next[key]) ? [] : [[key, next[key]]],
),
) as AssistantBoardSettingsChanges;
export const getAssistantBoardSettingsResult = (
id: string,
current: AssistantBoardSettingsChanges,
next: AssistantBoardSettingsChanges,
): ConfigureBoardSettingsResult => {
const changes = getChangedBoardSettings(current, next);
return Object.keys(changes).length === 0 ? { id, cancelled: true } : { id, ...changes };
};
export const getCustomCssWarnings = (css: string) => ({
importsStylesheet: /@import\b/iu.test(css),
loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css),
});
const appearanceKeys = new Set<keyof AssistantBoardSettingsChanges>([
"primaryColor",
"secondaryColor",
"opacity",
"iconColor",
"itemRadius",
]);
const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
]);
export const getAssistantBoardSettingsDefaultTab = (
changes: AssistantBoardSettingsChanges,
): "general" | "appearance" | "background" | "css" => {
if (changes.customCss !== undefined) return "css";
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
if (keys.some((key) => backgroundKeys.has(key))) return "background";
return "general";
};
@@ -0,0 +1,74 @@
import { describe, expect, test, vi } from "vitest";
import { createAssistantBrowserToolExecutors, resolveAssistantInternalRoute } from "./assistant-browser-tool-executors";
describe("assistant browser tool executors", () => {
test("normalizes same-origin routes and rejects external or protocol-relative navigation", () => {
expect(resolveAssistantInternalRoute("/manage/apps?view=grid#new", "https://homarr.example")).toBe(
"/manage/apps?view=grid#new",
);
expect(resolveAssistantInternalRoute("https://example.com", "https://homarr.example")).toBeNull();
expect(resolveAssistantInternalRoute("//example.com/apps", "https://homarr.example")).toBeNull();
expect(resolveAssistantInternalRoute("/\\example.com/apps", "https://homarr.example")).toBeNull();
});
test("executes every supported frontend action through its injected Homarr dependency", async () => {
const navigate = vi.fn();
const openCommandMenu = vi.fn();
const openMediaRequestSearch = vi.fn();
const refreshCurrentView = vi.fn().mockResolvedValue(undefined);
const executors = createAssistantBrowserToolExecutors({
getOrigin: () => "https://homarr.example",
navigate,
openCommandMenu,
openMediaRequestSearch,
refreshCurrentView,
});
await expect(executors.navigate_to_route({ path: "/manage/apps" })).resolves.toEqual({
success: true,
path: "/manage/apps",
});
await expect(executors.open_command_menu()).resolves.toEqual({ success: true });
await expect(executors.open_media_request_search()).resolves.toEqual({ success: true });
await expect(executors.refresh_current_view()).resolves.toEqual({ success: true });
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/manage/apps");
expect(openCommandMenu).toHaveBeenCalledOnce();
expect(openMediaRequestSearch).toHaveBeenCalledOnce();
expect(refreshCurrentView).toHaveBeenCalledOnce();
});
test("does not navigate when the assistant requests an unsafe route", async () => {
const navigate = vi.fn();
const executors = createAssistantBrowserToolExecutors({
getOrigin: () => "https://homarr.example",
navigate,
openCommandMenu: vi.fn(),
openMediaRequestSearch: vi.fn(),
refreshCurrentView: vi.fn().mockResolvedValue(undefined),
});
await expect(executors.navigate_to_route({ path: "https://example.com" })).resolves.toEqual({
success: false,
error: "Only internal Homarr paths are allowed.",
});
expect(navigate).not.toHaveBeenCalled();
});
test("reports a failed refresh without crashing the assistant run", async () => {
const executors = createAssistantBrowserToolExecutors({
getOrigin: () => "https://homarr.example",
navigate: vi.fn(),
openCommandMenu: vi.fn(),
openMediaRequestSearch: vi.fn(),
refreshCurrentView: vi.fn().mockRejectedValue(new Error("offline")),
});
await expect(executors.refresh_current_view()).resolves.toEqual({
success: false,
error: "The current Homarr view could not be refreshed.",
});
});
});
@@ -0,0 +1,52 @@
interface AssistantBrowserToolDependencies {
getOrigin: () => string;
navigate: (path: string) => void;
openCommandMenu: () => void;
openMediaRequestSearch: () => void;
refreshCurrentView: () => Promise<void>;
}
export const resolveAssistantInternalRoute = (path: string, origin: string) => {
if (!path.startsWith("/") || path.startsWith("/\\")) return null;
const target = URL.canParse(path, origin) ? new URL(path, origin) : null;
if (target === null || target.origin !== origin) return null;
return `${target.pathname}${target.search}${target.hash}`;
};
export const createAssistantBrowserToolExecutors = ({
getOrigin,
navigate,
openCommandMenu,
openMediaRequestSearch,
refreshCurrentView,
}: AssistantBrowserToolDependencies) => ({
navigate_to_route: async ({ path }: { path: string }) => {
const internalPath = resolveAssistantInternalRoute(path, getOrigin());
if (internalPath === null) {
return { success: false as const, error: "Only internal Homarr paths are allowed." };
}
navigate(internalPath);
return { success: true as const, path: internalPath };
},
open_command_menu: async () => {
openCommandMenu();
return { success: true as const };
},
open_media_request_search: async () => {
openMediaRequestSearch();
return { success: true as const };
},
refresh_current_view: async () => {
try {
await refreshCurrentView();
return { success: true as const };
} catch {
return { success: false as const, error: "The current Homarr view could not be refreshed." };
}
},
});
export type AssistantBrowserToolExecutors = ReturnType<typeof createAssistantBrowserToolExecutors>;
@@ -0,0 +1,198 @@
"use client";
import type { RefObject } from "react";
import { useContext, useLayoutEffect, useMemo, useRef } from "react";
import type { Unstable_TriggerItem } from "@assistant-ui/react";
import {
ComposerPrimitive,
useAui,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
unstable_useTriggerPopoverScopeContext,
} from "@assistant-ui/react";
import { Text, ThemeIcon } from "@mantine/core";
import { IconAt, IconChevronRight } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { AssistantDirectiveEntitiesContext, contextIcons } from "./assistant-message-content";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject<HTMLDivElement | null> }) => {
const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } =
unstable_useTriggerPopoverScopeContext();
useLayoutEffect(() => {
if (!open) return;
const viewport = viewportRef.current;
const highlightedItem = viewport?.querySelector<HTMLElement>("[data-highlighted]");
if (!viewport || !highlightedItem) return;
const viewportRect = viewport.getBoundingClientRect();
const itemRect = highlightedItem.getBoundingClientRect();
const viewportTop = viewportRect.top + viewport.clientTop;
const viewportBottom = viewportTop + viewport.clientHeight;
viewport.scrollTop = getNearestTriggerScrollTop({
scrollTop: viewport.scrollTop,
viewportTop,
viewportBottom,
itemTop: itemRect.top,
itemBottom: itemRect.bottom,
});
}, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]);
return null;
};
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>
);
};
export const ComposerTriggers = () => {
const t = useI18n("assistant");
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(null);
const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext);
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")),
},
{
id: "style",
label: "/style",
description: t("commands.style"),
execute: () => aui.composer().setText(t("suggestions.style.prompt")),
},
{
id: "design-widget",
label: "/design-widget",
description: t("commands.designWidget"),
execute: () => aui.composer().setText(t("suggestions.designWidget.prompt")),
},
],
});
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
ref={mentionViewportRef}
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<TriggerPopoverAutoScroll viewportRef={mentionViewportRef} />
<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
ref={slashViewportRef}
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<TriggerPopoverAutoScroll viewportRef={slashViewportRef} />
<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>
</>
);
};
@@ -0,0 +1,247 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { ComposerPrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
import { ActionIcon, Box, Button, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import {
IconArrowUp,
IconMessage,
IconPaperclip,
IconPlayerStop,
IconPlus,
IconQuote,
IconX,
} from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { ComposerTriggers } from "./assistant-composer-triggers";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
import { ConversationContext } from "./assistant-conversation-context";
import { useAssistantPreferences } from "./assistant-context";
import { AutoApprovalControl } from "./assistant-panel-controls";
import { Attachment, ContextDirectiveChip } from "./assistant-message-content";
import type { AssistantPendingAction } from "./assistant-pending-action";
import { isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { HomarrProviderQuota, RuntimeControls } from "./assistant-runtime-controls";
type ComposerProps = AssistantConversationControls & {
pendingAction: AssistantPendingAction | undefined;
compact?: boolean;
};
export const Composer = (props: ComposerProps) => {
const t = useI18n("assistant");
const preferences = useAssistantPreferences();
const running = useAuiState((state) => state.thread.isRunning);
const hasPendingAction = props.pendingAction !== undefined;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const sendBlocked = hasPendingAction || providerUnavailable;
let sendLabel = t("send");
if (providerUnavailable) sendLabel = t("providerQuota.unavailableDescription");
if (hasPendingAction) sendLabel = t("pendingAction.sendBlocked");
const composerInputRef = useRef<HTMLDivElement>(null);
const composerLabel = t("composerPlaceholder");
const actionSize = props.compact ? "sm" : "lg";
const sendSize = props.compact ? "md" : "lg";
const attachmentAction = (
<Tooltip label={t("attachments.add")}>
<ComposerPrimitive.AddAttachment multiple asChild>
<ActionIcon
variant="subtle"
color="gray"
size={actionSize}
radius={props.compact ? "xl" : undefined}
aria-label={t("attachments.add")}
>
{props.compact ? <IconPlus size="1em" /> : <IconPaperclip size="1em" />}
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
);
const sendAction = running ? (
<ComposerPrimitive.Cancel asChild>
<ActionIcon
color="red"
variant="light"
size={sendSize}
radius={props.compact ? "xl" : undefined}
aria-label={t("stop")}
>
<IconPlayerStop size="1em" />
</ActionIcon>
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon
variant="filled"
size={sendSize}
radius={props.compact ? "xl" : undefined}
aria-label={sendLabel}
disabled={sendBlocked}
>
<IconArrowUp size="1em" />
</ActionIcon>
</ComposerPrimitive.Send>
);
useLayoutEffect(() => {
const editor = composerInputRef.current?.querySelector<HTMLElement>(".aui-lexical-input");
if (!editor) return;
editor.setAttribute("aria-label", composerLabel);
return () => {
if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label");
};
}, [composerLabel]);
return (
<Box className={classes.composerWrap}>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
<ComposerPrimitive.Root
className={classes.composer}
data-pending-action={hasPendingAction || undefined}
onSubmit={(event) => {
if (sendBlocked) event.preventDefault();
}}
>
<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>
<Box className={classes.composerAttachments}>
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
</Box>
<Box className={classes.composerMain}>
{props.compact ? (
<LexicalComposerInput
ref={composerInputRef}
className={`${classes.composerInput} ${classes.composerWidgetInput}`}
data-assistant-composer-input
placeholder={composerLabel}
directiveChip={ContextDirectiveChip}
// oxlint-disable-next-line jsx-a11y/no-autofocus -- opening the assistant is an explicit intent to compose
autoFocus={props.autoFocusComposer}
data-autofocus={props.autoFocusComposer ? true : undefined}
/>
) : (
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="center">
<Group gap={2} wrap="nowrap">
{attachmentAction}
</Group>
<LexicalComposerInput
ref={composerInputRef}
className={classes.composerInput}
data-assistant-composer-input
placeholder={composerLabel}
directiveChip={ContextDirectiveChip}
// oxlint-disable-next-line jsx-a11y/no-autofocus -- opening the assistant is an explicit intent to compose
autoFocus={props.autoFocusComposer}
data-autofocus={props.autoFocusComposer ? true : undefined}
/>
{sendAction}
</Group>
)}
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
{props.compact ? (
<>
<Group className={classes.composerLeading} gap="xs" wrap="nowrap">
{attachmentAction}
<AutoApprovalControl compact />
</Group>
<Group className={classes.composerTrailing} gap={0} wrap="nowrap">
<ConversationContext compact />
<RuntimeControls {...props} compact />
{sendAction}
</Group>
</>
) : (
<Group className={classes.composerControls} gap={5} wrap="nowrap">
<RuntimeControls {...props} />
<HomarrProviderQuota />
<ConversationContext />
</Group>
)}
</Group>
</Box>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
</Box>
);
};
export const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
const t = useI18n("assistant");
if (!action) return undefined;
if (action.kind === "question") {
return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") };
}
if (action.kind === "form") {
const isBoardSettings = action.toolName === "configure_board_settings";
return {
title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"),
detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")),
};
}
const tool = action.toolName.replaceAll("_", " ");
return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool };
};
export const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => {
const t = useI18n("assistant");
const copy = usePendingActionCopy(pendingAction);
if (!copy || pendingAction?.kind === "question") return null;
return (
<Box component="output" className={classes.pendingActionBanner}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="yellow" radius="xl">
<IconMessage size={17} />
</ThemeIcon>
<Stack gap={0} flex={1} miw={0}>
<Text size="sm" fw={700}>
{copy.title}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{copy.detail}
</Text>
</Stack>
<ThreadPrimitive.ScrollToBottom asChild>
<Button variant="light" color="yellow" size="compact-sm">
{t("pendingAction.review")}
</Button>
</ThreadPrimitive.ScrollToBottom>
</Group>
</Box>
);
};
export const PendingQuestionDock = ({
pendingAction,
setTarget,
}: {
pendingAction: AssistantPendingAction | undefined;
setTarget: (target: HTMLDivElement | null) => void;
}) => {
const t = useI18n("assistant");
if (pendingAction?.kind !== "question") return null;
return (
<Box component="section" className={classes.pendingQuestionDock} aria-label={t("pendingAction.answerTitle")}>
<div ref={setTarget} className={classes.pendingQuestionTarget} />
</Box>
);
};
@@ -0,0 +1,71 @@
"use client";
import { createContext, useContext } from "react";
import type { AssistantProvider } from "@homarr/definitions";
import type { WorkshopAssistantUsage, WorkshopUser } from "@homarr/workshop/schema";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
/**
* Context and hooks live in their own module so that consumers such as the user menu can read the
* assistant state without pulling in `assistant-provider`, which statically imports the
* assistant-ui runtime, the Lexical composer, the Markdown renderer and Mermaid. Importing those
* from a component that renders on every page would ship them to every page.
*/
export interface AssistantContextValue {
enabled: boolean;
unavailableDescription: string | null;
opened: boolean;
isRunning: boolean;
isRefreshing: boolean;
unreadCount: number;
hasVisibleWidget: boolean;
activeWidgetId: string | null;
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>;
setWidgetVisible: (widgetId: string, visible: boolean) => void;
activateWidget: (widgetId: string) => void;
}
export const AssistantContext = createContext<AssistantContextValue | null>(null);
export interface AssistantPreferencesContextValue {
provider: AssistantProvider | null;
defaultModelId: string | null;
modelId: string | null;
models: AssistantRuntimeModelOption[];
reasoning: AssistantReasoningMode;
isLoading: boolean;
providerUser: WorkshopUser | null;
quota: WorkshopAssistantUsage | null;
quotaLoading: boolean;
quotaError: string | null;
setModelId: (modelId: string) => void;
setReasoning: (reasoning: AssistantReasoningMode) => void;
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
getRequestHeaders: () => Record<string, string>;
refreshQuota: () => Promise<void>;
signInToProvider: () => Promise<void>;
}
export const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
export const useHomarrAssistant = () => {
const value = useContext(AssistantContext);
if (!value) {
throw new Error("useHomarrAssistant must be used within AssistantProvider");
}
return value;
};
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
export const useAssistantPreferences = () => {
const value = useContext(AssistantPreferencesContext);
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
return value;
};
@@ -0,0 +1,664 @@
"use client";
import { useId, useMemo, useState } from "react";
import { useAuiState } from "@assistant-ui/react";
import {
Badge,
Box,
Collapse,
Divider,
Group,
HoverCard,
Loader,
Popover,
Progress,
RingProgress,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
useComputedColorScheme,
} from "@mantine/core";
import { IconChevronDown, IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { assistantProviderIds, assistantProviderPresets } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import {
getAssistantContextBreakdown,
getAssistantConversationUsage,
getAssistantTelemetry,
} from "./assistant-message-metadata";
import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata";
const formatDuration = (milliseconds: number) =>
milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
const getCostPrecision = (cost: number) => {
if (cost < 0.001) return 5;
if (cost < 0.01) return 4;
return 3;
};
const formatCost = (cost: number) => {
if (cost === 0) return "$0";
if (cost < 0.00001) return `<$0.00001`;
return `$${cost.toFixed(getCostPrecision(cost))}`;
};
const formatStepThroughput = (providerThroughput: number | undefined, fallbackThroughput: number | undefined) => {
const throughput = providerThroughput ?? fallbackThroughput;
if (throughput === undefined) return "";
return ` · ${throughput.toFixed(1)} tok/s`;
};
const getContextColor = (percentage: number) => {
if (percentage >= 90) return "red";
if (percentage >= 75) return "orange";
return "blue";
};
const getAssistantProviderPreset = (provider: string) => {
if (!assistantProviderIds.includes(provider as AssistantProvider)) return null;
return assistantProviderPresets[provider as AssistantProvider];
};
const ProviderIcon = ({ provider, size = 16 }: { provider: string; size?: number }) => {
const colorScheme = useComputedColorScheme("light");
const preset = getAssistantProviderPreset(provider);
const source = colorScheme === "dark" && preset && "darkIconUrl" in preset ? preset.darkIconUrl : preset?.iconUrl;
return source ? (
<Box component="img" className={classes.providerIcon} src={source} alt="" aria-hidden w={size} h={size} />
) : (
<IconRobot size={size} aria-hidden />
);
};
export const ProviderMessageInfo = () => {
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
if (!telemetry) return null;
const details = [
telemetry.modelId,
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
telemetry.outputTokensPerSecond !== undefined ? `${telemetry.outputTokensPerSecond.toFixed(1)} tok/s` : undefined,
telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined,
].filter(Boolean);
return (
<Tooltip
label={
<Stack gap={2}>
<Text size="xs" fw={700} tt="capitalize">
{telemetry.provider}
</Text>
<Text size="xs">{details.join(" · ")}</Text>
</Stack>
}
multiline
maw="min(24rem, calc(100vw - 2rem))"
>
<Box
component="span"
className={classes.providerAction}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the logo and its focused tooltip form one labelled image
role="img"
tabIndex={0}
aria-label={`${telemetry.provider}: ${telemetry.modelId}`}
>
<ProviderIcon provider={telemetry.provider} />
</Box>
</Tooltip>
);
};
const ConversationTurn = ({
index,
messageId,
threadId,
turn,
}: {
index: number;
messageId?: string;
threadId?: string;
turn: AssistantConversationTurnUsage;
}) => {
const t = useI18n("assistant");
const detailsId = useId();
const [opened, setOpened] = useState(false);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
{ threadId: threadId ?? "", messageId: messageId ?? "" },
{
enabled: opened && turn.provider === "openrouter" && Boolean(threadId) && Boolean(messageId),
refetchInterval: (query) =>
query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false,
retry: 2,
staleTime: (query) => (query.state.data?.complete ? 5 * 60_000 : 0),
},
);
const generationById = new Map(
generationQuery.data?.generations.flatMap((generation) =>
generation.generationId ? [[generation.generationId, generation] as const] : [],
) ?? [],
);
const steps = turn.telemetry.steps.map((step) => ({
...step,
...(step.generationId ? generationById.get(step.generationId) : undefined),
}));
const sumCompleteMetric = (getValue: (step: (typeof steps)[number]) => number | undefined) => {
if (steps.length === 0) return undefined;
const values = steps.map(getValue);
return values.some((value) => value === undefined)
? undefined
: (values as number[]).reduce((sum, value) => sum + value, 0);
};
const providerInputTokens = sumCompleteMetric((step) => step.inputTokens);
const providerOutputTokens = sumCompleteMetric((step) => step.outputTokens);
const providerGenerationTimeMs = sumCompleteMetric((step) => step.generationTimeMs);
const providerOutputTokensPerSecond =
providerOutputTokens !== undefined && providerGenerationTimeMs !== undefined && providerGenerationTimeMs > 0
? providerOutputTokens / (providerGenerationTimeMs / 1000)
: undefined;
const latestStep = steps.at(-1);
const providerContextUsed =
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
? latestStep.inputTokens + latestStep.outputTokens
: undefined;
const usage = {
...turn.usage,
...(generationQuery.data?.complete && providerInputTokens !== undefined
? { inputTokens: providerInputTokens }
: {}),
...(generationQuery.data?.complete && providerOutputTokens !== undefined
? { outputTokens: providerOutputTokens }
: {}),
...(generationQuery.data?.complete && providerInputTokens !== undefined && providerOutputTokens !== undefined
? { totalTokens: providerInputTokens + providerOutputTokens }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cachedInputTokens) !== undefined
? { cachedInputTokens: sumCompleteMetric((step) => step.cachedInputTokens) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.reasoningTokens) !== undefined
? { reasoningTokens: sumCompleteMetric((step) => step.reasoningTokens) }
: {}),
};
const telemetry = {
...turn.telemetry,
steps,
...(generationQuery.data?.complete && providerGenerationTimeMs !== undefined
? { generationTimeMs: providerGenerationTimeMs }
: {}),
...(generationQuery.data?.complete && providerOutputTokensPerSecond !== undefined
? { providerOutputTokensPerSecond }
: {}),
...(generationQuery.data?.complete && providerContextUsed !== undefined
? {
contextUsed: providerContextUsed,
...(turn.telemetry.contextLength
? { contextUtilization: Math.min(providerContextUsed / turn.telemetry.contextLength, 1) }
: {}),
}
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cost) !== undefined
? { cost: sumCompleteMetric((step) => step.cost), costType: "reported" as const }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.upstreamCost) !== undefined
? { upstreamCost: sumCompleteMetric((step) => step.upstreamCost) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cacheDiscount) !== undefined
? { cacheDiscount: sumCompleteMetric((step) => step.cacheDiscount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackCount) !== undefined
? { fallbackCount: sumCompleteMetric((step) => step.fallbackCount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: sumCompleteMetric((step) => step.fallbackLatencyMs) }
: {}),
};
const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond;
let reportedCost: string | undefined;
if (telemetry.cost !== undefined) {
reportedCost = formatCost(telemetry.cost);
if (telemetry.costType) reportedCost += ` · ${t(`usage.${telemetry.costType}`)}`;
}
return (
<Box className={classes.conversationTurn} data-opened={opened || undefined}>
<UnstyledButton
className={classes.conversationTurnTrigger}
aria-expanded={opened}
aria-controls={detailsId}
onClick={() => setOpened((value) => !value)}
>
<Group justify="space-between" align="flex-start" gap="xs" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Box className={classes.conversationTurnProvider}>
<ProviderIcon provider={turn.provider} size={15} />
</Box>
<Box miw={0}>
<Text size="xs" fw={650} truncate>
{t("usage.step", { number: index + 1 })} · {turn.modelId}
</Text>
<Text size="xs" c="dimmed">
{[
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined,
usage.totalTokens !== undefined
? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}`
: undefined,
telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined,
]
.filter(Boolean)
.join(" · ") || t("usage.notReported")}
</Text>
</Box>
</Group>
<IconChevronDown size={14} className={classes.disclosureIcon} data-opened={opened || undefined} />
</Group>
</UnstyledButton>
<Collapse id={detailsId} expanded={opened}>
<Stack gap="sm" pt="sm">
{generationQuery.isFetching && generationQuery.data?.complete !== true && (
<Group gap="xs">
<Loader size="xs" type="bars" />
<Text size="xs" c="dimmed">
{t("usage.loadingProviderDetails")}
</Text>
</Group>
)}
{!generationQuery.isFetching && generationQuery.data?.complete === false && (
<Text size="xs" c="dimmed">
{t("usage.providerDetailsPending")}
</Text>
)}
<Box className={classes.usageGrid}>
{[
[t("usage.input"), usage.inputTokens?.toLocaleString()],
[t("usage.output"), usage.outputTokens?.toLocaleString()],
[t("usage.cached"), usage.cachedInputTokens?.toLocaleString()],
[t("usage.reasoning"), usage.reasoningTokens?.toLocaleString()],
[t("usage.cacheWrite"), usage.cacheWriteTokens?.toLocaleString()],
[t("usage.tokens"), usage.totalTokens?.toLocaleString()],
[
t("usage.firstOutput"),
telemetry.timeToFirstOutputMs !== undefined ? formatDuration(telemetry.timeToFirstOutputMs) : undefined,
],
[
t("usage.endToEnd"),
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
],
[
t("usage.generationTime"),
telemetry.generationTimeMs !== undefined ? formatDuration(telemetry.generationTimeMs) : undefined,
],
[
t("usage.providerThroughput"),
displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined,
],
[t("usage.cost"), reportedCost],
[
t("usage.upstreamCost"),
telemetry.upstreamCost !== undefined ? formatCost(telemetry.upstreamCost) : undefined,
],
[t("usage.fallbacks"), telemetry.fallbackCount?.toLocaleString()],
[
t("usage.fallbackLatency"),
telemetry.fallbackLatencyMs !== undefined ? formatDuration(telemetry.fallbackLatencyMs) : undefined,
],
[
t("usage.cacheDiscount"),
telemetry.cacheDiscount !== undefined ? formatCost(telemetry.cacheDiscount) : undefined,
],
[t("usage.webSearches"), telemetry.webSearchRequests?.toLocaleString()],
[t("usage.finishReason"), telemetry.finishReason],
].map(([label, value]) => (
<div key={label}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value ?? t("usage.notReported")}
</Text>
</div>
))}
</Box>
{(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && (
<Text size="xs" c="dimmed">
{t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "}
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
</Text>
)}
{steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={650}>
{t("usage.agentSteps")}
</Text>
{steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" fw={600}>
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{formatStepThroughput(step.providerOutputTokensPerSecond, step.outputTokensPerSecond)}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
<Text size="xs" c="dimmed">
{[
step.providerLatencyMs !== undefined
? `${t("usage.providerLatency")} ${formatDuration(step.providerLatencyMs)}`
: undefined,
step.generationTimeMs !== undefined
? `${t("usage.generationTime")} ${formatDuration(step.generationTimeMs)}`
: undefined,
step.moderationLatencyMs !== undefined
? `${t("usage.moderationLatency")} ${formatDuration(step.moderationLatencyMs)}`
: undefined,
step.inputTokens !== undefined
? `${step.inputTokens.toLocaleString()} ${t("usage.inputShort")}`
: undefined,
step.outputTokens !== undefined
? `${step.outputTokens.toLocaleString()} ${t("usage.outputShort")}`
: undefined,
step.cachedInputTokens !== undefined
? `${step.cachedInputTokens.toLocaleString()} ${t("usage.cachedShort")}`
: undefined,
step.reasoningTokens !== undefined
? `${step.reasoningTokens.toLocaleString()} ${t("usage.reasoningShort")}`
: undefined,
step.fallbackCount !== undefined
? `${t("usage.fallbacks")} ${step.fallbackCount.toLocaleString()}`
: undefined,
step.fallbackLatencyMs !== undefined
? `${t("usage.fallbackLatency")} ${formatDuration(step.fallbackLatencyMs)}`
: undefined,
step.nativeFinishReason,
]
.filter(Boolean)
.join(" · ") || t("usage.notReported")}
</Text>
{(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && (
<Text size="xs" c="dimmed">
{t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "}
{step.normalizedOutputTokens?.toLocaleString() ?? "–"}
</Text>
)}
{(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && (
<Text size="xs" c="dimmed">
{[
step.routerStrategy,
step.routerRegion,
step.serviceTier,
step.dataRegion,
step.isByok ? t("usage.byok") : undefined,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</Text>
)}
</Box>
))}
</Stack>
)}
</Stack>
</Collapse>
</Box>
);
};
const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => {
const t = useI18n("assistant");
const hasContext = breakdown.percentage !== undefined;
const categories = [
{ label: t("usage.input"), value: breakdown.inputTokens },
{ label: t("usage.cached"), value: breakdown.cachedInputTokens },
{ label: t("usage.output"), value: breakdown.outputTokens },
{ label: t("usage.reasoning"), value: breakdown.reasoningTokens },
];
return (
<Box className={classes.contextBreakdown}>
<RingProgress
className={classes.contextBreakdownRing}
size={76}
thickness={7}
roundCaps
sections={
hasContext ? [{ value: breakdown.percentage ?? 0, color: getContextColor(breakdown.percentage ?? 0) }] : []
}
label={
<Text ta="center" size="xs" fw={700}>
{hasContext ? `${breakdown.percentage}%` : "–"}
</Text>
}
/>
<Box className={classes.contextBreakdownWindow}>
{[
{ label: t("usage.used"), value: breakdown.contextUsed },
{ label: t("usage.remaining"), value: breakdown.remaining },
{ label: t("usage.capacity"), value: breakdown.contextLength },
].map((item) => (
<Box key={item.label}>
<Text size="xs" c="dimmed">
{item.label}
</Text>
<Text size="sm" fw={650}>
{item.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
<Box className={classes.contextBreakdownCategories}>
{categories.map((category) => (
<Box key={category.label}>
<Text size="xs" c="dimmed">
{category.label}
</Text>
<Text size="xs" fw={650}>
{category.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
</Box>
);
};
export const ConversationContext = ({ compact = false }: { compact?: boolean }) => {
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
const messages = useAuiState((state) => state.thread.messages);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const metadata = useMemo(() => messages.map((message) => message.metadata), [messages]);
const messageIdByRequestId = useMemo(
() =>
new Map(
messages.flatMap((message) => {
const telemetry = getAssistantTelemetry(message.metadata);
return telemetry ? [[telemetry.requestId, message.id] as const] : [];
}),
),
[messages],
);
const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]);
const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]);
const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined);
const hasCost = usage.turns.some((turn) => turn.cost !== undefined);
const hasContext = breakdown.percentage !== undefined;
const contextPercentage = breakdown.percentage ?? 0;
const quickLabel = [
hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined,
hasCost ? formatCost(usage.cost) : undefined,
]
.filter(Boolean)
.join(" · ");
if (compact) {
let contextUsed = t("usage.notReported");
if (breakdown.contextUsed !== undefined && breakdown.contextLength !== undefined) {
contextUsed = `${breakdown.contextUsed.toLocaleString()} / ${breakdown.contextLength.toLocaleString()} ${t("usage.tokens")}`;
}
return (
<HoverCard width={220} position="top" shadow="md" openDelay={120} closeDelay={120} withinPortal>
<HoverCard.Target>
<Box
component="span"
className={classes.composerContextIndicator}
role="img"
tabIndex={0}
aria-label={`${t("usage.contextWindow")}: ${contextUsed}. ${t("usage.cost")}: ${hasCost ? formatCost(usage.cost) : t("usage.notReported")}`}
>
<RingProgress
size={24}
thickness={4}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
</Box>
</HoverCard.Target>
<HoverCard.Dropdown className={classes.contextQuickView}>
<Stack gap="xs">
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" fw={500}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{hasContext ? `${contextPercentage}%` : t("usage.notReported")}
</Text>
</Group>
<Progress size="xs" value={contextPercentage} color={getContextColor(contextPercentage)} />
<Text size="xs" c="dimmed">
{contextUsed}
</Text>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="xs" fw={500}>
{hasCost ? formatCost(usage.cost) : t("usage.notReported")}
</Text>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
width="min(28rem, calc(100vw - 1rem))"
position="top-end"
shadow="md"
withinPortal
trapFocus
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.composerContext}
type="button"
aria-label={quickLabel ? `${t("usage.contextWindow")}: ${quickLabel}` : t("usage.contextWindow")}
title={quickLabel || t("usage.contextWindow")}
aria-expanded={opened}
aria-haspopup="dialog"
onClick={() => setOpened((value) => !value)}
>
<Group gap={5} wrap="nowrap">
<RingProgress
size={22}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}>
<Stack gap="sm">
<Group justify="space-between" gap="xs" wrap="nowrap">
<Box miw={0}>
<Text size="sm" fw={700}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{t("usage.requestDetails")}
</Text>
</Box>
{hasContext && (
<Badge size="sm" variant="light" color={getContextColor(contextPercentage)}>
{contextPercentage}%
</Badge>
)}
</Group>
<Box className={classes.conversationUsageSummary}>
<div>
<Text size="xs" c="dimmed">
{t("usage.tokens")}
</Text>
<Text size="sm" fw={650}>
{hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={650}>
{hasCost ? formatCost(usage.cost) : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.agentSteps")}
</Text>
<Text size="sm" fw={650}>
{usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
</Text>
</div>
</Box>
<ConversationContextBreakdown breakdown={breakdown} />
<Divider />
<ScrollArea.Autosize mah="min(14rem, 30dvh)" type="auto" offsetScrollbars>
<Stack gap={5}>
{usage.turns.length === 0 ? (
<Text size="sm" c="dimmed" py="xs">
{t("usage.notReported")}
</Text>
) : (
usage.turns.map((turn, index) => (
<ConversationTurn
key={turn.requestId}
index={index}
turn={turn}
threadId={threadId ?? undefined}
messageId={messageIdByRequestId.get(turn.requestId)}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,13 @@
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
export interface AssistantConversationControls {
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
reasoning: AssistantReasoningMode;
isRefreshing: boolean;
autoFocusComposer?: boolean;
onRefresh: () => Promise<void>;
onModelChange: (modelId: string) => void;
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
}
@@ -0,0 +1,170 @@
import { describe, expect, test } from "vitest";
import {
buildAssistantConversationMarkdown,
buildAssistantMessageMarkdown,
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
describe("buildAssistantConversationMarkdown", () => {
test("exports user, assistant, reasoning, tool, and server telemetry content", () => {
const markdown = buildAssistantConversationMarkdown({
thread: {
id: "thread-12345678",
title: "Icon debugging",
modelId: "deepseek/deepseek-v4-flash",
createdAt: "2026-08-10T08:00:00.000Z",
updatedAt: "2026-08-10T08:01:00.000Z",
},
messages: [
{
id: "user-1",
parentId: null,
format: "ai-sdk/v6",
content: { role: "user", parts: [{ type: "text", text: "Find the Homarr icon" }] },
},
{
id: "assistant-1",
parentId: "user-1",
format: "ai-sdk/v6",
content: {
role: "assistant",
parts: [
{ type: "reasoning", text: "I should search the local icon catalog." },
{
type: "dynamic-tool",
toolName: "icon_findIcons",
toolCallId: "tool-1",
state: "output-available",
input: { searchText: "homarr", apiKey: "must-not-leak" },
output: {
icons: [{ name: "homarr.svg" }],
url: "https://user:password@example.com/icons?api_key=must-not-leak&query=homarr",
},
},
{ type: "text", text: "I found the icon." },
],
metadata: {
custom: {
telemetry: { requestId: "request-1", inputTokens: 120, generationAccessToken: "must-not-leak" },
},
},
},
},
],
exportedAt: new Date("2026-08-10T08:02:00.000Z"),
});
expect(markdown).toContain("## 1. User message");
expect(markdown).toContain("Find the Homarr icon");
expect(markdown).toContain("## 2. Assistant response");
expect(markdown).toContain("<summary>Reasoning</summary>");
expect(markdown).toContain("### Tool call: `icon_findIcons`");
expect(markdown).toContain("- Tool calls: 1");
expect(markdown).toContain('"searchText": "homarr"');
expect(markdown).toContain('"inputTokens": 120');
expect(markdown).toContain('"generationAccessToken": "[redacted]"');
expect(markdown).toContain("https://redacted:redacted@example.com/icons?api_key=redacted&query=homarr");
expect(markdown).not.toContain("must-not-leak");
});
test("normalizes assistant-ui tool calls with raw streamed input, results, errors, and server metadata", () => {
const markdown = buildAssistantMessageMarkdown(
{
id: "assistant-runtime-1",
parentId: "user-1",
format: "assistant-ui/runtime",
content: {
id: "assistant-runtime-1",
role: "assistant",
createdAt: "2026-08-10T08:03:00.000Z",
status: { type: "incomplete", reason: "error" },
content: [
{
type: "tool-call",
toolName: "icon_findIcons",
toolCallId: "icon-call-1",
args: { searchText: "homarr" },
argsText: '{"searchText":"homarr"',
result: { error: "Error in input stream" },
isError: true,
providerMetadata: { openrouter: { traceId: "trace-1" } },
},
],
metadata: { custom: { telemetry: { requestId: "request-2", cost: 0.001 } } },
},
},
new Date("2026-08-10T08:04:00.000Z"),
);
expect(markdown).toContain("### Tool call: `icon_findIcons`");
expect(markdown).toContain('"searchText": "homarr"');
expect(markdown).toContain("Raw streamed input");
expect(markdown).toContain("Error in input stream");
expect(markdown).toContain("Tool diagnostics");
expect(markdown).toContain('"traceId": "trace-1"');
expect(markdown).toContain("Server metadata");
expect(markdown).toContain('"requestId": "request-2"');
});
test("keeps system and unknown server messages available for debugging", () => {
const markdown = buildAssistantConversationMarkdown({
thread: { id: "thread-server", title: "Server records" },
messages: [
{
id: "system-1",
parentId: null,
format: "ai-sdk/v6",
content: { role: "system", parts: [{ type: "text", text: "System context snapshot" }] },
},
{
id: "server-1",
parentId: "system-1",
format: "server/debug",
content: { event: "stream-error", error: "Provider disconnected" },
},
],
});
expect(markdown).toContain("## 1. System message");
expect(markdown).toContain("System context snapshot");
expect(markdown).toContain("## 2. Server message");
expect(markdown).toContain('"event": "stream-error"');
expect(markdown).toContain("Provider disconnected");
});
test("omits embedded attachment data and handles an empty conversation", () => {
expect(
buildAssistantConversationMarkdown({
thread: { id: "thread-empty", title: null },
messages: [],
exportedAt: new Date("2026-08-10T08:02:00.000Z"),
}),
).toContain("No messages have been stored");
const markdown = buildAssistantConversationMarkdown({
thread: { id: "thread-files", title: "Files" },
messages: [
{
id: "user-file",
parentId: null,
format: "ai-sdk/v6",
content: {
role: "user",
parts: [{ type: "data-debug", payload: "data:image/png;base64,AAAA" }],
},
},
],
});
expect(markdown).toContain("[image/png data omitted]");
expect(markdown).not.toContain("base64,AAAA");
});
});
describe("getAssistantConversationExportFilename", () => {
test("creates a short portable Markdown filename", () => {
expect(getAssistantConversationExportFilename(" Icônes & Debug ", "abcdefgh12345678")).toBe(
"icones-debug-abcdefgh.md",
);
});
});
@@ -0,0 +1,261 @@
import { isRecord } from "@homarr/common";
export interface AssistantConversationExportMessage {
id: string;
parentId: string | null;
format: string;
content: unknown;
createdAt?: Date | string | null;
}
interface AssistantConversationExportInput {
thread: {
id: string;
title?: string | null;
modelId?: string | null;
createdAt?: Date | string | null;
updatedAt?: Date | string | null;
};
messages: AssistantConversationExportMessage[];
exportedAt?: Date;
}
const sensitiveKeyPattern =
/(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|session[-_]?token|(?:access|auth|refresh|generation)[-_]?token|^token$)$/iu;
const dataUrlPattern = /^data:([^;,]+)[;,]/iu;
const bearerTokenPattern = /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/giu;
const commonApiKeyPattern = /\bsk-(?:or-v1-)?[A-Za-z0-9_-]{16,}\b/giu;
const maxDebugStringLength = 12_000;
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
const redactDebugValue = (value: unknown, key?: string): unknown => {
if (key && sensitiveKeyPattern.test(key)) return "[redacted]";
if (typeof value === "string") {
let text = value.replaceAll(bearerTokenPattern, "Bearer [redacted]").replaceAll(commonApiKeyPattern, "[redacted]");
const dataUrl = dataUrlPattern.exec(text);
if (dataUrl) return `[${dataUrl[1] ?? "binary"} data omitted]`;
if (/^https?:\/\//iu.test(text)) {
try {
const url = new URL(text);
if (url.username) url.username = "redacted";
if (url.password) url.password = "redacted";
for (const name of url.searchParams.keys()) {
if (sensitiveKeyPattern.test(name)) url.searchParams.set(name, "redacted");
}
text = url.toString();
} catch {
// Keep malformed URLs as ordinary bounded debug strings.
}
}
return text.length > maxDebugStringLength
? `${text.slice(0, maxDebugStringLength)}\n… [truncated ${text.length - maxDebugStringLength} characters]`
: text;
}
if (Array.isArray(value)) return value.map((entry) => redactDebugValue(entry));
const record = asRecord(value);
if (!record) return value;
return Object.fromEntries(
Object.entries(record).map(([entryKey, entry]) => [entryKey, redactDebugValue(entry, entryKey)]),
);
};
const fencedJson = (value: unknown) => {
const serialized = JSON.stringify(redactDebugValue(value), null, 2) ?? String(redactDebugValue(value));
return `\n\`\`\`json\n${serialized}\n\`\`\`\n`;
};
const formatDate = (value: Date | string | null | undefined) => {
if (!value) return "Not recorded";
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
};
const roleLabel = (role: unknown) => {
switch (role) {
case "user":
return "User message";
case "assistant":
return "Assistant response";
case "system":
return "System message";
case "tool":
return "Tool message";
default:
return "Server message";
}
};
const getToolName = (part: Record<string, unknown>) => {
if (typeof part.toolName === "string") return part.toolName;
if (typeof part.type !== "string") return null;
if (part.type === "tool-call") return "unknown tool";
return part.type.startsWith("tool-") ? part.type.slice("tool-".length) : null;
};
const isToolPart = (part: unknown) => {
const record = asRecord(part);
if (!record || typeof record.type !== "string") return false;
return record.type === "dynamic-tool" || record.type === "tool-call" || record.type.startsWith("tool-");
};
const getToolValue = (part: Record<string, unknown>, primary: string, fallback: string) =>
primary in part ? part[primary] : part[fallback];
const renderToolPart = (part: Record<string, unknown>) => {
const toolName = getToolName(part) ?? "unknown tool";
const input = getToolValue(part, "input", "args");
const output = getToolValue(part, "output", "result");
const rawInput = getToolValue(part, "rawInput", "argsText");
const state =
typeof part.state === "string"
? part.state
: part.isError === true
? "output-error"
: output !== undefined
? "output-available"
: "input-available";
const lines = [`### Tool call: \`${toolName}\``, `- State: \`${state}\``];
if (typeof part.toolCallId === "string") lines.push(`- Call ID: \`${part.toolCallId}\``);
if (typeof part.providerExecuted === "boolean")
lines.push(`- Provider executed: ${part.providerExecuted ? "Yes" : "No"}`);
if (typeof part.preliminary === "boolean") lines.push(`- Preliminary result: ${part.preliminary ? "Yes" : "No"}`);
if (input !== undefined) lines.push("#### Input", fencedJson(input));
if (rawInput !== undefined && rawInput !== input) lines.push("#### Raw streamed input", fencedJson(rawInput));
if (output !== undefined) lines.push("#### Output", fencedJson(output));
if (typeof part.errorText === "string") lines.push("#### Error", part.errorText);
else if (part.isError === true) lines.push("#### Error", "The tool result was marked as an error.");
if (part.approval !== undefined) lines.push("#### Approval", fencedJson(part.approval));
const diagnostics = Object.fromEntries(
Object.entries(part).filter(
([key]) =>
![
"type",
"toolName",
"toolCallId",
"state",
"input",
"args",
"output",
"result",
"rawInput",
"argsText",
"errorText",
"isError",
"approval",
"providerExecuted",
"preliminary",
].includes(key),
),
);
if (Object.keys(diagnostics).length > 0) {
lines.push("<details>", "<summary>Tool diagnostics</summary>", fencedJson(diagnostics), "</details>");
}
return lines.join("\n");
};
const renderSourcePart = (part: Record<string, unknown>) => {
const title = typeof part.title === "string" ? part.title : "Source";
if (typeof part.url === "string") return `- [${title}](${part.url})`;
return `### Source document\n${fencedJson(part)}`;
};
const renderMessagePart = (part: unknown, index: number) => {
const record = asRecord(part);
if (!record) return `### Part ${index}\n${fencedJson(part)}`;
const type = typeof record.type === "string" ? record.type : "unknown";
if (type === "text" && typeof record.text === "string") return record.text;
if (type === "reasoning" && typeof record.text === "string") {
return `<details>\n<summary>Reasoning</summary>\n\n${record.text}\n\n</details>`;
}
if (type === "step-start") return "**Agent step started**";
if (isToolPart(record)) return renderToolPart(record);
if (type === "source" || type === "source-url" || type === "source-document") return renderSourcePart(record);
if (type === "file" || type === "image" || type === "audio" || type === "reasoning-file") {
return `### ${type}\n${fencedJson(record)}`;
}
if (type === "data" || type.startsWith("data-") || type === "generative-ui" || type === "custom") {
return `### ${type}\n${fencedJson(record)}`;
}
return `### ${type}\n${fencedJson(record)}`;
};
const getMessagePayload = (message: AssistantConversationExportMessage) => asRecord(message.content);
const getMessageParts = (message: AssistantConversationExportMessage) => {
const payload = getMessagePayload(message);
if (Array.isArray(payload?.parts)) return payload.parts;
return Array.isArray(payload?.content) ? payload.content : [];
};
const getMessageToolCount = (message: AssistantConversationExportMessage) =>
getMessageParts(message).filter(isToolPart).length;
const renderMessage = (message: AssistantConversationExportMessage, index: number) => {
const payload = getMessagePayload(message);
const parts = getMessageParts(message);
const createdAt = message.createdAt ?? payload?.createdAt;
const lines = [
`## ${index + 1}. ${roleLabel(payload?.role)}`,
`- Message ID: \`${message.id}\``,
`- Parent ID: ${message.parentId ? `\`${message.parentId}\`` : "None"}`,
`- Storage format: \`${message.format}\``,
`- Created: ${formatDate(createdAt as Date | string | null | undefined)}`,
`- Tool calls: ${getMessageToolCount(message)}`,
];
if (payload?.status !== undefined) lines.push(`- Status: \`${JSON.stringify(redactDebugValue(payload.status))}\``);
if (parts.length === 0) lines.push("", "_No normalized message parts were stored._", fencedJson(message.content));
else lines.push("", ...parts.map((part, partIndex) => renderMessagePart(part, partIndex + 1)));
if (payload?.attachments !== undefined) {
lines.push("", "<details>", "<summary>Attachments</summary>", fencedJson(payload.attachments), "</details>");
}
if (payload?.metadata !== undefined) {
lines.push("", "<details>", "<summary>Server metadata</summary>", fencedJson(payload.metadata), "</details>");
}
return lines.join("\n");
};
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
};
export const buildAssistantConversationMarkdown = ({
thread,
messages,
exportedAt = new Date(),
}: AssistantConversationExportInput) => {
const title = thread.title?.trim() || "Untitled assistant conversation";
const toolCount = messages.reduce((total, message) => total + getMessageToolCount(message), 0);
const header = [
`# ${title}`,
"",
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"",
`- Conversation ID: \`${thread.id}\``,
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
`- Created: ${formatDate(thread.createdAt)}`,
`- Updated: ${formatDate(thread.updatedAt)}`,
`- Exported: ${exportedAt.toISOString()}`,
`- Stored messages: ${messages.length}`,
`- Tool calls: ${toolCount}`,
];
if (messages.length === 0) return `${header.join("\n")}\n\n_No messages have been stored for this conversation._\n`;
return `${header.join("\n")}\n\n${messages.map(renderMessage).join("\n\n---\n\n")}\n`;
};
export const getAssistantConversationExportFilename = (title: string | null | undefined, threadId: string) => {
const slug = (title?.trim() || "assistant-conversation")
.normalize("NFKD")
.replaceAll(/\p{Mark}+/gu, "")
.replaceAll(/[^\p{Letter}\p{Number}]+/gu, "-")
.replaceAll(/^-+|-+$/gu, "")
.slice(0, 64)
.toLowerCase();
return `${slug || "assistant-conversation"}-${threadId.slice(0, 8)}.md`;
};
@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
describe("assistant directives", () => {
test("preserves text while parsing Homarr context references into visual segments", () => {
expect(
parseAssistantDirectives(
"Open :app[Facebook]{name=p6ik48io64xh4p1kyz2kwxiw} with :integration[Jellyfin]{name=pui7uj2196m5a96tl5hues5r} on :board[defaultv2]{name=a7b4sb1qkwftzt5xppxuvceq}",
),
).toEqual([
{ kind: "text", text: "Open " },
{ kind: "mention", type: "app", label: "Facebook", id: "p6ik48io64xh4p1kyz2kwxiw" },
{ kind: "text", text: " with " },
{ kind: "mention", type: "integration", label: "Jellyfin", id: "pui7uj2196m5a96tl5hues5r" },
{ kind: "text", text: " on " },
{ kind: "mention", type: "board", label: "defaultv2", id: "a7b4sb1qkwftzt5xppxuvceq" },
]);
});
test("uses the tools label for model-context tools and a safe fallback for unknown directives", () => {
expect(getAssistantDirectiveTranslationKey("tool")).toBe("tools");
expect(getAssistantDirectiveTranslationKey("unknown")).toBe("context");
});
});
@@ -0,0 +1,9 @@
import { unstable_defaultDirectiveFormatter } from "@assistant-ui/react";
export const parseAssistantDirectives = (text: string) => unstable_defaultDirectiveFormatter.parse(text);
export const getAssistantDirectiveTranslationKey = (type: string) => {
if (type === "tool" || type === "tools") return "tools" as const;
if (type === "app" || type === "integration" || type === "board" || type === "widget") return type;
return "context" as const;
};
@@ -0,0 +1,87 @@
@property --assistant-dot-matrix-hi {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --assistant-dot-matrix-lo {
syntax: "<number>";
inherits: false;
initial-value: 0.15;
}
.root {
display: inline-block;
width: 1rem;
height: 1rem;
flex: 0 0 auto;
color: currentColor;
}
.root[data-state="idle"],
.root[data-state="paused"],
.root[data-state="stopped"],
.root[data-state="offline"] {
color: var(--mantine-color-dimmed);
}
.root[data-state="recording"],
.root[data-state="error"] {
color: var(--mantine-color-red-filled);
}
.root[data-state="success"] {
color: var(--mantine-color-green-filled);
}
.root[data-state="warning"] {
color: var(--mantine-color-yellow-filled);
}
.root[data-state="info"] {
color: var(--mantine-color-blue-filled);
}
.grid {
display: block;
width: 100%;
height: 100%;
}
.dot {
transition-duration: 300ms;
transition-property: --assistant-dot-matrix-hi, --assistant-dot-matrix-lo, opacity;
animation-name: assistant-dot-matrix-blink;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
padding: 0;
border: 0;
margin: -1px;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@keyframes assistant-dot-matrix-blink {
0%,
100% {
opacity: var(--assistant-dot-matrix-hi, 1);
}
50% {
opacity: var(--assistant-dot-matrix-lo, 0.15);
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
transition-duration: 0ms;
animation-name: none;
}
}
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
describe("AssistantDotMatrix", () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
test("renders an accessible deterministic 5 by 5 loading matrix", async () => {
await act(async () => root.render(createElement(AssistantDotMatrix, { label: "Loading response" })));
const matrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
expect(matrix?.dataset.state).toBe("loading");
expect(matrix?.tagName).toBe("OUTPUT");
expect(matrix?.textContent).toBe("Loading response");
expect(matrix?.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
expect(matrix?.querySelectorAll("[data-slot='assistant-dot-matrix-dot']")).toHaveLength(25);
});
test("switches from the thinking wave to the streaming wave without changing its structure", async () => {
await act(async () => root.render(createElement(AssistantDotMatrix, { state: "thinking", label: "Thinking" })));
const matrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
const firstDot = matrix?.querySelector<SVGCircleElement>("[data-slot='assistant-dot-matrix-dot']");
expect(matrix?.dataset.state).toBe("thinking");
expect(firstDot?.style.animationDuration).toBe("1.2s");
await act(async () =>
root.render(createElement(AssistantDotMatrix, { state: "streaming", label: "Writing response" })),
);
const updatedMatrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
const updatedFirstDot = updatedMatrix?.querySelector<SVGCircleElement>("[data-slot='assistant-dot-matrix-dot']");
expect(updatedMatrix).toBe(matrix);
expect(updatedMatrix?.dataset.state).toBe("streaming");
expect(updatedMatrix?.textContent).toBe("Writing response");
expect(updatedFirstDot?.style.animationDuration).toBe("0.9s");
expect(updatedMatrix?.querySelectorAll("[data-slot='assistant-dot-matrix-dot']")).toHaveLength(25);
});
});
@@ -0,0 +1,232 @@
import type { ComponentProps, CSSProperties } from "react";
import classes from "./assistant-dot-matrix.module.css";
const gridSize = 5;
const center = (gridSize - 1) / 2;
const dotIndexes = Array.from({ length: gridSize * gridSize }, (_, index) => index);
const hash = (value: number, salt: number, range: number) => {
let mixed = (Math.imul(value, 374761393) + Math.imul(salt, 668265263)) >>> 0;
mixed = Math.imul(mixed ^ (mixed >>> 13), 1274126177) >>> 0;
return ((mixed ^ (mixed >>> 16)) % range) / 1000;
};
const glyph = (dots: [number, number][]) => new Set(dots.map(([row, column]) => row * gridSize + column));
const check = glyph([
[1, 4],
[2, 3],
[3, 0],
[3, 2],
[4, 1],
]);
const cross = glyph([
[0, 0],
[0, 4],
[1, 1],
[1, 3],
[2, 2],
[3, 1],
[3, 3],
[4, 0],
[4, 4],
]);
const bang = glyph([
[0, 2],
[1, 2],
[2, 2],
[4, 2],
]);
const info = glyph([
[0, 2],
[2, 2],
[3, 2],
[4, 2],
]);
const pause = glyph([
[1, 1],
[2, 1],
[3, 1],
[1, 3],
[2, 3],
[3, 3],
]);
const stop = glyph([
[1, 1],
[1, 2],
[1, 3],
[2, 1],
[2, 2],
[2, 3],
[3, 1],
[3, 2],
[3, 3],
]);
const record = glyph([
[1, 2],
[2, 1],
[2, 2],
[2, 3],
[3, 2],
]);
const ellipsis = glyph([
[2, 0],
[2, 2],
[2, 4],
]);
interface Blink {
duration: number;
delay: number;
lo: number;
}
interface StateConfig {
glyph?: Set<number>;
base?: number;
dim?: number;
blink?: (index: number, row: number, column: number) => Blink;
}
const states = {
idle: { base: 0.3 },
loading: {
blink: (index) => ({
duration: 0.9 + hash(index, 2, 700),
delay: -hash(index, 1, 1200),
lo: 0.15,
}),
},
thinking: {
blink: (_index, row, column) => ({
duration: 1.2,
delay: -(row + column) * 0.09,
lo: 0.2,
}),
},
streaming: {
blink: (_index, row, column) => ({
duration: 0.9,
delay: -(row * 0.12 + hash(column, 3, 900)),
lo: 0.15,
}),
},
searching: {
blink: (_index, _row, column) => ({ duration: 1.1, delay: -column * 0.12, lo: 0.2 }),
},
syncing: {
blink: (_index, row, column) => {
const turn = (Math.atan2(row - center, column - center) + Math.PI) / (2 * Math.PI);
return { duration: 1.3, delay: -turn * 1.3, lo: 0.2 };
},
},
connecting: {
blink: (_index, row, column) => ({
duration: 1.4,
delay: -Math.max(Math.abs(row - center), Math.abs(column - center)) * 0.18,
lo: 0.15,
}),
},
waiting: {
glyph: ellipsis,
blink: (_index, _row, column) => ({
duration: 1.2,
delay: -column * 0.09,
lo: 0.2,
}),
},
uploading: {
blink: (_index, row) => ({
duration: 1,
delay: -(gridSize - 1 - row) * 0.12,
lo: 0.2,
}),
},
downloading: {
blink: (_index, row) => ({ duration: 1, delay: -row * 0.12, lo: 0.2 }),
},
listening: {
blink: (_index, _row, column) => ({
duration: 0.7 + hash(column, 4, 500),
delay: -hash(column, 5, 900),
lo: 0.25,
}),
},
speaking: {
blink: (_index, _row, column) => ({
duration: 0.4 + hash(column, 6, 350),
delay: -hash(column, 7, 700),
lo: 0.2,
}),
},
recording: {
glyph: record,
dim: 0.12,
blink: () => ({ duration: 1.4, delay: 0, lo: 0.3 }),
},
success: { glyph: check },
error: {
glyph: cross,
blink: () => ({ duration: 1.1, delay: 0, lo: 0.4 }),
},
warning: {
glyph: bang,
blink: () => ({ duration: 1.6, delay: 0, lo: 0.45 }),
},
info: { glyph: info },
paused: { glyph: pause },
stopped: { glyph: stop },
offline: { base: 0.15 },
} satisfies Record<string, StateConfig>;
export type AssistantDotMatrixState = keyof typeof states;
interface AssistantDotMatrixProps extends Omit<ComponentProps<"output">, "children"> {
state?: AssistantDotMatrixState;
label?: string;
}
export const AssistantDotMatrix = ({ className, state = "loading", label, ...props }: AssistantDotMatrixProps) => {
const config: StateConfig = states[state];
return (
<output
data-slot="assistant-dot-matrix"
data-state={state}
className={[classes.root, className].filter(Boolean).join(" ")}
{...props}
>
<span className={classes.visuallyHidden}>{label ?? state}</span>
<svg aria-hidden viewBox="0 0 20 20" fill="currentColor" className={classes.grid}>
{dotIndexes.map((index) => {
const row = Math.floor(index / gridSize);
const column = index % gridSize;
const on = !config.glyph || config.glyph.has(index);
const hi = on ? (config.base ?? 1) : (config.dim ?? 0.15);
const blink = on ? config.blink?.(index, row, column) : undefined;
return (
<circle
key={index}
data-slot="assistant-dot-matrix-dot"
cx={2 + column * 4}
cy={2 + row * 4}
r={1.3}
className={classes.dot}
style={
{
opacity: hi,
animationDuration: `${blink?.duration ?? 1}s`,
animationDelay: `${blink?.delay ?? 0}s`,
"--assistant-dot-matrix-hi": hi,
"--assistant-dot-matrix-lo": blink?.lo ?? hi,
} as CSSProperties
}
/>
);
})}
</svg>
</output>
);
};
@@ -0,0 +1,81 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { useI18n } from "@homarr/translation/client";
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
import { AssistantContext } from "./assistant-context";
import { AssistantBoardWidgetLazy } from "./assistant-widget-lazy";
import { useRegisterAssistantSpotlightPlaceholder } from "./assistant-spotlight-placeholder";
/**
* The enabled runtime is the expensive half of the assistant (assistant-ui, Lexical, Markdown,
* Mermaid). Loading it through `next/dynamic` puts it in its own chunk that is only requested when
* this component actually renders. Availability is resolved alongside other root-layout reads on
* the server, so enabled instances render children in their final provider from the initial HTML
* without remounting the board after hydration.
*/
const EnabledAssistantRoot = dynamic(() =>
import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
);
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description);
const value = useMemo(
() => ({
enabled: false,
unavailableDescription: description,
opened: false,
isRunning: false,
isRefreshing: false,
unreadCount: 0,
hasVisibleWidget: false,
activeWidgetId: null,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(),
setWidgetVisible: () => undefined,
activateWidget: () => undefined,
}),
[description],
);
return (
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidgetLazy}>{children}</AssistantWidgetRendererProvider>
</AssistantContext.Provider>
);
};
/**
* Why the assistant is or is not usable, resolved alongside root-layout data.
*
* `unauthenticated` is kept apart from `unconfigured` because the server cannot check availability
* for a signed-out visitor, and telling them the instance is unconfigured would be a guess.
*/
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "error";
interface AssistantGateProps extends PropsWithChildren {
availability: AssistantAvailability;
}
const unavailableMessageKeys = {
unauthenticated: "unavailable.signIn",
unconfigured: "unavailable.notConfigured",
error: "unavailable.error",
} as const;
export const AssistantGate = ({ availability, children }: AssistantGateProps) => {
const t = useI18n("assistant");
if (availability === "enabled") {
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
}
return <DisabledAssistant description={t(unavailableMessageKeys[availability])}>{children}</DisabledAssistant>;
};
@@ -0,0 +1,334 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { ThreadListItemPrimitive, ThreadListPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ActionIcon, Button, Group, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
import {
IconCheck,
IconDotsVertical,
IconFileExport,
IconHistory,
IconPencil,
IconPlus,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { fetchApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import {
buildAssistantConversationMarkdown,
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
export const downloadAssistantMarkdown = (markdown: string, filename: string) => {
const blobUrl = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" }));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
};
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
const [exporting, setExporting] = useState(false);
const [actionsOpened, setActionsOpened] = useState(false);
const [renameOpened, setRenameOpened] = useState(false);
const [renaming, setRenaming] = useState(false);
const [nextTitle, setNextTitle] = useState(title ?? "");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renameOpened) renameInputRef.current?.focus();
}, [renameOpened]);
const exportConversation = async () => {
if (!remoteId || exporting) return;
setExporting(true);
try {
const conversation = await fetchApi.assistant.getThread.query({ threadId: remoteId });
const markdown = buildAssistantConversationMarkdown(conversation);
downloadAssistantMarkdown(
markdown,
getAssistantConversationExportFilename(conversation.thread.title ?? title, remoteId),
);
} catch {
showErrorNotification({
title: t("exportConversation.failedTitle"),
message: t("exportConversation.failedDescription"),
});
} finally {
setExporting(false);
}
};
const renameConversation = async () => {
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle || renaming) return;
setRenaming(true);
try {
await aui.threadListItem().rename(trimmedTitle);
setRenameOpened(false);
setActionsOpened(false);
} catch {
showErrorNotification({
title: t("renameConversation.failedTitle"),
message: t("renameConversation.failedDescription"),
});
} finally {
setRenaming(false);
}
};
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
};
if (renameOpened) {
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<TextInput
ref={renameInputRef}
className={classes.historyRenameInput}
value={nextTitle}
onChange={(event) => setNextTitle(event.currentTarget.value)}
aria-label={t("renameConversation.label")}
maxLength={72}
size="xs"
onKeyDown={(event) => {
if (event.key === "Enter") void renameConversation();
if (event.key === "Escape" && !renaming) setRenameOpened(false);
}}
/>
<ActionIcon
variant="subtle"
color="green"
size="sm"
aria-label={t("renameConversation.save")}
disabled={!nextTitle.trim()}
loading={renaming}
loaderProps={{ type: "bars" }}
onClick={() => void renameConversation()}
>
<IconCheck size={15} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={actionT("cancel")}
disabled={renaming}
onClick={() => setRenameOpened(false)}
>
<IconX size={15} />
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
}
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<ThreadListItemPrimitive.Trigger asChild>
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
<Text component="span" size="sm" fw={500} truncate>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Text>
</UnstyledButton>
</ThreadListItemPrimitive.Trigger>
{actionsOpened && (
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
title={t("renameConversation.action")}
aria-label={t("renameConversation.action")}
onClick={() => {
setNextTitle(title ?? "");
setRenameOpened(true);
}}
>
<IconPencil size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={exporting}
loaderProps={{ type: "bars" }}
disabled={!remoteId}
title={t("exportConversation.action")}
aria-label={t("exportConversation.action")}
onClick={() => void exportConversation()}
>
<IconFileExport size={14} />
</ActionIcon>
<InlineConfirmActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
>
<IconTrash size={14} />
</InlineConfirmActionIcon>
</Group>
)}
<ActionIcon
className={classes.historyItemMenu}
variant="subtle"
color="gray"
size="sm"
title={t("conversationActions")}
aria-label={t("conversationActions")}
aria-expanded={actionsOpened}
onClick={() => setActionsOpened((current) => !current)}
>
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
};
const ThreadHistory = ({ onSelect, compact }: { onSelect: () => void; compact: boolean }) => {
const t = useI18n("assistant");
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const threadItems = useAuiState((state) => state.threads.threadItems);
const hasMatchingConversation = threadItems.some((item) => {
const itemTitle = item.title ?? t("newConversation");
return itemTitle.toLocaleLowerCase().includes(normalizedQuery);
});
return (
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
{compact ? (
<Group justify="space-between" gap="xs" wrap="nowrap" px="xs">
<Text size="sm" fw={600}>
{t("conversations")}
</Text>
<ThreadListPrimitive.New asChild>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
title={t("newConversation")}
aria-label={t("newConversation")}
onClick={onSelect}
>
<IconPlus size={16} />
</ActionIcon>
</ThreadListPrimitive.New>
</Group>
) : (
<>
<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>
<TextInput
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={15} />}
placeholder={t("searchConversations")}
aria-label={t("searchConversations")}
size="xs"
/>
</>
)}
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items>
{({ threadListItem }) => {
const itemTitle = threadListItem.title ?? t("newConversation");
if (!compact && normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery))
return null;
return <ThreadListItem />;
}}
</ThreadListPrimitive.Items>
{!compact && normalizedQuery && !hasMatchingConversation && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("noMatchingConversations")}
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HistorySelectContext.Provider>
);
};
export const ConversationHistory = ({ compact = false }: { compact?: boolean }) => {
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<Button
className={classes.historyButton}
variant="subtle"
color="gray"
size={compact ? "xs" : "compact-sm"}
leftSection={<IconHistory size={16} />}
classNames={{ section: classes.historyButtonSection, label: classes.historyButtonLabel }}
onClick={() => setOpened((current) => !current)}
onKeyDown={(event) => {
if (event.key === "Escape" && opened) {
event.stopPropagation();
setOpened(false);
}
}}
aria-label={opened ? t("closeHistory") : t("openHistory")}
>
{t("conversations")}
</Button>
</Popover.Target>
<Popover.Dropdown className={compact ? classes.widgetHistoryDropdown : undefined} p={0}>
<ThreadHistory compact={compact} onSelect={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,7 @@
import type { ToolCallMessagePartStatus } from "@assistant-ui/react";
export const hasCompleteAssistantToolArguments = (status: ToolCallMessagePartStatus | undefined) =>
status?.type !== "running" && status?.type !== "incomplete";
export const hasFailedAssistantToolArguments = (status: ToolCallMessagePartStatus | undefined) =>
status?.type === "incomplete";
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { appManageSchema } from "@homarr/validation/app";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import { getAssistantAppFormValues } from "./assistant-human-tools";
describe("assistant human tool forms", () => {
test("keeps every human tool in its loading state while arguments are streaming", () => {
expect(hasCompleteAssistantToolArguments({ type: "running" })).toBe(false);
expect(hasCompleteAssistantToolArguments({ type: "complete" })).toBe(true);
expect(hasCompleteAssistantToolArguments({ type: "requires-action", reason: "interrupt" })).toBe(true);
expect(hasCompleteAssistantToolArguments({ type: "incomplete", reason: "error" })).toBe(false);
expect(hasFailedAssistantToolArguments({ type: "incomplete", reason: "error" })).toBe(true);
});
test("treats cancelled and truncated tool arguments as failed instead of loading forever", () => {
expect(hasFailedAssistantToolArguments({ type: "incomplete", reason: "cancelled" })).toBe(true);
expect(hasFailedAssistantToolArguments({ type: "incomplete", reason: "length" })).toBe(true);
expect(hasFailedAssistantToolArguments({ type: "running" })).toBe(false);
});
test("waits for streamed app arguments before initializing the form", () => {
expect(
getAssistantAppFormValues(
{ name: "Wiki" },
{
type: "running",
},
),
).toBeNull();
});
test("initializes the native app form with every completed tool argument", () => {
const values = getAssistantAppFormValues(
{
name: "Wikipedia",
description: "The Free Encyclopedia",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
pingUrl: "https://wikipedia.org",
},
{ type: "complete" },
);
expect(values).toEqual({
name: "Wikipedia",
description: "The Free Encyclopedia",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
pingUrl: "https://wikipedia.org",
});
expect(appManageSchema.safeParse(values).success).toBe(true);
});
test("drops unusable icon identifiers while preserving the other app fields", () => {
expect(
getAssistantAppFormValues(
{
name: "Wikipedia",
iconUrl: "wikipedia",
href: "https://wikipedia.org",
},
{ type: "complete" },
),
).toMatchObject({
name: "Wikipedia",
iconUrl: "",
href: "https://wikipedia.org",
});
});
});
@@ -0,0 +1,352 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } from "@assistant-ui/react";
import {
Alert,
Badge,
Box,
Button,
Group,
Skeleton,
Stack,
Text,
TextInput,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowRight,
IconCheck,
IconMessageQuestion,
IconPencil,
IconX,
} from "@tabler/icons-react";
import type { z } from "zod/v4";
import { AppForm } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import { getAssistantAskUserOptionKind } from "./assistant-ask-user";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import classes from "./assistant-panel.module.css";
import { AssistantPendingQuestionPortal } from "./assistant-question-portal";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import { normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
import type { AskUserArgs, AskUserResult, ConfigureAppArgs } from "./assistant-tool-contracts";
type AppValues = z.infer<typeof appManageSchema>;
export const toAssistantAppValues = (args: ConfigureAppArgs | undefined): AppValues => ({
name: args?.name ?? "",
description: args?.description ?? "",
iconUrl: normalizeAssistantAppIconUrl(args?.iconUrl),
href: args?.href ?? "",
pingUrl: args?.pingUrl ?? "",
});
export const getAssistantAppFormValues = (
args: ConfigureAppArgs | undefined,
status: ToolCallMessagePartStatus | undefined,
) => (hasCompleteAssistantToolArguments(status) ? toAssistantAppValues(args) : null);
export const AssistantHumanToolError = () => {
const t = useI18n("assistant.toolPreparationError");
return (
<Alert color="red" variant="light" title={t("title")} icon={<IconAlertTriangle size={18} />}>
{t("description")}
</Alert>
);
};
export const AssistantAskUserTool = ({
args,
result,
addResult,
status,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useI18n("assistant.askUser");
const [showOther, setShowOther] = useState(false);
const [other, setOther] = useState("");
const [submitting, setSubmitting] = useState(false);
const otherInputRef = useRef<HTMLInputElement>(null);
const options = Array.isArray(args?.options) ? args.options : [];
useEffect(() => {
if (showOther) otherInputRef.current?.focus();
}, [showOther]);
if (result) {
return (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color="green">
<IconCheck size={13} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed">
{t("answered")}
</Text>
<Text size="sm" fw={600}>
{result.answer}
</Text>
</Box>
</Box>
);
}
if (hasFailedAssistantToolArguments(status)) return <AssistantHumanToolError />;
if (!hasCompleteAssistantToolArguments(status)) {
return (
<AssistantPendingQuestionPortal>
<Box className={classes.humanTool} aria-label={t("preparing")}>
<Stack gap="xs">
<Skeleton height={18} width="62%" />
<Skeleton height={52} />
<Skeleton height={52} />
</Stack>
</Box>
</AssistantPendingQuestionPortal>
);
}
if (!args?.question || options.length < 2) {
return (
<AssistantPendingQuestionPortal>
<AssistantHumanToolError />
</AssistantPendingQuestionPortal>
);
}
const submitResult = (answer: AskUserResult) => {
if (submitting) return;
setSubmitting(true);
addResult(answer);
};
const submitOther = () => {
const answer = other.trim();
if (!answer) return;
submitResult({ answer, optionKind: "alternative", source: "other" });
};
return (
<AssistantPendingQuestionPortal>
<Box className={classes.humanTool}>
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">
<IconMessageQuestion size={18} />
</ThemeIcon>
<Box flex={1} miw={0}>
<Text fw={700} className={classes.humanToolQuestion}>
{args.question}
</Text>
{args.description && (
<Text size="sm" c="dimmed" mt={3}>
{args.description}
</Text>
)}
</Box>
</Group>
<Stack gap="xs" mt="md">
{options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
);
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
return (
<UnstyledButton
key={`${option.id}:${index}`}
className={classes.humanToolOption}
data-kind={kind}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
);
})}
{args.allowOther !== false &&
(showOther ? (
<Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput
ref={otherInputRef}
className={classes.humanToolOtherInput}
value={other}
onChange={(event) => setOther(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
submitOther();
}
}}
label={t("otherLabel")}
placeholder={t("otherPlaceholder")}
/>
<Button size="md" onClick={submitOther} disabled={!other.trim() || submitting} loading={submitting}>
{t("submit")}
</Button>
<Button
size="md"
variant="default"
px="sm"
aria-label={t("cancelOther")}
onClick={() => {
setShowOther(false);
setOther("");
}}
>
<IconX size={17} />
</Button>
</Group>
) : (
<Button
className={classes.humanToolOption}
variant="default"
size="md"
fullWidth
leftSection={<IconPencil size={17} />}
rightSection={
<Badge variant="light" color="gray" size="xs">
{t("optionKind.custom")}
</Badge>
}
justify="flex-start"
onClick={() => setShowOther(true)}
>
{t("other")}
</Button>
))}
</Stack>
</Box>
</AssistantPendingQuestionPortal>
);
};
export const AssistantConfigureAppTool = ({
args,
result,
addResult,
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureAppArgs, AppValues>) => {
const t = useI18n("assistant.configureApp");
const [submitting, setSubmitting] = useState(false);
const initialValues = getAssistantAppFormValues(args, status);
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && initialValues !== null,
completed: result !== undefined,
confirm: () => {
if (!initialValues) return;
addResult(initialValues);
},
});
if (result) {
return (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color="green">
<IconCheck size={13} />
</ThemeIcon>
<Box miw={0}>
<Text size="xs" c="dimmed">
{t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{result.name}
</Text>
</Box>
</Box>
);
}
if (hasFailedAssistantToolArguments(status)) return <AssistantHumanToolError />;
if (initialValues === null) {
return (
<Box className={classes.appTool} aria-label={t("preparing")}>
<Stack gap="sm">
<Skeleton height={18} width="42%" />
<Skeleton height={36} />
<Skeleton height={36} />
<Skeleton height={72} />
<Skeleton height={36} />
</Stack>
</Box>
);
}
if (autoConfirming) {
return (
<Box className={classes.appTool}>
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
</Box>
);
}
return (
<Box className={classes.appTool}>
<Stack gap={2} mb="md">
<Text fw={700}>{t("title")}</Text>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
</Stack>
<AppForm
key={toolCallId}
initialValues={initialValues}
buttonLabels={{ submit: t("continue") }}
showBackToOverview={false}
handleSubmit={(values) => {
if (submitting) return;
setSubmitting(true);
addResult(values);
}}
isPending={submitting}
/>
</Box>
);
};
@@ -0,0 +1,69 @@
.assistantImage {
width: fit-content;
max-width: min(100%, 28rem);
margin: 0.65rem 0;
}
.assistantImageViewport {
position: relative;
display: grid;
min-width: min(12rem, 100%);
min-height: 6rem;
overflow: hidden;
place-items: center;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background:
linear-gradient(45deg, var(--mantine-color-default) 25%, transparent 25%),
linear-gradient(-45deg, var(--mantine-color-default) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--mantine-color-default) 75%),
linear-gradient(-45deg, transparent 75%, var(--mantine-color-default) 75%);
background-position:
0 0,
0 0.45rem,
0.45rem -0.45rem,
-0.45rem 0;
background-size: 0.9rem 0.9rem;
}
.assistantImageViewport > img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 24rem;
margin: 0;
border-radius: 0;
object-fit: contain;
}
.assistantImageStatus {
position: absolute;
inset: 0;
min-height: 6rem;
color: var(--mantine-color-dimmed);
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
}
.assistantImageCaption {
max-width: 100%;
padding: 0.3rem 0.15rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(td) .assistantImage {
width: 2rem;
margin: 0 auto;
}
:global(td) .assistantImageViewport,
:global(td) .assistantImageStatus {
min-width: 2rem;
min-height: 2rem;
}
:global(td) .assistantImageViewport {
border-radius: var(--mantine-radius-sm);
}
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { AssistantImage } from "./assistant-image";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const containers: HTMLDivElement[] = [];
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
afterEach(() => {
for (const container of containers.splice(0)) container.remove();
});
const renderImage = async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
MantineProvider,
undefined,
createElement(AssistantImage, {
source: "https://cdn.example.com/homarr.png",
alt: "Homarr icon",
loadingLabel: "Loading image",
failedLabel: "Image could not be loaded",
retryLabel: "Retry image",
}),
),
);
});
return { container, root };
};
describe("AssistantImage", () => {
test("shows an accessible loading state and reveals the responsive image after loading", async () => {
const { container, root } = await renderImage();
expect(container.querySelector("output")?.getAttribute("aria-label")).toBe("Loading image");
const image = container.querySelector("img");
expect(image?.getAttribute("alt")).toBe("Homarr icon");
expect(image?.getAttribute("referrerpolicy")).toBe("no-referrer");
await act(async () => image?.dispatchEvent(new Event("load")));
expect(container.querySelector("output")).toBeNull();
expect(container.querySelector("figure")?.getAttribute("data-image-state")).toBe("loaded");
await act(async () => root.unmount());
});
test("replaces a broken image with a recoverable error and retries it", async () => {
const { container, root } = await renderImage();
const firstImage = container.querySelector("img") as HTMLImageElement;
await act(async () => firstImage.dispatchEvent(new Event("error")));
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Image could not be loaded");
const retry = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Retry image"),
);
await act(async () => retry?.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("output")).not.toBeNull();
expect(container.querySelector("img")).not.toBe(firstImage);
await act(async () => root.unmount());
});
});
@@ -0,0 +1,97 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { useEffect, useState } from "react";
import { MessagePartPrimitive } from "@assistant-ui/react";
import { Box, Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconPhotoOff, IconRefresh } from "@tabler/icons-react";
import classes from "./assistant-image.module.css";
interface AssistantImageProps extends Omit<ComponentPropsWithoutRef<"img">, "src"> {
source: string;
messagePart?: boolean;
caption?: string;
loadingLabel: string;
failedLabel: string;
retryLabel: string;
}
/**
* A bounded image preview shared by Markdown and assistant-ui image message parts. Message parts
* deliberately render through MessagePartPrimitive.Image so their source remains connected to the
* assistant-ui part context; the sanitized source prop overrides the raw part value.
*/
export const AssistantImage = ({
source,
messagePart = false,
caption,
loadingLabel,
failedLabel,
retryLabel,
alt,
className,
...imageProps
}: AssistantImageProps) => {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
useEffect(() => {
setState("loading");
setAttempt(0);
}, [source]);
const sharedProps = {
...imageProps,
src: source,
className,
loading: "lazy" as const,
decoding: "async" as const,
referrerPolicy: "no-referrer" as const,
onLoad: () => setState("loaded"),
onError: () => setState("failed"),
};
return (
<Box component="figure" className={classes.assistantImage} data-image-state={state}>
<Box className={classes.assistantImageViewport}>
{messagePart ? (
<MessagePartPrimitive.Image key={attempt} {...sharedProps} alt={alt} />
) : (
<img key={attempt} {...sharedProps} alt={alt} />
)}
{state === "loading" && (
<Center component="output" className={classes.assistantImageStatus} aria-label={loadingLabel}>
<Loader type="bars" size="sm" />
</Center>
)}
{state === "failed" && (
<Center className={classes.assistantImageStatus} role="alert">
<Stack align="center" gap={6} p="sm">
<IconPhotoOff size={22} aria-hidden />
<Text size="xs" ta="center">
{failedLabel}
</Text>
<Button
size="compact-xs"
variant="default"
leftSection={<IconRefresh size={13} aria-hidden />}
onClick={() => {
setState("loading");
setAttempt((value) => value + 1);
}}
>
{retryLabel}
</Button>
</Stack>
</Center>
)}
</Box>
{caption && state === "loaded" && (
<Text component="figcaption" className={classes.assistantImageCaption} size="xs" c="dimmed">
{caption}
</Text>
)}
</Box>
);
};
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { resolveAssistantDirectiveEntity, transformAssistantMarkdownDirectives } from "./assistant-markdown-directives";
describe("assistant markdown directives", () => {
test("turns assistant item citations into typed inline elements", () => {
const directive: {
type: string;
name: string;
attributes: { name: string };
children: { type: string; value: string }[];
data?: Record<string, unknown>;
} = {
type: "textDirective",
name: "app",
attributes: { name: "app-1" },
children: [{ type: "text", value: "Discord" }],
};
transformAssistantMarkdownDirectives({ type: "root", children: [directive] });
expect(directive.data).toEqual({
hName: "span",
hProperties: {
"data-assistant-directive": "true",
"data-directive-id": "app-1",
"data-directive-label": "Discord",
"data-directive-type": "app",
},
});
});
test("resolves shorthand citations only when their label is unambiguous", () => {
const entities = [
{ id: "app-1", type: "app" as const, label: "Discord", description: "Chat", iconUrl: "/discord.svg" },
{ id: "board-1", type: "board" as const, label: "Home", description: "Home board" },
];
expect(resolveAssistantDirectiveEntity(entities, { id: "Discord", label: "Discord", type: "app" })?.id).toBe(
"app-1",
);
expect(resolveAssistantDirectiveEntity(entities, { id: "Home", label: "Home", type: "board" })?.id).toBe("board-1");
});
});
@@ -0,0 +1,63 @@
export type AssistantDirectiveType = "app" | "integration" | "board" | "widget";
export type AssistantDirectiveEntity = {
id: string;
type: AssistantDirectiveType;
label: string;
description: string;
iconUrl?: string;
};
type MarkdownNode = {
type?: string;
name?: string;
value?: string;
attributes?: Record<string, string | null | undefined> | null;
children?: MarkdownNode[];
data?: Record<string, unknown>;
};
const directiveTypes = new Set<AssistantDirectiveType>(["app", "integration", "board", "widget"]);
const getNodeText = (node: MarkdownNode): string =>
typeof node.value === "string" ? node.value : (node.children ?? []).map(getNodeText).join("");
export const transformAssistantMarkdownDirectives = (tree: MarkdownNode) => {
const visit = (node: MarkdownNode) => {
if (node.type === "textDirective" && node.name && directiveTypes.has(node.name as AssistantDirectiveType)) {
const label = getNodeText(node).trim();
const directiveId = node.attributes?.name?.trim() || label;
if (label && directiveId) {
node.data = {
...node.data,
hName: "span",
hProperties: {
"data-assistant-directive": "true",
"data-directive-id": directiveId,
"data-directive-label": label,
"data-directive-type": node.name,
},
};
}
}
for (const child of node.children ?? []) visit(child);
};
visit(tree);
};
export const remarkAssistantDirectives = () => transformAssistantMarkdownDirectives;
export const resolveAssistantDirectiveEntity = (
entities: AssistantDirectiveEntity[],
directive: { id: string; label: string; type: string },
) => {
if (!directiveTypes.has(directive.type as AssistantDirectiveType)) return undefined;
const matchingType = entities.filter((entity) => entity.type === directive.type);
const exactId = matchingType.find((entity) => entity.id === directive.id);
if (exactId) return exactId;
const normalizedLabel = directive.label.trim().toLocaleLowerCase();
const labelMatches = matchingType.filter((entity) => entity.label.trim().toLocaleLowerCase() === normalizedLabel);
return labelMatches.length === 1 ? labelMatches[0] : undefined;
};
@@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest";
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
describe("getSafeAssistantMarkdownImageSource", () => {
test("allows Homarr-relative and ordinary remote images", () => {
expect(getSafeAssistantMarkdownImageSource("/logo/logo.png")).toBe("/logo/logo.png");
expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBe(
"https://cdn.example.com/icons/discord.svg",
);
});
test("rejects unsafe protocols, protocol-relative URLs, and embedded credentials", () => {
expect(getSafeAssistantMarkdownImageSource("javascript:alert(1)")).toBeNull();
expect(getSafeAssistantMarkdownImageSource("data:image/svg+xml;base64,PHN2Zy8+")).toBeNull();
expect(getSafeAssistantMarkdownImageSource("//example.com/icon.svg")).toBeNull();
expect(getSafeAssistantMarkdownImageSource("https://user:secret@example.com/icon.svg")).toBeNull();
});
});
describe("getSafeAssistantAttachmentImageSource", () => {
test("allows persisted raster image data and same-origin image URLs", () => {
expect(getSafeAssistantAttachmentImageSource("data:image/png;base64,iVBORw0KGgo=", "https://homarr.local")).toBe(
"data:image/png;base64,iVBORw0KGgo=",
);
expect(getSafeAssistantAttachmentImageSource("/api/assistant/file/one", "https://homarr.local")).toBe(
"/api/assistant/file/one",
);
expect(
getSafeAssistantAttachmentImageSource("https://homarr.local/api/assistant/file/one", "https://homarr.local"),
).toBe("https://homarr.local/api/assistant/file/one");
});
test("rejects active image formats, unsafe schemes, and cross-origin URLs", () => {
expect(
getSafeAssistantAttachmentImageSource("data:image/svg+xml;base64,PHN2Zy8+", "https://homarr.local"),
).toBeNull();
expect(getSafeAssistantAttachmentImageSource("javascript:alert(1)", "https://homarr.local")).toBeNull();
expect(getSafeAssistantAttachmentImageSource("https://example.com/private.png", "https://homarr.local")).toBeNull();
});
});
@@ -0,0 +1,34 @@
import { getSafeAssistantHttpUrl } from "./assistant-url";
export const getSafeAssistantMarkdownImageSource = (source: string | Blob | undefined) => {
if (typeof source !== "string" || !source) return null;
if (source.startsWith("/") && !source.startsWith("//")) return source;
return getSafeAssistantHttpUrl(source);
};
const safeAttachmentDataUrl =
/^data:image\/(?:gif|jpeg|png|webp);base64,(?:[a-z\d+/]{4})*(?:[a-z\d+/]{2}==|[a-z\d+/]{3}=)?$/iu;
export const getSafeAssistantAttachmentImageSource = (source: unknown, currentOrigin?: string) => {
if (typeof source !== "string" || !source) return null;
if (safeAttachmentDataUrl.test(source)) return source;
if (source.startsWith("/") && !source.startsWith("//")) return source;
if (!currentOrigin) return null;
try {
const sourceUrl = new URL(source);
const originUrl = new URL(currentOrigin);
if (
sourceUrl.origin !== originUrl.origin ||
(sourceUrl.protocol !== "http:" && sourceUrl.protocol !== "https:") ||
sourceUrl.username ||
sourceUrl.password
) {
return null;
}
return sourceUrl.toString();
} catch {
return null;
}
};
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
describe("normalizeAssistantMarkdown", () => {
test("formats the assistant capability introduction as readable sections", () => {
const text =
"Hey! 👋 I'm your Homarr Assistant — ready to help you manage your dashboard. Here's what I can do: - **Boards & Apps** — create, edit, organize your dashboard boards and app shortcuts - **Integrations** — work with Sonarr, Radarr, Plex, Jellyfin, Home Assistant, Pi-hole, download clients, and more - **Media** — search and request movies/shows, check streams, browse calendars - **Docker** — view, start, stop, restart containers - **System** — monitor health, DNS stats, downloads, and smart home devices What are you looking to do?";
const normalized = normalizeAssistantMarkdown(text);
expect(normalized).toContain("Here's what I can do:\n\n- **Boards & Apps**");
expect(normalized).toContain("\n- **Integrations**");
expect(normalized).toContain("\n- **System**");
expect(normalized.endsWith("devices\n\nWhat are you looking to do?")).toBe(true);
});
test("turns an inline bold-label sequence into a Markdown list", () => {
const text =
"Here's what I can do: - **Boards & Apps** — create and organize boards - **Integrations** — work with Sonarr and Radarr - **Docker** — manage containers What would you like to do?";
expect(normalizeAssistantMarkdown(text)).toBe(
"Here's what I can do:\n\n- **Boards & Apps** — create and organize boards\n- **Integrations** — work with Sonarr and Radarr\n- **Docker** — manage containers\n\nWhat would you like to do?",
);
});
test("supports real and escaped line breaks", () => {
expect(normalizeAssistantMarkdown("First line\\nSecond line\nThird line")).toBe(
"First line\nSecond line\nThird line",
);
});
test("does not rewrite fenced or inline code", () => {
const text = "Before\\n`value\\n - **one** — 1 - **two** — 2`\n```md\nA\\n - **one** — 1 - **two** — 2\n```\nAfter";
expect(normalizeAssistantMarkdown(text)).toBe(
"Before\n`value\\n - **one** — 1 - **two** — 2`\n```md\nA\\n - **one** — 1 - **two** — 2\n```\nAfter",
);
});
test("leaves ordinary inline hyphens and single suggestions alone", () => {
const text = "Use the well-known provider - or choose **Custom**. Try - **Boards** — list boards.";
expect(normalizeAssistantMarkdown(text)).toBe(text);
});
});
@@ -0,0 +1,41 @@
const protectedMarkdownPattern = /```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`+[^`\n]*`+/gu;
const inlineStrongListMarker = / - (?=\*\*[^*\n]{1,80}\*\*\s+(?:[—–:-]\s+)?)/gu;
const trailingQuestion = /\s+((?:Are|Can|Could|Do|Does|How|Is|What|When|Where|Which|Why|Would)\b[^?\n]*\?)$/u;
const normalizeProse = (text: string) => {
const withLineBreaks = text.replaceAll("\\r\\n", "\n").replaceAll("\\n", "\n");
return withLineBreaks
.split("\n")
.map((line) => {
const markers = [...line.matchAll(inlineStrongListMarker)];
if (markers.length < 2) return line;
let markerIndex = 0;
return line
.replaceAll(inlineStrongListMarker, () => (markerIndex++ === 0 ? "\n\n- " : "\n- "))
.replace(trailingQuestion, "\n\n$1");
})
.join("\n");
};
/**
* Makes common streamed model Markdown readable without changing code samples.
*
* Models occasionally emit escaped line breaks or an entire bold-label list on
* one line. The assistant-ui renderer receives this function through its
* streaming-safe `preprocess` hook, before Markdown is parsed.
*/
export const normalizeAssistantMarkdown = (text: string) => {
let offset = 0;
let normalized = "";
for (const match of text.matchAll(protectedMarkdownPattern)) {
const index = match.index;
normalized += normalizeProse(text.slice(offset, index));
normalized += match[0];
offset = index + match[0].length;
}
return normalized + normalizeProse(text.slice(offset));
};
@@ -0,0 +1,545 @@
"use client";
import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { createContext, lazy, Suspense, useContext, useEffect, useId, useState } from "react";
import type {
EmptyMessagePartProps,
FileMessagePartProps,
ImageMessagePartProps,
ReasoningMessagePartProps,
SourceMessagePartProps,
} from "@assistant-ui/react";
import { AttachmentPrimitive, useAuiState, useMessagePartText } from "@assistant-ui/react";
import type { DirectiveChipProps } from "@assistant-ui/react-lexical";
import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents } from "@assistant-ui/react-markdown";
import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown";
import { ActionIcon, Anchor, Box, Group, Loader, Text, useComputedColorScheme } from "@mantine/core";
import {
IconAlertTriangle,
IconApps,
IconAt,
IconDownload,
IconFile,
IconLink,
IconMessage,
IconPhoto,
IconTool,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { AssistantImage } from "./assistant-image";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
import { getSafeAssistantHttpUrl } from "./assistant-url";
// Load Shiki only when a settled response contains a code block.
const ShikiHighlighter = lazy(() => import("react-shiki/web"));
const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks];
export const contextIcons = {
app: IconApps,
integration: IconLink,
board: IconMessage,
widget: IconTool,
tool: IconTool,
tools: IconTool,
};
export const ContextDirectiveChip = ({
directiveId,
directiveType,
label,
iconUrl,
}: DirectiveChipProps & { iconUrl?: string }) => {
const t = useI18n("assistant");
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
const typeLabel = t(`mentions.${translationKey}`);
return (
<span
className={classes.directiveChip}
data-directive-type={directiveType}
data-directive-id={directiveId}
title={`${typeLabel}: ${label}`}
>
{safeIconUrl ? (
<img className={classes.directiveChipImage} src={safeIconUrl} alt="" aria-hidden referrerPolicy="no-referrer" />
) : (
<Icon size={12} aria-hidden />
)}
<span className={classes.directiveChipLabel}>{label}</span>
</span>
);
};
const getInternalAssistantHref = (href: string) => {
if (href.startsWith("/") && !href.startsWith("//")) return href;
if (typeof window === "undefined" || !URL.canParse(href)) return null;
const url = new URL(href);
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : null;
};
export const hasModifiedLinkClick = (event: ReactMouseEvent<HTMLAnchorElement>) =>
event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
const MarkdownLink = ({ href, children, onClick, onMouseEnter, ...props }: ComponentPropsWithoutRef<"a">) => {
const router = useRouter();
const httpLink = href !== undefined && /^https?:\/\//iu.test(href);
const relativeLink = href?.startsWith("/") && !href.startsWith("//");
const safeLink = href?.startsWith("#") || href?.startsWith("mailto:") || httpLink || relativeLink;
if (!href || !safeLink) return <span>{children}</span>;
return (
<a
href={href}
target={httpLink ? "_blank" : undefined}
rel={httpLink ? "noreferrer" : undefined}
{...props}
onMouseEnter={(event) => {
onMouseEnter?.(event);
const internalHref = getInternalAssistantHref(href);
if (internalHref) router.prefetch(internalHref);
}}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || hasModifiedLinkClick(event)) return;
const internalHref = getInternalAssistantHref(href);
if (!internalHref) return;
event.preventDefault();
router.push(internalHref);
}}
>
{children}
</a>
);
};
const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
<div className={classes.markdownTableWrap}>
<table {...props}>{children}</table>
</div>
);
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useI18n("assistant.image");
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
return (
<AssistantImage
{...props}
source={safeSource}
alt={alt}
className={classes.markdownImage}
loadingLabel={t("loading")}
failedLabel={t("failed")}
retryLabel={t("retry")}
/>
);
};
const AssistantSyntaxHighlighter = ({ code, components, language }: SyntaxHighlighterProps) => {
const colorScheme = useComputedColorScheme("light");
const streaming = useAuiState((state) => state.part.status?.type === "running");
const Pre = components.Pre;
const Code = components.Code;
if (streaming) {
return (
<Pre>
<Code>{code}</Code>
</Pre>
);
}
return (
<Suspense
fallback={
<Pre>
<Code>{code}</Code>
</Pre>
}
>
<ShikiHighlighter
className={classes.syntaxHighlighter}
language={language === "unknown" ? "text" : language}
theme={colorScheme === "dark" ? "github-dark" : "github-light"}
showLanguage={false}
addDefaultStyles={false}
>
{code}
</ShikiHighlighter>
</Suspense>
);
};
const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => {
const t = useI18n("assistant");
const colorScheme = useComputedColorScheme("light");
const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`;
const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code });
useEffect(() => {
let active = true;
setRendered({ code });
void import("mermaid")
.then(async ({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: colorScheme === "dark" ? "dark" : "neutral",
fontFamily: "Inter, system-ui, sans-serif",
});
return await mermaid.render(diagramId, code);
})
.then(({ svg }) => {
if (active) setRendered({ code, svg });
})
.catch(() => {
document.getElementById(`d${diagramId}`)?.remove();
if (active) setRendered({ code, failed: true });
});
return () => {
active = false;
};
}, [code, colorScheme, diagramId]);
if (rendered.code !== code || (!rendered.svg && !rendered.failed)) {
return (
<Group component="output" className={classes.mermaidStatus} gap="xs">
<Loader type="bars" size="xs" />
<Text size="xs" c="dimmed">
{t("markdown.renderingDiagram")}
</Text>
</Group>
);
}
if (rendered.failed || !rendered.svg) {
const Pre = components.Pre;
const Code = components.Code;
return (
<Box className={classes.mermaidFallback}>
<Text size="xs" c="dimmed" mb="xs">
{t("markdown.diagramUnavailable")}
</Text>
<Pre>
<Code>{code}</Code>
</Pre>
</Box>
);
}
return (
<Box
component="figure"
className={classes.mermaidDiagram}
aria-label={t("markdown.diagram")}
dangerouslySetInnerHTML={{ __html: rendered.svg }}
/>
);
};
type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
"data-assistant-directive"?: string;
"data-directive-id"?: string;
"data-directive-label"?: string;
"data-directive-type"?: string;
};
interface AssistantDirectiveEntitiesState {
entities: AssistantDirectiveEntity[];
isLoading: boolean;
}
export const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntitiesState>({
entities: [],
isLoading: false,
});
export const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => {
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
return (
<AssistantDirectiveEntitiesContext.Provider value={{ entities, isLoading }}>
{children}
</AssistantDirectiveEntitiesContext.Provider>
);
};
const AssistantMarkdownSpan = ({
children,
"data-assistant-directive": assistantDirective,
"data-directive-id": directiveId,
"data-directive-label": directiveLabel,
"data-directive-type": directiveType,
...props
}: MarkdownSpanProps) => {
const { entities } = useContext(AssistantDirectiveEntitiesContext);
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
const entity = resolveAssistantDirectiveEntity(entities, {
id: directiveId,
label: directiveLabel,
type: directiveType,
});
return (
<ContextDirectiveChip
directiveId={entity?.id ?? directiveId}
directiveType={directiveType}
label={entity?.label ?? directiveLabel}
iconUrl={entity?.iconUrl}
/>
);
}
return <span {...props}>{children}</span>;
};
const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({
a: MarkdownLink,
img: MarkdownImage,
span: AssistantMarkdownSpan,
table: MarkdownTable,
SyntaxHighlighter: AssistantSyntaxHighlighter,
});
export const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
);
export const UserTextPart = () => {
const { text } = useMessagePartText();
const { entities } = useContext(AssistantDirectiveEntitiesContext);
const segments = parseAssistantDirectives(text);
return (
<span className={classes.messageText}>
{segments.map((segment, index) => {
if (segment.kind === "text") return <span key={`${index}:${segment.text}`}>{segment.text}</span>;
const entity = resolveAssistantDirectiveEntity(entities, segment);
return (
<ContextDirectiveChip
key={`${index}:${segment.type}:${segment.id}`}
directiveId={entity?.id ?? segment.id}
directiveType={segment.type}
label={entity?.label ?? segment.label}
iconUrl={entity?.iconUrl}
/>
);
})}
</span>
);
};
const AttachmentPreview = () => {
const attachment = useAuiState((state) => state.attachment);
const [filePreview, setFilePreview] = useState<string | null>(null);
const contentImage = attachment.content?.find(
(part) => part.type === "image" || (part.type === "file" && part.mimeType?.startsWith("image/") === true),
);
let persistedImageSource: string | null = null;
if (contentImage?.type === "image" && typeof contentImage.image === "string") {
persistedImageSource = contentImage.image;
}
if (contentImage?.type === "file" && typeof contentImage.data === "string") {
persistedImageSource = contentImage.data;
}
const persistedPreview = persistedImageSource
? getSafeAssistantAttachmentImageSource(
persistedImageSource,
typeof window === "undefined" ? undefined : window.location.origin,
)
: null;
const isImage = attachment.type === "image" || attachment.contentType?.startsWith("image/") === true;
useEffect(() => {
if (persistedPreview || !isImage || !attachment.file) {
setFilePreview(null);
return;
}
const reader = new FileReader();
let cancelled = false;
reader.onload = () => {
if (!cancelled && typeof reader.result === "string") setFilePreview(reader.result);
};
reader.onerror = () => {
if (!cancelled) setFilePreview(null);
};
reader.readAsDataURL(attachment.file);
return () => {
cancelled = true;
if (reader.readyState === FileReader.LOADING) reader.abort();
};
}, [attachment.file, isImage, persistedPreview]);
const source = persistedPreview ?? filePreview;
if (source) {
return <Box component="img" className={classes.attachmentImage} src={source} alt="" aria-hidden />;
}
return <Box className={classes.attachmentFileIcon}>{isImage ? <IconPhoto size={18} /> : <IconFile size={18} />}</Box>;
};
export const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useI18n("assistant");
const attachment = useAuiState((state) => state.attachment);
const isImage = attachment.type === "image" || attachment.contentType?.startsWith("image/") === true;
return (
<AttachmentPrimitive.Root className={classes.attachment} data-image={isImage || undefined}>
<AttachmentPreview />
<Box className={classes.attachmentCopy}>
<Text size="xs" fw={600} lineClamp={1} className={classes.attachmentName}>
<AttachmentPrimitive.Name />
</Text>
{attachment.contentType && (
<Text size="xs" truncate className={classes.attachmentType}>
{attachment.contentType}
</Text>
)}
</Box>
{attachment.status.type === "running" && <Loader type="bars" size="xs" />}
{removable && (
<AttachmentPrimitive.Remove asChild>
<ActionIcon
className={classes.attachmentRemove}
variant="filled"
color="dark"
size="xs"
aria-label={t("removeAttachment")}
>
<IconX size={12} />
</ActionIcon>
</AttachmentPrimitive.Remove>
)}
</AttachmentPrimitive.Root>
);
};
export const SentAttachment = () => <Attachment />;
export const HiddenMessagePart = () => null;
export const ReasoningPart = (_props: ReasoningMessagePartProps) => (
<AssistantDirectiveEntitiesContext.Provider value={{ entities: [], isLoading: false }}>
<MarkdownTextPrimitive
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
/>
</AssistantDirectiveEntitiesContext.Provider>
);
export const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
const t = useI18n("assistant");
if (status.type !== "running") return null;
return (
<Group component="output" className={classes.messagePending} gap="xs" wrap="nowrap" aria-live="polite">
<AssistantDotMatrix state="thinking" role="presentation" aria-hidden />
<Text size="xs" fw={600} c="dimmed">
{t("activity.thinking")}
</Text>
</Group>
);
};
export const ReasoningVisibilityContext = createContext<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}>({ collapsed: false, setCollapsed: () => undefined });
export const SourcePart = (source: SourceMessagePartProps) => {
const safeUrl = getSafeAssistantHttpUrl(source.url);
const content = (
<>
<IconLink size={13} />
{source.title}
</>
);
return safeUrl ? (
<Anchor className={classes.source} href={safeUrl} target="_blank" rel="noreferrer" size="xs">
{content}
</Anchor>
) : (
<Text component="span" className={classes.source} c="dimmed" size="xs">
{content}
</Text>
);
};
export const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const t = useI18n("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>
);
};
export const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useI18n("assistant");
const source = getSafeAssistantAttachmentImageSource(
image,
typeof window === "undefined" ? undefined : window.location.origin,
);
if (!source) {
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconAlertTriangle size={16} />
<Text size="xs">{t("externalImageBlocked")}</Text>
</Group>
);
}
return (
<AssistantImage
messagePart
source={source}
alt={filename ?? t("attachedImage")}
caption={filename}
className={classes.messageImage}
loadingLabel={t("image.loading")}
failedLabel={t("image.failed")}
retryLabel={t("image.retry")}
/>
);
};
@@ -0,0 +1,12 @@
import { groupPartByType } from "@assistant-ui/react";
/**
* Keep model reasoning and ordinary tool calls in one trace. Human tools and
* other standalone tool UIs stay outside the trace so approvals and questions
* retain the full conversation width.
*/
export const assistantMessageGroupBy = groupPartByType({
reasoning: ["group-agent-trace", "group-reasoning"],
"tool-call": ["group-agent-trace", "group-tool"],
"standalone-tool-call": [],
});
@@ -0,0 +1,315 @@
import { describe, expect, test } from "vitest";
import {
getAssistantContextBreakdown,
getAssistantConversationUsage,
getAssistantTelemetry,
getAssistantUsage,
resolveAssistantContextWindowTelemetry,
} from "./assistant-message-metadata";
const createUsageMetadata = ({
requestId,
inputTokens,
outputTokens,
cost,
contextUsed,
}: {
requestId: string;
inputTokens: number;
outputTokens: number;
cost: number;
contextUsed?: number;
}) => ({
usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
custom: {
telemetry: {
requestId,
provider: "openrouter",
modelId: "example/model",
startedAt: "2026-08-04T10:00:00.000Z",
completedAt: "2026-08-04T10:00:01.000Z",
durationMs: 1000,
cost,
contextLength: 128_000,
...(contextUsed === undefined ? {} : { contextUsed }),
steps: [],
},
},
});
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",
providerOutputTokensPerSecond: 37.9,
generationTimeMs: 1451,
cacheDiscount: 0.00003,
fallbackCount: 1,
webSearchRequests: 2,
webSearchSources: [
{ url: "https://example.com/research", title: "Research source" },
{ url: "javascript:alert(1)", title: "Unsafe source" },
],
steps: [
{
index: 1,
durationMs: 900,
modelDurationMs: 800,
toolDurationMs: 100,
providerLatencyMs: 964,
generationTimeMs: 1451,
providerOutputTokensPerSecond: 37.9,
inputTokens: 12_777,
outputTokens: 55,
normalizedInputTokens: 12_799,
normalizedOutputTokens: 62,
cachedInputTokens: 2048,
reasoningTokens: 7,
cacheDiscount: 0.00003,
fallbackCount: 1,
webSearchRequests: 2,
webSearchSources: [{ url: "https://example.com/research", title: "Research source" }],
routedProvider: "Alibaba",
serviceTier: "default",
dataRegion: "US",
isByok: false,
streamed: true,
cancelled: false,
},
],
},
},
}),
).toMatchObject({
requestId: "request-1",
provider: "openrouter",
durationMs: 1000,
contextLength: 128_000,
costType: "reported",
providerOutputTokensPerSecond: 37.9,
generationTimeMs: 1451,
cacheDiscount: 0.00003,
fallbackCount: 1,
webSearchRequests: 2,
webSearchSources: [{ url: "https://example.com/research", title: "Research source" }],
steps: [
expect.objectContaining({
providerLatencyMs: 964,
inputTokens: 12_777,
cachedInputTokens: 2048,
routedProvider: "Alibaba",
serviceTier: "default",
webSearchRequests: 2,
webSearchSources: [{ url: "https://example.com/research", title: "Research source" }],
}),
],
});
});
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 });
});
});
describe("resolveAssistantContextWindowTelemetry", () => {
const current = {
requestId: "current",
provider: "openrouter",
modelId: "example/model",
startedAt: "2026-08-04T10:00:00.000Z",
contextLength: 128_000,
steps: [],
};
const previousMetadata = {
custom: {
telemetry: {
...current,
requestId: "previous",
completedAt: "2026-08-04T09:59:00.000Z",
contextUsed: 12_500,
},
},
};
test("keeps the previous completed reading while a matching request streams", () => {
expect(resolveAssistantContextWindowTelemetry(current, [previousMetadata])).toMatchObject({
source: "previous",
telemetry: { contextUsed: 12_500 },
});
});
test("prefers a current completed reading", () => {
expect(
resolveAssistantContextWindowTelemetry({ ...current, contextUsed: 13_000 }, [previousMetadata]),
).toMatchObject({ source: "current", telemetry: { contextUsed: 13_000 } });
});
test("does not mix readings from a model with another context capacity", () => {
expect(
resolveAssistantContextWindowTelemetry(current, [
{
custom: {
telemetry: {
...previousMetadata.custom.telemetry,
modelId: "another/model",
contextLength: 1_000_000,
},
},
},
]),
).toBeNull();
});
});
describe("getAssistantConversationUsage", () => {
test("aggregates unique request usage and keeps the latest reported context reading", () => {
const first = createUsageMetadata({
requestId: "first",
inputTokens: 100,
outputTokens: 25,
cost: 0.001,
contextUsed: 125,
});
const second = createUsageMetadata({ requestId: "second", inputTokens: 200, outputTokens: 50, cost: 0.002 });
expect(getAssistantConversationUsage([first, second, second, undefined])).toMatchObject({
inputTokens: 300,
outputTokens: 75,
totalTokens: 375,
cost: 0.003,
contextUsed: 125,
contextLength: 128_000,
turns: [{ requestId: "first" }, { requestId: "second" }],
});
});
test("falls back to step metrics when top-level usage is unavailable", () => {
const result = getAssistantConversationUsage([
{
custom: {
telemetry: {
requestId: "step-only",
provider: "openrouter",
modelId: "example/model",
startedAt: "2026-08-04T10:00:00.000Z",
steps: [
{
index: 1,
durationMs: 100,
modelDurationMs: 100,
toolDurationMs: 0,
inputTokens: 80,
outputTokens: 20,
cachedInputTokens: 10,
reasoningTokens: 5,
cost: 0.0004,
},
],
},
},
},
]);
expect(result).toMatchObject({
inputTokens: 80,
outputTokens: 20,
totalTokens: 100,
cachedInputTokens: 10,
reasoningTokens: 5,
cost: 0.0004,
});
});
});
describe("getAssistantContextBreakdown", () => {
test("combines the latest context reading with the latest reported token categories", () => {
const usage = getAssistantConversationUsage([
createUsageMetadata({
requestId: "reported",
inputTokens: 12_777,
outputTokens: 55,
cost: 0.00041,
contextUsed: 13_343,
}),
{
custom: {
telemetry: {
requestId: "streaming",
provider: "openrouter",
modelId: "example/model",
startedAt: "2026-08-04T10:00:02.000Z",
steps: [],
},
},
},
]);
expect(getAssistantContextBreakdown(usage)).toMatchObject({
contextUsed: 13_343,
contextLength: 128_000,
remaining: 114_657,
percentage: 10,
inputTokens: 12_777,
outputTokens: 55,
});
});
test("does not report an invalid percentage for an unavailable capacity", () => {
expect(
getAssistantContextBreakdown({
turns: [],
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
cost: 0,
contextUsed: 10,
contextLength: 0,
}),
).toEqual({});
});
});
@@ -0,0 +1,477 @@
import type { UIMessage } from "ai";
import { isRecord } from "@homarr/common";
export type AssistantUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cachedInputTokens?: number;
cacheWriteTokens?: number;
};
export type AssistantWebSearchSource = {
url: string;
title?: string;
};
export type AssistantRequestStep = {
index: number;
durationMs: number;
modelDurationMs: number;
toolDurationMs: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
providerLatencyMs?: number;
generationTimeMs?: number;
moderationLatencyMs?: number;
providerOutputTokensPerSecond?: number;
inputTokens?: number;
outputTokens?: number;
normalizedInputTokens?: number;
normalizedOutputTokens?: number;
cachedInputTokens?: number;
reasoningTokens?: number;
cost?: number;
upstreamCost?: number;
cacheDiscount?: number;
generationId?: string;
generationAccessToken?: string;
routedProvider?: string;
finishReason?: string;
nativeFinishReason?: string;
serviceTier?: string;
dataRegion?: string;
routerStrategy?: string;
routerRegion?: string;
fallbackCount?: number;
fallbackLatencyMs?: number;
webSearchRequests?: number;
webSearchSources?: AssistantWebSearchSource[];
isByok?: boolean;
streamed?: boolean;
cancelled?: boolean;
};
export type AssistantRequestTelemetry = {
requestId: string;
provider: string;
modelId: string;
startedAt: string;
completedAt?: string;
durationMs?: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
providerOutputTokensPerSecond?: number;
generationTimeMs?: number;
contextLength?: number;
contextUsed?: number;
contextUtilization?: number;
cost?: number;
upstreamCost?: number;
cacheDiscount?: number;
fallbackCount?: number;
fallbackLatencyMs?: number;
webSearchRequests?: number;
webSearchSources?: AssistantWebSearchSource[];
costType?: "reported" | "estimated";
finishReason?: string;
steps: AssistantRequestStep[];
};
export type AssistantMessageMetadata = {
usage?: AssistantUsage;
custom: {
telemetry: AssistantRequestTelemetry;
};
};
export type AssistantUIMessage = UIMessage<AssistantMessageMetadata>;
export type AssistantContextWindowTelemetry = {
telemetry: AssistantRequestTelemetry;
source: "current" | "previous";
};
export type AssistantConversationTurnUsage = {
requestId: string;
provider: string;
modelId: string;
startedAt: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
cachedInputTokens?: number;
reasoningTokens?: number;
cost?: number;
durationMs?: number;
contextUsed?: number;
contextLength?: number;
usage: AssistantUsage;
telemetry: AssistantRequestTelemetry;
};
export type AssistantConversationUsage = {
turns: AssistantConversationTurnUsage[];
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedInputTokens: number;
reasoningTokens: number;
cost: number;
contextUsed?: number;
contextLength?: number;
};
export type AssistantContextBreakdown = {
contextUsed?: number;
contextLength?: number;
remaining?: number;
percentage?: number;
inputTokens?: number;
outputTokens?: number;
cachedInputTokens?: number;
reasoningTokens?: number;
};
const getFiniteNonNegativeNumber = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
const getWebSearchSources = (value: unknown): AssistantWebSearchSource[] => {
if (!Array.isArray(value)) return [];
const sources = new Map<string, AssistantWebSearchSource>();
for (const item of value) {
if (!isRecord(item)) continue;
if (typeof item.url !== "string" || !URL.canParse(item.url)) continue;
const url = new URL(item.url);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) continue;
const title = typeof item.title === "string" ? item.title.trim().slice(0, 200) : "";
if (!sources.has(url.href)) sources.set(url.href, { url: url.href, ...(title ? { title } : {}) });
if (sources.size >= 12) break;
}
return [...sources.values()];
};
export const getAssistantUsage = (metadata: unknown): AssistantUsage | null => {
if (!isRecord(metadata) || !("usage" in metadata)) return null;
const usage = metadata.usage;
if (!isRecord(usage)) return null;
const value = usage;
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 (!isRecord(metadata)) return null;
const custom = metadata.custom;
if (!isRecord(custom)) return null;
const telemetry = custom.telemetry;
if (!isRecord(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 (!isRecord(step)) return [];
const candidate = step;
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.providerLatencyMs) !== undefined
? { providerLatencyMs: getFiniteNonNegativeNumber(candidate.providerLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.generationTimeMs) !== undefined
? { generationTimeMs: getFiniteNonNegativeNumber(candidate.generationTimeMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.moderationLatencyMs) !== undefined
? { moderationLatencyMs: getFiniteNonNegativeNumber(candidate.moderationLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.providerOutputTokensPerSecond) !== undefined
? { providerOutputTokensPerSecond: getFiniteNonNegativeNumber(candidate.providerOutputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(candidate.inputTokens) !== undefined
? { inputTokens: getFiniteNonNegativeNumber(candidate.inputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.outputTokens) !== undefined
? { outputTokens: getFiniteNonNegativeNumber(candidate.outputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.normalizedInputTokens) !== undefined
? { normalizedInputTokens: getFiniteNonNegativeNumber(candidate.normalizedInputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.normalizedOutputTokens) !== undefined
? { normalizedOutputTokens: getFiniteNonNegativeNumber(candidate.normalizedOutputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cachedInputTokens) !== undefined
? { cachedInputTokens: getFiniteNonNegativeNumber(candidate.cachedInputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.reasoningTokens) !== undefined
? { reasoningTokens: getFiniteNonNegativeNumber(candidate.reasoningTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cost) !== undefined
? { cost: getFiniteNonNegativeNumber(candidate.cost) }
: {}),
...(getFiniteNonNegativeNumber(candidate.upstreamCost) !== undefined
? { upstreamCost: getFiniteNonNegativeNumber(candidate.upstreamCost) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cacheDiscount) !== undefined
? { cacheDiscount: getFiniteNonNegativeNumber(candidate.cacheDiscount) }
: {}),
...(typeof candidate.generationId === "string" ? { generationId: candidate.generationId } : {}),
...(typeof candidate.generationAccessToken === "string"
? { generationAccessToken: candidate.generationAccessToken }
: {}),
...(typeof candidate.routedProvider === "string" ? { routedProvider: candidate.routedProvider } : {}),
...(typeof candidate.finishReason === "string" ? { finishReason: candidate.finishReason } : {}),
...(typeof candidate.nativeFinishReason === "string"
? { nativeFinishReason: candidate.nativeFinishReason }
: {}),
...(typeof candidate.serviceTier === "string" ? { serviceTier: candidate.serviceTier } : {}),
...(typeof candidate.dataRegion === "string" ? { dataRegion: candidate.dataRegion } : {}),
...(typeof candidate.routerStrategy === "string" ? { routerStrategy: candidate.routerStrategy } : {}),
...(typeof candidate.routerRegion === "string" ? { routerRegion: candidate.routerRegion } : {}),
...(getFiniteNonNegativeNumber(candidate.fallbackCount) !== undefined
? { fallbackCount: getFiniteNonNegativeNumber(candidate.fallbackCount) }
: {}),
...(getFiniteNonNegativeNumber(candidate.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: getFiniteNonNegativeNumber(candidate.fallbackLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.webSearchRequests) !== undefined
? { webSearchRequests: getFiniteNonNegativeNumber(candidate.webSearchRequests) }
: {}),
...(getWebSearchSources(candidate.webSearchSources).length > 0
? { webSearchSources: getWebSearchSources(candidate.webSearchSources) }
: {}),
...(typeof candidate.isByok === "boolean" ? { isByok: candidate.isByok } : {}),
...(typeof candidate.streamed === "boolean" ? { streamed: candidate.streamed } : {}),
...(typeof candidate.cancelled === "boolean" ? { cancelled: candidate.cancelled } : {}),
},
];
})
: [];
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.providerOutputTokensPerSecond) !== undefined
? { providerOutputTokensPerSecond: getFiniteNonNegativeNumber(value.providerOutputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(value.generationTimeMs) !== undefined
? { generationTimeMs: getFiniteNonNegativeNumber(value.generationTimeMs) }
: {}),
...(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) }
: {}),
...(getFiniteNonNegativeNumber(value.cacheDiscount) !== undefined
? { cacheDiscount: getFiniteNonNegativeNumber(value.cacheDiscount) }
: {}),
...(getFiniteNonNegativeNumber(value.fallbackCount) !== undefined
? { fallbackCount: getFiniteNonNegativeNumber(value.fallbackCount) }
: {}),
...(getFiniteNonNegativeNumber(value.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: getFiniteNonNegativeNumber(value.fallbackLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(value.webSearchRequests) !== undefined
? { webSearchRequests: getFiniteNonNegativeNumber(value.webSearchRequests) }
: {}),
...(getWebSearchSources(value.webSearchSources).length > 0
? { webSearchSources: getWebSearchSources(value.webSearchSources) }
: {}),
...(value.costType === "reported" || value.costType === "estimated" ? { costType: value.costType } : {}),
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
};
};
const sumReportedStepMetric = (
steps: AssistantRequestStep[],
getValue: (step: AssistantRequestStep) => number | undefined,
) => {
const values = steps.flatMap((step) => {
const value = getValue(step);
return value === undefined ? [] : [value];
});
return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) : undefined;
};
/**
* Produces a stable conversation-level usage snapshot from persisted message metadata.
*
* A newly-created or streaming message can temporarily have no usage metadata. Keeping
* the latest complete context reading from earlier turns avoids flashing the composer
* meter back to zero while the next request starts or when its popover is reopened.
*/
export const getAssistantConversationUsage = (messageMetadata: unknown[]): AssistantConversationUsage => {
const turnsByRequestId = new Map<string, AssistantConversationTurnUsage>();
for (const metadata of messageMetadata) {
const telemetry = getAssistantTelemetry(metadata);
if (!telemetry) continue;
const usage = getAssistantUsage(metadata);
const inputTokens = usage?.inputTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.inputTokens);
const outputTokens = usage?.outputTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.outputTokens);
const totalTokens =
usage?.totalTokens ??
(inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
const cachedInputTokens =
usage?.cachedInputTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.cachedInputTokens);
const reasoningTokens =
usage?.reasoningTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.reasoningTokens);
const cost = telemetry.cost ?? sumReportedStepMetric(telemetry.steps, (step) => step.cost);
turnsByRequestId.set(telemetry.requestId, {
requestId: telemetry.requestId,
provider: telemetry.provider,
modelId: telemetry.modelId,
startedAt: telemetry.startedAt,
...(inputTokens !== undefined ? { inputTokens } : {}),
...(outputTokens !== undefined ? { outputTokens } : {}),
...(totalTokens !== undefined ? { totalTokens } : {}),
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
...(cost !== undefined ? { cost } : {}),
...(telemetry.durationMs !== undefined ? { durationMs: telemetry.durationMs } : {}),
...(telemetry.contextUsed !== undefined ? { contextUsed: telemetry.contextUsed } : {}),
...(telemetry.contextLength !== undefined ? { contextLength: telemetry.contextLength } : {}),
usage: usage ?? {},
telemetry,
});
}
const turns = [...turnsByRequestId.values()];
const latestContextTurn = turns.findLast(
(turn) => turn.contextUsed !== undefined && turn.contextLength !== undefined,
);
const sum = (getValue: (turn: AssistantConversationTurnUsage) => number | undefined) =>
turns.reduce((total, turn) => total + (getValue(turn) ?? 0), 0);
return {
turns,
inputTokens: sum((turn) => turn.inputTokens),
outputTokens: sum((turn) => turn.outputTokens),
totalTokens: sum((turn) => turn.totalTokens),
cachedInputTokens: sum((turn) => turn.cachedInputTokens),
reasoningTokens: sum((turn) => turn.reasoningTokens),
cost: sum((turn) => turn.cost),
...(latestContextTurn?.contextUsed !== undefined ? { contextUsed: latestContextTurn.contextUsed } : {}),
...(latestContextTurn?.contextLength !== undefined ? { contextLength: latestContextTurn.contextLength } : {}),
};
};
export const getAssistantContextBreakdown = (usage: AssistantConversationUsage): AssistantContextBreakdown => {
const latestTokenTurn = usage.turns.findLast(
(turn) =>
turn.inputTokens !== undefined ||
turn.outputTokens !== undefined ||
turn.cachedInputTokens !== undefined ||
turn.reasoningTokens !== undefined,
);
const { contextUsed, contextLength } = usage;
const hasContext = contextUsed !== undefined && contextLength !== undefined && contextLength > 0;
return {
...(hasContext
? {
contextUsed,
contextLength,
remaining: Math.max(0, contextLength - contextUsed),
percentage: Math.min(100, Math.round((contextUsed / contextLength) * 100)),
}
: {}),
...(latestTokenTurn?.inputTokens !== undefined ? { inputTokens: latestTokenTurn.inputTokens } : {}),
...(latestTokenTurn?.outputTokens !== undefined ? { outputTokens: latestTokenTurn.outputTokens } : {}),
...(latestTokenTurn?.cachedInputTokens !== undefined
? { cachedInputTokens: latestTokenTurn.cachedInputTokens }
: {}),
...(latestTokenTurn?.reasoningTokens !== undefined ? { reasoningTokens: latestTokenTurn.reasoningTokens } : {}),
};
};
export const resolveAssistantContextWindowTelemetry = (
current: AssistantRequestTelemetry,
previousMetadata: unknown[],
): AssistantContextWindowTelemetry | null => {
if (current.contextLength !== undefined && current.contextUsed !== undefined) {
return { telemetry: current, source: "current" };
}
for (const metadata of previousMetadata.toReversed()) {
const previous = getAssistantTelemetry(metadata);
if (
previous?.completedAt &&
previous.requestId !== current.requestId &&
previous.modelId === current.modelId &&
previous.contextLength === current.contextLength &&
previous.contextUsed !== undefined
) {
return { telemetry: previous, source: "previous" };
}
}
return null;
};
@@ -0,0 +1,94 @@
import { describe, expect, test } from "vitest";
import type { ThreadMessage } from "@assistant-ui/react";
import {
getSuccessfulApprovedAssistantMutationIds,
updateAssistantMutationRefreshState,
} from "./assistant-mutation-refresh";
const assistantMessage = (...content: ThreadMessage["content"]): ThreadMessage =>
({
id: "assistant-message",
role: "assistant",
content,
status: { type: "complete", reason: "stop" },
createdAt: new Date(),
metadata: { custom: {} },
}) as ThreadMessage;
describe("assistant mutation refresh", () => {
test("returns only completed, approved mutation calls", () => {
expect(
getSuccessfulApprovedAssistantMutationIds([
assistantMessage(
{
type: "tool-call",
toolCallId: "approved-1",
toolName: "app_create",
args: { name: "Wikipedia" },
argsText: "{}",
approval: { id: "approval-1", approved: true },
result: { id: "app-1" },
},
{
type: "tool-call",
toolCallId: "read-1",
toolName: "app_getAll",
args: {},
argsText: "{}",
result: [],
},
{
type: "tool-call",
toolCallId: "pending-1",
toolName: "board_savePartialBoardSettings",
args: { id: "board-1" },
argsText: "{}",
approval: { id: "approval-2", approved: true },
},
),
]),
).toEqual(["approved-1"]);
});
test("ignores denied and failed mutations", () => {
expect(
getSuccessfulApprovedAssistantMutationIds([
assistantMessage(
{
type: "tool-call",
toolCallId: "denied-1",
toolName: "app_create",
args: {},
argsText: "{}",
approval: { id: "approval-1", approved: false },
result: null,
},
{
type: "tool-call",
toolCallId: "failed-1",
toolName: "app_create",
args: {},
argsText: "{}",
approval: { id: "approval-2", approved: true },
result: { error: "Creation failed" },
},
),
]),
).toEqual([]);
});
test("baselines loaded history, refreshes once for new calls, and baselines a switched conversation", () => {
const initial = updateAssistantMutationRefreshState({ conversationId: null, toolCallIds: new Set() }, "thread-1", [
"historical-1",
]);
expect(initial.shouldRefresh).toBe(false);
const mutation = updateAssistantMutationRefreshState(initial.state, "thread-1", ["historical-1", "new-1", "new-2"]);
expect(mutation.shouldRefresh).toBe(true);
expect(updateAssistantMutationRefreshState(mutation.state, "thread-1", ["new-1", "new-2"]).shouldRefresh).toBe(
false,
);
expect(updateAssistantMutationRefreshState(mutation.state, "thread-2", ["historical-2"]).shouldRefresh).toBe(false);
});
});
@@ -0,0 +1,46 @@
import type { ThreadMessage } from "@assistant-ui/react";
const resultHasError = (result: unknown) => typeof result === "object" && result !== null && "error" in result;
export const getSuccessfulApprovedAssistantMutationIds = (messages: readonly ThreadMessage[]) =>
messages.flatMap((message) =>
message.role === "assistant"
? message.content.flatMap((part) =>
part.type === "tool-call" &&
part.approval?.approved === true &&
part.result !== undefined &&
part.isError !== true &&
!resultHasError(part.result)
? [part.toolCallId]
: [],
)
: [],
);
export interface AssistantMutationRefreshState {
conversationId: string | null;
toolCallIds: Set<string>;
}
export const updateAssistantMutationRefreshState = (
state: AssistantMutationRefreshState,
conversationId: string,
successfulMutationIds: readonly string[],
) => {
if (state.conversationId !== conversationId) {
return {
state: { conversationId, toolCallIds: new Set(successfulMutationIds) },
shouldRefresh: false,
};
}
const toolCallIds = new Set(state.toolCallIds);
let shouldRefresh = false;
for (const toolCallId of successfulMutationIds) {
if (toolCallIds.has(toolCallId)) continue;
toolCallIds.add(toolCallId);
shouldRefresh = true;
}
return { state: { conversationId, toolCallIds }, shouldRefresh };
};
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import { initialAssistantNotificationState, updateAssistantNotificationState } from "./assistant-notifications";
describe("updateAssistantNotificationState", () => {
test("baselines the current response when notifications initialize", () => {
const update = updateAssistantNotificationState(initialAssistantNotificationState, {
conversationId: "conversation-a",
notificationKey: "response-a:complete",
});
expect(update.shouldNotify).toBe(false);
expect(update.state).toEqual({
initialized: true,
conversationId: "conversation-a",
notificationKey: "response-a:complete",
});
});
test("notifies when a response completes in the active conversation", () => {
const update = updateAssistantNotificationState(
{
initialized: true,
conversationId: "conversation-a",
notificationKey: null,
},
{
conversationId: "conversation-a",
notificationKey: "response-a:complete",
},
);
expect(update.shouldNotify).toBe(true);
});
test("does not count existing history when the selected conversation changes", () => {
const update = updateAssistantNotificationState(
{
initialized: true,
conversationId: "conversation-a",
notificationKey: "response-a:complete",
},
{
conversationId: "conversation-b",
notificationKey: "response-b:complete",
},
);
expect(update.shouldNotify).toBe(false);
expect(update.state.notificationKey).toBe("response-b:complete");
});
test("notifies for the next completion after switching conversations", () => {
const switched = updateAssistantNotificationState(
{
initialized: true,
conversationId: "conversation-a",
notificationKey: "response-a:complete",
},
{
conversationId: "conversation-b",
notificationKey: "response-b:complete",
},
);
const completed = updateAssistantNotificationState(switched.state, {
conversationId: "conversation-b",
notificationKey: "response-c:complete",
});
expect(completed.shouldNotify).toBe(true);
});
test("ignores running and repeated response states", () => {
const previous = {
initialized: true,
conversationId: "conversation-a",
notificationKey: "response-a:complete",
};
expect(
updateAssistantNotificationState(previous, {
conversationId: "conversation-a",
notificationKey: null,
}).shouldNotify,
).toBe(false);
expect(
updateAssistantNotificationState(previous, {
conversationId: "conversation-a",
notificationKey: "response-a:complete",
}).shouldNotify,
).toBe(false);
});
});
@@ -0,0 +1,32 @@
export interface AssistantNotificationState {
initialized: boolean;
conversationId: string | null;
notificationKey: string | null;
}
export interface AssistantNotificationUpdate {
state: AssistantNotificationState;
shouldNotify: boolean;
}
export const initialAssistantNotificationState: AssistantNotificationState = {
initialized: false,
conversationId: null,
notificationKey: null,
};
export const updateAssistantNotificationState = (
previous: AssistantNotificationState,
current: Pick<AssistantNotificationState, "conversationId" | "notificationKey">,
): AssistantNotificationUpdate => {
const state = { initialized: true, ...current };
if (!previous.initialized || previous.conversationId !== current.conversationId) {
return { state, shouldNotify: false };
}
return {
state,
shouldNotify: current.notificationKey !== null && current.notificationKey !== previous.notificationKey,
};
};
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { ThreadPrimitive } from "@assistant-ui/react";
import { ActionIcon, Box, Button, Popover, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import {
IconActivityHeartbeat,
IconApps,
IconPalette,
IconRefresh,
IconRobot,
IconSearch,
IconShield,
IconShieldCheck,
} from "@tabler/icons-react";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { useAssistantAutoApproval } from "./assistant-auto-approval";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
const compactSuggestionVars = () => ({
root: {
"--button-fz": "calc(var(--mantine-font-size-sm) * var(--board-canvas-ui-scale, 1))",
"--button-bg": "rgb(from var(--mantine-color-default) r g b / var(--opacity, 1))",
"--button-hover": "rgb(from var(--mantine-color-default-hover) r g b / var(--opacity, 1))",
"--button-bd": "1px solid rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1))",
},
});
export const AutoApprovalControl = ({ compact = false }: { compact?: boolean }) => {
const t = useI18n("assistant.autoApproval");
const [opened, setOpened] = useState(false);
const { enabled, setEnabled } = useAssistantAutoApproval();
return (
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
{compact ? (
<ActionIcon
className={classes.composerApproval}
variant={enabled ? "light" : "subtle"}
color={enabled ? "green" : "gray"}
size="sm"
onClick={() => setOpened((current) => !current)}
aria-label={enabled ? t("enabled") : t("label")}
aria-pressed={enabled}
title={enabled ? t("enabled") : t("label")}
>
{enabled ? <IconShieldCheck size={18} /> : <IconShield size={18} />}
</ActionIcon>
) : (
<ActionIcon
className={classes.panelAction}
variant={enabled ? "light" : "subtle"}
color={enabled ? "green" : "gray"}
onClick={() => setOpened((current) => !current)}
aria-label={t("label")}
aria-pressed={enabled}
title={enabled ? t("enabled") : t("label")}
>
<IconShieldCheck size={17} />
</ActionIcon>
)}
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" mt={3}>
{t("description")}
</Text>
</Box>
<Button
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
leftSection={<IconShieldCheck size={16} />}
onClick={() => {
setEnabled(!enabled);
setOpened(false);
}}
>
{enabled ? t("disable") : t("enable")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
export const ViewRefreshAction = ({
isRefreshing,
onRefresh,
}: Pick<AssistantConversationControls, "isRefreshing" | "onRefresh">) => {
const t = useI18n("assistant.refresh");
const refresh = async () => {
try {
await onRefresh();
showSuccessNotification({ title: t("completeTitle"), message: t("completeDescription") });
} catch {
showErrorNotification({ title: t("failedTitle"), message: t("failedDescription") });
}
};
return (
<Tooltip label={isRefreshing ? t("working") : t("action")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
loading={isRefreshing}
loaderProps={{ type: "bars" }}
onClick={() => void refresh()}
aria-label={isRefreshing ? t("working") : t("action")}
>
<IconRefresh size={17} />
</ActionIcon>
</Tooltip>
);
};
export const EmptyThread = ({ compact = false }: { compact?: boolean }) => {
const t = useI18n("assistant");
if (compact) {
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty} data-compact>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send clearComposer asChild>
<Button
variant="default"
size="sm"
className={classes.compactSuggestion}
vars={compactSuggestionVars}
leftSection={<IconActivityHeartbeat size="1em" />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send clearComposer asChild>
<Button
variant="default"
size="sm"
className={classes.compactSuggestion}
vars={compactSuggestionVars}
leftSection={<IconApps size="1em" />}
>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send clearComposer asChild>
<Button
variant="default"
size="sm"
className={classes.compactSuggestion}
vars={compactSuggestionVars}
leftSection={<IconSearch size="1em" />}
>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send clearComposer asChild>
<Button
variant="default"
size="sm"
className={classes.compactSuggestion}
vars={compactSuggestionVars}
leftSection={<IconPalette size="1em" />}
>
{t("suggestions.style.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Box>
</ThreadPrimitive.Empty>
);
}
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430} ta="center">
<ThemeIcon size={52} radius="xl" variant="light">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" className={classes.emptyDescription}>
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,733 @@
"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>
)}
</>
);
};
@@ -0,0 +1,121 @@
import { describe, expect, test } from "vitest";
import type { ThreadMessage } from "@assistant-ui/react";
import { getPendingAssistantAction } from "./assistant-pending-action";
const assistantMessage = (...content: ThreadMessage["content"]): ThreadMessage =>
({
id: "assistant-message",
role: "assistant",
content,
status: { type: "requires-action", reason: "tool-calls" },
createdAt: new Date(),
metadata: { custom: {} },
}) as ThreadMessage;
describe("getPendingAssistantAction", () => {
test("exposes an unanswered structured question", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "ask-1",
toolName: "ask_user",
args: { question: "Which board should receive the widget?" },
argsText: "{}",
}),
),
).toEqual({
kind: "question",
toolName: "ask_user",
detail: "Which board should receive the widget?",
});
});
test("clears a human action after its result is supplied", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "ask-1",
toolName: "ask_user",
args: { question: "Continue?" },
argsText: "{}",
result: { answer: "Yes" },
}),
),
).toBeUndefined();
});
test("exposes the app form while its details still need review", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "configure-1",
toolName: "configure_app",
args: { name: "Wikipedia" },
argsText: "{}",
}),
),
).toEqual({ kind: "form", toolName: "configure_app", detail: "Wikipedia" });
});
test("exposes the board settings form while changes still need review", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "settings-1",
toolName: "configure_board_settings",
args: { boardId: "board-1", boardName: "Home", changes: { customCss: ".item {}" } },
argsText: "{}",
}),
),
).toEqual({ kind: "form", toolName: "configure_board_settings", detail: "Home" });
});
test("exposes the native widget form while configuration still needs review", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "widget-1",
toolName: "configure_widget",
args: { boardId: "board-1", boardName: "Home", kind: "notebook" },
argsText: "{}",
}),
),
).toEqual({ kind: "form", toolName: "configure_widget", detail: "Home" });
});
test("exposes an unresolved mutation approval", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "create-1",
toolName: "app_create",
args: { name: "Wikipedia" },
argsText: "{}",
approval: { id: "approval-1" },
}),
),
).toEqual({ kind: "approval", toolName: "app_create", detail: "Wikipedia" });
});
test("clears an approval after the user responds", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "create-1",
toolName: "app_create",
args: { name: "Wikipedia" },
argsText: "{}",
approval: { id: "approval-1", approved: true },
}),
),
).toBeUndefined();
});
});
@@ -0,0 +1,54 @@
import type { ThreadMessage } from "@assistant-ui/react";
export type AssistantPendingAction = {
kind: "question" | "form" | "approval";
toolName: string;
detail?: string;
};
const humanToolKinds = {
ask_user: "question",
configure_app: "form",
configure_board_settings: "form",
configure_widget: "form",
} as const;
const getStringArg = (args: unknown, key: string) => {
if (typeof args !== "object" || args === null || !(key in args)) return undefined;
const value = (args as Record<string, unknown>)[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
};
export const getPendingAssistantAction = (message: ThreadMessage | undefined): AssistantPendingAction | undefined => {
if (message?.role !== "assistant") return undefined;
for (const part of message.content.toReversed()) {
if (part.type !== "tool-call" || part.isError) continue;
if (part.approval && part.approval.approved === undefined && !part.approval.resolution) {
return {
kind: "approval",
toolName: part.toolName,
detail: getStringArg(part.args, "name"),
};
}
const kind = humanToolKinds[part.toolName as keyof typeof humanToolKinds];
if (kind === undefined || part.result !== undefined) continue;
return {
kind,
toolName: part.toolName,
detail: getStringArg(
part.args,
kind === "question"
? "question"
: part.toolName === "configure_board_settings" || part.toolName === "configure_widget"
? "boardName"
: "name",
),
};
}
return undefined;
};
@@ -0,0 +1,131 @@
import { describe, expect, test } from "vitest";
import {
resolveAssistantPreferenceModelId,
resolveAssistantReasoningDisplayMode,
resolveAssistantThreadPreferenceModelId,
} from "./assistant-preferences";
const models = [
{
id: "provider/default",
name: "Default",
description: null,
contextLength: null,
promptPrice: null,
completionPrice: null,
inputModalities: ["text"],
},
{
id: "provider/alternate",
name: "Alternate",
description: null,
contextLength: null,
promptPrice: null,
completionPrice: null,
inputModalities: ["text"],
},
];
describe("resolveAssistantPreferenceModelId", () => {
test("uses the server default when preferences first load", () => {
expect(
resolveAssistantPreferenceModelId({
currentModelId: null,
previousDefaultModelId: undefined,
defaultModelId: "provider/default",
models,
}),
).toBe("provider/default");
});
test("adopts a changed server default even when the old model remains available", () => {
expect(
resolveAssistantPreferenceModelId({
currentModelId: "provider/default",
previousDefaultModelId: "provider/default",
defaultModelId: "provider/alternate",
models,
}),
).toBe("provider/alternate");
});
test("preserves an available conversation selection while the default is unchanged", () => {
expect(
resolveAssistantPreferenceModelId({
currentModelId: "provider/alternate",
previousDefaultModelId: "provider/default",
defaultModelId: "provider/default",
models,
}),
).toBe("provider/alternate");
});
test("falls back to the default when the selected model is no longer available", () => {
expect(
resolveAssistantPreferenceModelId({
currentModelId: "provider/removed",
previousDefaultModelId: "provider/default",
defaultModelId: "provider/default",
models,
}),
).toBe("provider/default");
});
});
describe("resolveAssistantThreadPreferenceModelId", () => {
test("waits for an existing remote conversation's metadata", () => {
expect(
resolveAssistantThreadPreferenceModelId({
isRemote: true,
metadataLoaded: false,
threadModelId: undefined,
defaultModelId: "provider/default",
models,
}),
).toBeUndefined();
});
test("uses the default immediately for a new local conversation", () => {
expect(
resolveAssistantThreadPreferenceModelId({
isRemote: false,
metadataLoaded: false,
threadModelId: undefined,
defaultModelId: "provider/default",
models,
}),
).toBe("provider/default");
});
test("restores a valid persisted conversation model", () => {
expect(
resolveAssistantThreadPreferenceModelId({
isRemote: true,
metadataLoaded: true,
threadModelId: "provider/alternate",
defaultModelId: "provider/default",
models,
}),
).toBe("provider/alternate");
});
});
describe("resolveAssistantReasoningDisplayMode", () => {
test("shows Max for the OpenRouter GPT-6 Luna route enforced by the server", () => {
expect(
resolveAssistantReasoningDisplayMode({
provider: "openrouter",
modelId: "openai/gpt-6-luna",
reasoning: "auto",
}),
).toBe("max");
});
test.each([
{ provider: "openrouter" as const, modelId: "openai/gpt-5.6-luna", reasoning: "auto" as const },
{ provider: "openai" as const, modelId: "openai/gpt-6-luna", reasoning: "high" as const },
])("preserves the selected mode outside the forced route: %s", (input) => {
expect(resolveAssistantReasoningDisplayMode(input)).toBe(input.reasoning);
});
});
@@ -0,0 +1,60 @@
import type { AssistantProvider, AssistantReasoningMode } from "@homarr/definitions";
export type { AssistantReasoningMode } from "@homarr/definitions";
export type AssistantRuntimeModelOption = {
id: string;
name: string;
description: string | null;
contextLength: number | null;
promptPrice: string | null;
completionPrice: string | null;
inputModalities: string[];
};
export const resolveAssistantReasoningDisplayMode = ({
provider,
modelId,
reasoning,
}: {
provider: AssistantProvider | null;
modelId: string | null;
reasoning: AssistantReasoningMode;
}): AssistantReasoningMode | "max" => {
if (provider === "openrouter" && modelId === "openai/gpt-6-luna") return "max";
return reasoning;
};
export const resolveAssistantPreferenceModelId = ({
currentModelId,
previousDefaultModelId,
defaultModelId,
models,
}: {
currentModelId: string | null;
previousDefaultModelId: string | null | undefined;
defaultModelId: string | null;
models: readonly AssistantRuntimeModelOption[];
}) => {
if (previousDefaultModelId !== defaultModelId) return defaultModelId;
if (currentModelId && models.some((model) => model.id === currentModelId)) return currentModelId;
return defaultModelId;
};
export const resolveAssistantThreadPreferenceModelId = ({
isRemote,
metadataLoaded,
threadModelId,
defaultModelId,
models,
}: {
isRemote: boolean;
metadataLoaded: boolean;
threadModelId: unknown;
defaultModelId: string | null;
models: readonly AssistantRuntimeModelOption[];
}) => {
if (isRemote && !metadataLoaded) return undefined;
if (typeof threadModelId === "string" && models.some((model) => model.id === threadModelId)) return threadModelId;
return defaultModelId;
};
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import {
getAssistantProviderQuotaLevel,
getAssistantProviderQuotaRefreshDelay,
isAssistantProviderUnavailable,
} from "./assistant-provider-quota";
describe("getAssistantProviderQuotaLevel", () => {
test.each([
[{ limit: 50, remaining: 50 }, "ok"],
[{ limit: 50, remaining: 26 }, "ok"],
[{ limit: 50, remaining: 25 }, "warning"],
[{ limit: 50, remaining: 10 }, "bad"],
[{ limit: 50, remaining: 0 }, "dead"],
[{ limit: 0, remaining: 0 }, "dead"],
] as const)("maps %o to %s", (usage, expected) => {
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
});
});
describe("isAssistantProviderUnavailable", () => {
test("blocks only unavailable Homarr provider sessions", () => {
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: false, remaining: undefined })).toBe(true);
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: 0 })).toBe(true);
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: undefined })).toBe(false);
expect(isAssistantProviderUnavailable({ provider: "openrouter", signedIn: false, remaining: 0 })).toBe(false);
});
});
describe("getAssistantProviderQuotaRefreshDelay", () => {
test("refreshes just after the UTC reset, or immediately when it has passed", () => {
const reset = "2026-08-12T00:00:00.000Z";
expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-11T23:59:00.000Z"))).toBe(61_000);
expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-12T00:00:01.000Z"))).toBe(1_000);
});
});
@@ -0,0 +1,27 @@
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
import type { AssistantProvider } from "@homarr/definitions";
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
export const getAssistantProviderQuotaRefreshDelay = (resetsAt: string, now = Date.now()) =>
Math.max(0, Date.parse(resetsAt) - now) + 1_000;
export const getAssistantProviderQuotaLevel = (
usage: Pick<WorkshopAssistantUsage, "limit" | "remaining">,
): AssistantProviderQuotaLevel => {
if (usage.remaining === 0 || usage.limit === 0) return "dead";
const remainingRatio = usage.remaining / usage.limit;
if (remainingRatio <= 0.2) return "bad";
if (remainingRatio <= 0.5) return "warning";
return "ok";
};
export const isAssistantProviderUnavailable = ({
provider,
signedIn,
remaining,
}: {
provider: AssistantProvider | null;
signedIn: boolean;
remaining: number | undefined;
}) => provider === "homarr" && (!signedIn || remaining === 0);
@@ -0,0 +1,964 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type {
AttachmentAdapter,
MessageFormatAdapter,
RemoteThreadListAdapter,
SpeechSynthesisAdapter,
ThreadHistoryAdapter,
ThreadMessage,
Toolkit,
} from "@assistant-ui/react";
import {
defineToolkit,
useAui,
useAuiEvent,
useAuiState,
useRemoteThreadListRuntime,
WebSpeechSynthesisAdapter,
} from "@assistant-ui/react";
import { useChat } from "@ai-sdk/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
import { clientApi, fetchApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { assistantHomarrProviderTokenHeader, hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { closeSpotlight } from "@homarr/spotlight/store";
import { useI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
import { createWorkshopClient } from "~/components/workshop/workshop-client";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { getAssistantProviderQuotaRefreshDelay, isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { getRunningAssistantPartType } from "./assistant-activity-state";
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
import { AssistantPanel } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
import {
AssistantComposerRuntimeProvider,
AssistantComposerSurfaceBoundary,
AssistantRunFocusPreserver,
AssistantRuntimeProviderWithTools,
} from "./assistant-runtime-provider";
import { sendAssistantPrompt as sendPromptThroughRuntime } from "./assistant-send";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
import { AssistantConfigureWidgetTool } from "./assistant-widget-tool";
import type { AssistantUIMessage } from "./assistant-message-metadata";
import {
getSuccessfulApprovedAssistantMutationIds,
updateAssistantMutationRefreshState,
} from "./assistant-mutation-refresh";
import { initialAssistantNotificationState, updateAssistantNotificationState } from "./assistant-notifications";
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
import { AssistantBoardWidget } from "./assistant-widget";
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 => {
return {
accept: [...(allowImages ? assistantImageAttachmentTypes : []), ...assistantDocumentAttachmentTypes].join(","),
async add({ file }) {
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 = createId();
return {
id,
type: isImage ? "image" : "document",
name: file.name,
file,
contentType: file.type,
status: { type: "requires-action", reason: "composer-send" },
};
},
async send(attachment) {
return {
...attachment,
status: { type: "complete" },
content: [
{
type: "file",
filename: attachment.name,
mimeType: attachment.contentType ?? "application/octet-stream",
data: await fileToDataUrlAsync(attachment.file),
},
],
};
},
async remove() {},
};
};
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
const t = useI18n("assistant");
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
staleTime: 10 * 60_000,
});
const [modelId, setModelIdState] = useState<string | null>(null);
const [reasoning, setReasoningState] = useState<AssistantReasoningMode>("auto");
const workshopClient = useMemo(createWorkshopClient, []);
const [providerUser, setProviderUser] = useState(workshopClient.currentUser);
const [quota, setQuota] = useState<Awaited<ReturnType<typeof workshopClient.getAssistantUsage>> | null>(null);
const [quotaLoading, setQuotaLoading] = useState(false);
const [quotaError, setQuotaError] = useState<string | null>(null);
const quotaRequestRef = useRef<AbortController | null>(null);
const previousDefaultModelIdRef = useRef<string | null | undefined>(undefined);
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
modelId: null,
reasoning: "auto",
});
const models = useMemo<AssistantRuntimeModelOption[]>(
() =>
data?.models.map(({ id, name, description, contextLength, promptPrice, completionPrice, inputModalities }) => ({
id,
name,
description,
contextLength,
promptPrice,
completionPrice,
inputModalities,
})) ?? [],
[data?.models],
);
const setModelId = useCallback((nextModelId: string) => {
preferencesRef.current.modelId = nextModelId;
setModelIdState(nextModelId);
}, []);
const setReasoning = useCallback((nextReasoning: AssistantReasoningMode) => {
preferencesRef.current.reasoning = nextReasoning;
setReasoningState(nextReasoning);
}, []);
const getRequestBody = useCallback(() => {
const current = preferencesRef.current;
return {
...(current.modelId ? { modelId: current.modelId } : {}),
reasoning: current.reasoning,
...(typeof window === "undefined"
? {}
: {
clientContext: {
pathname: window.location.pathname,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
}),
};
}, []);
const getRequestHeaders = useCallback((): Record<string, string> => {
if (data?.provider !== "homarr") return {};
const token = workshopClient.authToken;
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
}, [data?.provider, workshopClient]);
const refreshQuota = useCallback(async () => {
quotaRequestRef.current?.abort();
if (data?.provider !== "homarr" || !workshopClient.authToken) {
quotaRequestRef.current = null;
setQuota(null);
setQuotaError(null);
setQuotaLoading(false);
return;
}
const controller = new AbortController();
quotaRequestRef.current = controller;
setQuotaLoading(true);
setQuotaError(null);
try {
const nextQuota = await workshopClient.getAssistantUsage(controller.signal);
if (quotaRequestRef.current === controller && !controller.signal.aborted) setQuota(nextQuota);
} catch {
if (quotaRequestRef.current === controller && !controller.signal.aborted) {
setQuotaError(t("providerQuota.loadError"));
}
} finally {
if (quotaRequestRef.current === controller) {
quotaRequestRef.current = null;
setQuotaLoading(false);
}
}
}, [data?.provider, t, workshopClient]);
const signInToProvider = useCallback(async () => {
setQuotaLoading(true);
setQuotaError(null);
try {
const user = await workshopClient.signInWithGitHub();
setProviderUser(user);
await refreshQuota();
} catch (error) {
setQuotaError(t("providerQuota.signInError"));
throw error;
} finally {
setQuotaLoading(false);
}
}, [refreshQuota, t, workshopClient]);
useEffect(() => {
if (!data) return;
const nextModelId = resolveAssistantPreferenceModelId({
currentModelId: preferencesRef.current.modelId,
previousDefaultModelId: previousDefaultModelIdRef.current,
defaultModelId: data.defaultModelId,
models: data.models,
});
previousDefaultModelIdRef.current = data.defaultModelId;
if (nextModelId === preferencesRef.current.modelId) return;
preferencesRef.current.modelId = nextModelId;
setModelIdState(nextModelId);
}, [data]);
useEffect(() => {
if (data?.provider !== "homarr") {
setProviderUser(null);
setQuota(null);
setQuotaError(null);
return;
}
const unsubscribe = workshopClient.subscribeToAuth(setProviderUser);
void workshopClient
.refreshAuth()
.then(setProviderUser)
.catch(() => undefined);
return unsubscribe;
}, [data?.provider, workshopClient]);
useEffect(
() => () => {
quotaRequestRef.current?.abort();
},
[],
);
useEffect(() => {
if (data?.provider !== "homarr" || !providerUser) return;
void refreshQuota();
}, [data?.provider, providerUser, refreshQuota]);
useEffect(() => {
if (data?.provider !== "homarr" || !providerUser || !quota) return;
const timeout = window.setTimeout(() => void refreshQuota(), getAssistantProviderQuotaRefreshDelay(quota.resetsAt));
return () => window.clearTimeout(timeout);
}, [data?.provider, providerUser, quota, refreshQuota]);
const value = useMemo(
() => ({
provider: data?.provider ?? null,
defaultModelId: data?.defaultModelId ?? null,
modelId,
models,
reasoning,
isLoading,
providerUser,
quota,
quotaLoading,
quotaError,
setModelId,
setReasoning,
getRequestBody,
getRequestHeaders,
refreshQuota,
signInToProvider,
}),
[
data?.defaultModelId,
data?.provider,
getRequestBody,
getRequestHeaders,
isLoading,
modelId,
models,
providerUser,
quota,
quotaError,
quotaLoading,
reasoning,
refreshQuota,
setModelId,
setReasoning,
signInToProvider,
],
);
return <AssistantPreferencesContext.Provider value={value}>{children}</AssistantPreferencesContext.Provider>;
};
const threadAdapter: RemoteThreadListAdapter = {
async list() {
const threads = await fetchApi.assistant.listThreads.query();
return {
threads: threads.map((thread) => ({
remoteId: thread.id,
status: "regular",
title: thread.title ?? undefined,
lastMessageAt: thread.updatedAt,
custom: { modelId: thread.modelId },
})),
};
},
async initialize(localId) {
const thread = await fetchApi.assistant.createThread.mutate({ localId });
return { remoteId: thread.id, externalId: undefined };
},
async fetch(threadId) {
const { thread } = await fetchApi.assistant.getThread.query({ threadId });
return {
remoteId: thread.id,
status: "regular",
title: thread.title ?? undefined,
lastMessageAt: thread.updatedAt,
custom: { modelId: thread.modelId },
};
},
async rename(threadId, title) {
await fetchApi.assistant.renameThread.mutate({ threadId, title });
},
async updateCustom(threadId, custom) {
const modelId = custom?.modelId;
if (typeof modelId !== "string") return;
await fetchApi.assistant.updateThreadModel.mutate({ threadId, modelId });
},
// assistant-ui requires these callbacks even when the product does not expose archiving.
archive: ignoreUnsupportedArchiveAction,
unarchive: ignoreUnsupportedArchiveAction,
async delete(threadId) {
await fetchApi.assistant.deleteThread.mutate({ threadId });
},
async generateTitle(_threadId, messages) {
const firstUserMessage = messages.find((message) => message.role === "user");
const text =
firstUserMessage?.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
.trim()
.slice(0, 72) || "New conversation";
return createAssistantStream((controller) => controller.appendText(text));
},
};
const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapter => ({
async load() {
return { messages: [] };
},
async append() {
// AI SDK runtimes use the typed adapter returned by withFormat below.
},
withFormat<TMessage, TStorageFormat extends Record<string, unknown>>(
formatAdapter: MessageFormatAdapter<TMessage, TStorageFormat>,
) {
// assistant-ui's current AI SDK v7 runtime intentionally keeps this identifier for its
// persisted UIMessage wire format. It is a storage codec name, not the installed SDK version.
if (formatAdapter.format !== "ai-sdk/v6") {
throw new Error(`Unsupported assistant message format: ${formatAdapter.format}`);
}
const storageFormat = formatAdapter.format;
return {
async load() {
if (!threadId) return { messages: [] };
const { messages } = await fetchApi.assistant.getThread.query({ threadId });
return {
messages: messages
.filter((message) => message.format === formatAdapter.format)
.map(
(message) =>
formatAdapter.decode({
id: message.id,
parent_id: message.parentId,
format: message.format,
content: message.content as TStorageFormat,
}) as { parentId: string | null; message: TMessage },
),
};
},
async append(item) {
if (!threadId) return;
await fetchApi.assistant.appendMessage.mutate({
threadId,
id: (formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>).getId(item.message),
parentId: item.parentId,
format: storageFormat,
content: (formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>).encode(
item as { parentId: string | null; message: unknown },
),
});
},
async update(item, localMessageId) {
if (!threadId) return;
const erasedFormatAdapter = formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>;
const id = erasedFormatAdapter.getId(item.message);
await fetchApi.assistant.appendMessage.mutate({
threadId,
id,
parentId: item.parentId,
format: storageFormat,
content: erasedFormatAdapter.encode(item as { parentId: string | null; message: unknown }),
});
if (localMessageId !== id) {
await fetchApi.assistant.deleteMessages.mutate({ threadId, ids: [localMessageId] });
}
},
async delete(items) {
if (!threadId || items.length === 0) return;
await fetchApi.assistant.deleteMessages.mutate({
threadId,
ids: items.map((item) =>
(formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>).getId(item.message),
),
});
},
};
},
});
const AssistantThreadRuntime = () => {
const t = useI18n("assistant");
const aui = useAui();
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const preferences = useAssistantPreferences();
const { getRequestBody, getRequestHeaders, refreshQuota } = preferences;
const transport = useMemo(
() =>
new AssistantChatTransport({
api: "/api/assistant/chat",
body: getRequestBody,
fetch: (input, init) => {
const headers = new Headers(init?.headers);
for (const [name, value] of Object.entries(getRequestHeaders())) headers.set(name, value);
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
},
}),
[getRequestBody, getRequestHeaders],
);
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
const attachments = useMemo(
() =>
createAssistantAttachmentAdapter(
selectedModel === undefined ||
selectedModel.inputModalities.length === 0 ||
selectedModel.inputModalities.includes("image"),
),
[selectedModel],
);
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
useEffect(() => {
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
setSpeech(new WebSpeechSynthesisAdapter());
}
}, []);
const feedback = useMemo(
() => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
if (!threadId) return;
void fetchApi.assistant.submitFeedback
.mutate({ threadId, messageId: message.id, type })
.catch(() => showErrorNotification({ title: t("responseError.title"), message: t("feedbackError") }));
},
}),
[t, threadId],
);
const onError = useCallback(() => {
void refreshQuota();
showErrorNotification({
title: t("responseError.title"),
message: t("responseError.description"),
autoClose: 10_000,
});
}, [refreshQuota, t]);
const onFinish = useCallback(() => void refreshQuota(), [refreshQuota]);
const chat = useChat<AssistantUIMessage>({
id: localThreadId,
transport,
onError,
onFinish,
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
});
// The lower-level AI SDK v7 runtime is intentional: Homarr supplies its own tRPC-backed
// thread list and history adapters and shares this chat instance with its custom transport.
const runtime = useAISDKRuntime(chat, {
...assistantAiSdkRuntimeOptions,
adapters: {
history,
attachments,
feedback,
speech,
},
});
transport.setRuntime(runtime);
// oxlint-disable-next-line no-underscore-dangle -- assistant-ui requires this hook to resolve the persisted remote thread before sending.
transport.__internal_setGetThreadListItem(() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
return runtime;
};
const AssistantRuntime = ({ children }: PropsWithChildren) => {
const router = useRouter();
const { refreshCurrentView } = useAssistantViewRefresh();
const runtime = useRemoteThreadListRuntime({
adapter: threadAdapter,
runtimeHook: AssistantThreadRuntime,
});
const browserToolExecutors = useMemo(
() =>
createAssistantBrowserToolExecutors({
getOrigin: () => window.location.origin,
navigate: (path) => router.push(path),
openCommandMenu: openSpotlight,
openMediaRequestSearch,
refreshCurrentView,
}),
[refreshCurrentView, router],
);
const toolkit = useMemo(
() =>
defineToolkit({
ask_user: {
type: "human",
display: "standalone",
...browserToolContracts.ask_user,
render: AssistantAskUserTool,
},
configure_app: {
type: "human",
display: "standalone",
...browserToolContracts.configure_app,
render: AssistantConfigureAppTool,
},
configure_board_settings: {
type: "human",
display: "standalone",
...browserToolContracts.configure_board_settings,
render: AssistantConfigureBoardSettingsTool,
},
configure_widget: {
type: "human",
display: "standalone",
...browserToolContracts.configure_widget,
render: AssistantConfigureWidgetTool,
},
navigate_to_route: {
type: "frontend",
...browserToolContracts.navigate_to_route,
execute: browserToolExecutors.navigate_to_route,
renderText: {
running: ({ args }) => `Opening ${args.path}…`,
complete: ({ args }) => `Opened ${args.path}`,
},
},
open_command_menu: {
type: "frontend",
...browserToolContracts.open_command_menu,
execute: browserToolExecutors.open_command_menu,
renderText: { running: "Opening command menu…", complete: "Command menu opened" },
},
open_media_request_search: {
type: "frontend",
...browserToolContracts.open_media_request_search,
execute: browserToolExecutors.open_media_request_search,
renderText: { running: "Opening media search…", complete: "Media search opened" },
},
refresh_current_view: {
type: "frontend",
...browserToolContracts.refresh_current_view,
execute: browserToolExecutors.refresh_current_view,
renderText: { running: "Refreshing current view…", complete: "Current view refreshed" },
},
}) as Toolkit,
[browserToolExecutors],
);
return (
<AssistantRuntimeProviderWithTools runtime={runtime} toolkit={toolkit}>
<AssistantRuntimeEvents />
<AssistantRunFocusPreserver />
<AssistantPreferenceSync />
{children}
</AssistantRuntimeProviderWithTools>
);
};
const AssistantPreferenceSync = () => {
const preferences = useAssistantPreferences();
// The local id distinguishes every thread immediately, including threads that do not have a remote id yet.
const conversationId = useAuiState((state) => state.threadListItem.id);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const threadCustom = useAuiState((state) => state.threadListItem.custom);
const threadModelId = threadCustom?.modelId;
const previousSyncKeyRef = useRef<string | null>(null);
const modelCatalogKey = preferences.models.map((model) => model.id).join("\0");
useEffect(() => {
if (preferences.models.length === 0) return;
const syncKey = `${conversationId}:${modelCatalogKey}`;
if (previousSyncKeyRef.current === syncKey) return;
const nextModelId = resolveAssistantThreadPreferenceModelId({
isRemote: remoteId !== undefined,
metadataLoaded: threadCustom !== undefined,
threadModelId,
defaultModelId: preferences.defaultModelId,
models: preferences.models,
});
if (nextModelId === undefined) return;
previousSyncKeyRef.current = syncKey;
if (nextModelId) preferences.setModelId(nextModelId);
}, [conversationId, modelCatalogKey, preferences, remoteId, threadCustom, threadModelId]);
return null;
};
const AssistantRuntimeEvents = () => {
const t = useI18n("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")
.map((part) => part.text)
.join("\n")
.trim() ?? "";
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 = useI18n("assistant");
const [opened, setOpened] = useState(false);
const [activityDismissed, setActivityDismissed] = useState(true);
const [unreadCount, setUnreadCount] = useState(0);
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
const aui = useAui();
const preferences = useAssistantPreferences();
const { isRefreshing, refreshCurrentView } = useAssistantViewRefresh();
const messages = useAuiState((state) => state.thread.messages);
// The local id is stable while a new thread is initialized and changes for every real thread switch.
const conversationId = useAuiState((state) => state.threadListItem.id);
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 latestAssistantPart = latestAssistantMessage?.content.at(-1);
const latestToolComplete = latestAssistantPart?.type === "tool-call" && latestAssistantPart.result !== undefined;
const latestAssistantPartType = getRunningAssistantPartType(
latestStatus?.type,
latestAssistantPart?.type,
latestToolComplete,
);
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
const assistantIsRunning = isRunning || queuedPrompt !== null;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const notificationKey = getNotificationKey(latestAssistantMessage);
const notificationStateRef = useRef(initialAssistantNotificationState);
const mutationRefreshStateRef = useRef<{ conversationId: string | null; toolCallIds: Set<string> }>({
conversationId: null,
toolCallIds: new Set(),
});
const successfulMutationIds = useMemo(() => getSuccessfulApprovedAssistantMutationIds(messages), [messages]);
const markRead = useCallback(() => {
notificationStateRef.current = {
initialized: true,
conversationId,
notificationKey,
};
setUnreadCount(0);
}, [conversationId, notificationKey]);
const markReadRef = useRef(markRead);
markReadRef.current = markRead;
const open = useCallback(() => {
markRead();
setActivityDismissed(false);
setOpened(true);
}, [markRead]);
const close = useCallback(() => setOpened(false), []);
const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => {
if (visible) markReadRef.current();
setVisibleWidgetIds((current) => {
if (current.has(widgetId) === visible) return current;
const next = new Set(current);
if (visible) next.add(widgetId);
else next.delete(widgetId);
return next;
});
}, []);
const activateWidget = useCallback((widgetId: string) => {
setVisibleWidgetIds((current) => {
if (!current.has(widgetId) || current.values().next().value === widgetId) return current;
return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]);
});
}, []);
const toggle = useCallback(() => {
if (opened) close();
else open();
}, [close, open, opened]);
const sendPrompt = useCallback(
(prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return false;
if (providerUnavailable) {
showWarningNotification({
title: t("providerQuota.title"),
message: preferences.providerUser
? t("providerQuota.exhaustedDescription")
: t("providerQuota.signInDescription"),
});
return false;
}
if (assistantIsRunning || latestStatus?.type === "requires-action") {
showWarningNotification({
title: t("busy.title"),
message: t("busy.description"),
});
return false;
}
setActivityDismissed(false);
if (isLoading) {
setQueuedPrompt(text);
return true;
}
return sendPromptThroughRuntime(aui, text);
},
[assistantIsRunning, aui, isLoading, latestStatus?.type, preferences.providerUser, providerUnavailable, t],
);
const selectModel = useCallback(
(modelId: string) => {
preferences.setModelId(modelId);
const threadListItem = aui.threadListItem();
if (!threadListItem.getState().remoteId) return;
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
},
[aui, preferences],
);
useEffect(() => {
if (assistantIsRunning) setActivityDismissed(false);
}, [assistantIsRunning]);
useEffect(() => {
if (isLoading || isRunning || queuedPrompt === null) return;
if (providerUnavailable) {
setQueuedPrompt(null);
return;
}
const prompt = queuedPrompt;
setQueuedPrompt(null);
sendPromptThroughRuntime(aui, prompt);
}, [aui, isLoading, isRunning, providerUnavailable, queuedPrompt]);
useEffect(() => {
if (isLoading) return;
const update = updateAssistantNotificationState(notificationStateRef.current, {
conversationId,
notificationKey,
});
notificationStateRef.current = update.state;
if (!update.shouldNotify) return;
setActivityDismissed(false);
if (opened || visibleWidgetIds.size > 0) {
setUnreadCount(0);
return;
}
setUnreadCount((current) => current + 1);
}, [conversationId, isLoading, notificationKey, opened, visibleWidgetIds.size]);
useEffect(() => {
if (isLoading) return;
const update = updateAssistantMutationRefreshState(
mutationRefreshStateRef.current,
conversationId,
successfulMutationIds,
);
mutationRefreshStateRef.current = update.state;
if (!update.shouldRefresh) return;
void refreshCurrentView().catch(() => {
showWarningNotification({
title: t("refresh.failedTitle"),
message: t("refresh.failedDescription"),
});
});
}, [conversationId, isLoading, refreshCurrentView, successfulMutationIds, t]);
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
const spotlightItem = useMemo(() => {
const canSend = !providerUnavailable && !assistantIsRunning && latestStatus?.type !== "requires-action";
return {
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description: providerUnavailable
? t("providerQuota.unavailableDescription")
: canSend
? t("spotlightDescription")
: t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (query: string) =>
createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted: closeSpotlight,
prompt: query,
canSend,
}),
};
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
const value = useMemo(
() => ({
enabled: true,
unavailableDescription: null,
opened,
isRunning: assistantIsRunning,
isRefreshing,
unreadCount,
hasVisibleWidget: visibleWidgetIds.size > 0,
activeWidgetId,
open,
close,
toggle,
sendPrompt,
refreshCurrentView,
setWidgetVisible,
activateWidget,
}),
[
assistantIsRunning,
activateWidget,
activeWidgetId,
close,
isRefreshing,
open,
opened,
refreshCurrentView,
sendPrompt,
setWidgetVisible,
toggle,
unreadCount,
visibleWidgetIds.size,
],
);
return (
<AssistantAutoApprovalProvider conversationId={conversationId}>
<AssistantContext.Provider value={value}>
<AssistantComposerRuntimeProvider>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
markRead();
setActivityDismissed(true);
}}
activityDismissed={activityDismissed}
hasVisibleWidget={visibleWidgetIds.size > 0}
isRunning={assistantIsRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestAssistantPartType={latestAssistantPartType}
latestUserText={queuedPrompt ?? latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
isRefreshing={isRefreshing}
onRefresh={refreshCurrentView}
onModelChange={selectModel}
onReasoningChange={preferences.setReasoning}
/>
</AssistantComposerSurfaceBoundary>
</AssistantComposerRuntimeProvider>
</AssistantContext.Provider>
</AssistantAutoApprovalProvider>
);
};
/**
* The enabled half of the assistant. Only rendered (and only downloaded) when the instance has the
* assistant configured and the visitor is signed in - see `assistant-gate`.
*/
export const EnabledAssistantRoot = ({ children }: PropsWithChildren) => (
<AssistantPreferencesProvider>
<AssistantViewRefreshProvider>
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
</AssistantViewRefreshProvider>
</AssistantPreferencesProvider>
);
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { act, createElement, useState } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, test } from "vitest";
import { AssistantPendingQuestionPortal, AssistantQuestionPortalProvider } from "./assistant-question-portal";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const containers: HTMLDivElement[] = [];
afterEach(() => {
for (const container of containers.splice(0)) container.remove();
});
const QuestionSurface = () => {
const [target, setTarget] = useState<HTMLDivElement | null>(null);
return createElement(
AssistantQuestionPortalProvider,
{ target },
createElement(
"div",
{ "data-testid": "transcript" },
createElement(AssistantPendingQuestionPortal, null, "Question"),
),
createElement("div", { "data-testid": "dock", ref: setTarget }),
);
};
describe("AssistantPendingQuestionPortal", () => {
test("keeps the question visible when a surface has no composer dock", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () =>
root.render(
createElement(
AssistantQuestionPortalProvider,
{ target: null },
createElement(AssistantPendingQuestionPortal, null, "Question"),
),
),
);
expect(container.textContent).toBe("Question");
await act(async () => root.unmount());
});
test("moves one pending question from the transcript into the composer dock", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(createElement(QuestionSurface)));
expect(container.querySelector('[data-testid="transcript"]')?.textContent).toBe("");
expect(container.querySelector('[data-testid="dock"]')?.textContent).toBe("Question");
await act(async () => root.unmount());
});
});
@@ -0,0 +1,19 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { createContext, useContext } from "react";
import { createPortal } from "react-dom";
const AssistantQuestionPortalContext = createContext<HTMLElement | null>(null);
export const AssistantQuestionPortalProvider = ({
children,
target,
}: PropsWithChildren<{ target: HTMLElement | null }>) => (
<AssistantQuestionPortalContext.Provider value={target}>{children}</AssistantQuestionPortalContext.Provider>
);
export const AssistantPendingQuestionPortal = ({ children }: { children: ReactNode }) => {
const target = useContext(AssistantQuestionPortalContext);
return target ? createPortal(children, target) : children;
};
@@ -0,0 +1,177 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { act, createElement, useEffect } from "react";
import { createRoot } from "react-dom/client";
import type { AssistantClient, ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
ChainOfThoughtByIndicesProvider,
ChainOfThoughtPrimitive,
MessagePrimitive,
ThreadPrimitive,
useAui,
useLocalRuntime,
} from "@assistant-ui/react";
import { afterEach, describe, expect, test } from "vitest";
import { assistantMessageGroupBy } from "./assistant-message-grouping";
import { useAssistantReasoningState } from "./assistant-reasoning-state";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0));
const containers: HTMLDivElement[] = [];
afterEach(() => {
for (const container of containers.splice(0)) container.remove();
});
const ReasoningPart = ({ text }: { text: string }) => <span data-testid="reasoning-part">{text}</span>;
const ToolPart = ({ toolName }: { toolName: string }) => <span data-testid="tool-part">{toolName}</span>;
const TestChainOfThought = ({
preferredCollapsed = true,
children,
}: {
preferredCollapsed?: boolean;
children?: ReactNode;
}) => {
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
return (
<ChainOfThoughtPrimitive.Root
data-testid="chain-of-thought"
data-status={chainStatus.type}
data-collapsed={String(collapsed)}
>
{children}
</ChainOfThoughtPrimitive.Root>
);
};
const TestMessage = () => (
<MessagePrimitive.Root>
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="never">
{({ 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}>
<TestChainOfThought>{children}</TestChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
}
case "group-reasoning":
case "group-tool":
return <>{children}</>;
case "reasoning":
return <ReasoningPart text={part.text} />;
case "tool-call":
return <ToolPart toolName={part.toolName} />;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
</MessagePrimitive.Root>
);
const RuntimeProbe = ({ onReady }: { onReady: (aui: AssistantClient) => void }) => {
const aui = useAui();
useEffect(() => {
onReady(aui);
}, [aui, onReady]);
return null;
};
const renderAssistant = async (adapter: ChatModelAdapter, initialMessages: ThreadMessageLike[] = []) => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let client: AssistantClient | undefined;
const TestAssistant = () => {
const runtime = useLocalRuntime(adapter, { initialMessages });
return (
<AssistantRuntimeProvider runtime={runtime}>
<RuntimeProbe
onReady={(aui) => {
client = aui;
}}
/>
<ThreadPrimitive.Messages components={{ Message: TestMessage }} />
</AssistantRuntimeProvider>
);
};
await act(async () => {
root.render(createElement(TestAssistant));
await flushEffects();
});
if (!client) throw new Error("The assistant runtime was not registered");
return { client, container, root };
};
describe("useAssistantReasoningState", () => {
test("mounts and renders streamed reasoning without updating the chain resource before mount", async () => {
const adapter: ChatModelAdapter = {
async *run() {
yield { content: [{ type: "reasoning", text: "Inspecting the dashboard" }] };
await flushEffects();
yield {
content: [
{ type: "reasoning", text: "Inspecting the dashboard" },
{ type: "text", text: "Done" },
],
};
},
};
const { client, container, root } = await renderAssistant(adapter);
await act(async () => {
client.composer().setText("Inspect my dashboard");
client.composer().send();
await flushEffects();
await flushEffects();
});
expect(container.querySelector('[data-testid="reasoning-part"]')?.textContent).toBe("Inspecting the dashboard");
expect(container.querySelector('[data-testid="chain-of-thought"]')?.getAttribute("data-status")).toBe("complete");
await act(async () => root.unmount());
});
test("opens a pending tool call even when completed reasoning is normally collapsed", async () => {
const pendingToolMessage: ThreadMessageLike = {
role: "assistant",
content: [
{ type: "reasoning", text: "I need approval" },
{
type: "tool-call",
toolCallId: "approval-1",
toolName: "app_create",
args: { name: "Wikipedia" },
argsText: JSON.stringify({ name: "Wikipedia" }),
},
],
status: { type: "requires-action", reason: "tool-calls" },
};
const { container, root } = await renderAssistant({ async *run() {} }, [pendingToolMessage]);
await act(async () => flushEffects());
expect(container.querySelector('[data-testid="tool-part"]')?.textContent).toBe("app_create");
expect(container.querySelector('[data-testid="chain-of-thought"]')?.getAttribute("data-status")).toBe(
"requires-action",
);
expect(container.querySelector('[data-testid="chain-of-thought"]')?.getAttribute("data-collapsed")).toBe("false");
await act(async () => root.unmount());
});
});
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
import { useAui, useAuiState } from "@assistant-ui/react";
/**
* Keeps the per-message chain of thought aligned with the conversation preference.
*
* assistant-ui commits its chain resource in a passive effect. Updating it from a
* layout effect races that commit and throws `Resource updated before mount` when
* reasoning or tool parts first appear. Keep all programmatic chain updates in
* passive effects so the resource is mounted before it receives an update.
*/
export const useAssistantReasoningState = (preferredCollapsed: boolean) => {
const aui = useAui();
const chainStatus = useAuiState((state) => state.chainOfThought.status);
const collapsed = useAuiState((state) => state.chainOfThought.collapsed);
useEffect(() => {
aui.chainOfThought().setCollapsed(preferredCollapsed);
}, [aui, preferredCollapsed]);
useEffect(() => {
if (chainStatus.type !== "requires-action") return;
aui.chainOfThought().setCollapsed(false);
}, [aui, chainStatus.type]);
return { chainStatus, collapsed };
};
@@ -0,0 +1,395 @@
"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
Badge,
Box,
Button,
Combobox,
Divider,
Group,
Loader,
Popover,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
useCombobox,
} from "@mantine/core";
import {
IconBattery1,
IconBattery3,
IconBatteryFilled,
IconBatteryOff,
IconCheck,
IconPhoto,
} from "@tabler/icons-react";
import { useTimeAgo } from "@homarr/common";
import { assistantReasoningModes } from "@homarr/definitions";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
import { useAssistantPreferences } from "./assistant-context";
import type { AssistantPendingAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantReasoningDisplayMode } from "./assistant-preferences";
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
type ComposerProps = AssistantConversationControls & {
pendingAction: AssistantPendingAction | undefined;
compact?: boolean;
};
const getModelProviderLabel = (modelId: string, fallback: string) => {
const separator = modelId.indexOf("/");
if (separator <= 0) return fallback;
const provider = modelId
.slice(0, separator)
.replace(/^[^\p{L}\p{N}]+/u, "")
.replaceAll(/[-_]/gu, " ");
if (!provider) return fallback;
return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`;
};
const formatCompactModelNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value);
export const RuntimeControls = ({
compact = false,
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
}: ComposerProps) => {
const t = useI18n("assistant");
const { provider: assistantProvider } = useAssistantPreferences();
const [modelSearch, setModelSearch] = useState("");
const selectedModel = models.find((model) => model.id === modelId);
const displayedReasoning = resolveAssistantReasoningDisplayMode({ provider: assistantProvider, modelId, reasoning });
const reasoningIsForced = displayedReasoning === "max";
let reasoningLabel = "Max";
if (!reasoningIsForced) reasoningLabel = t(`runtime.reasoning.${displayedReasoning}`);
let reasoningControl: ReactNode;
if (reasoningIsForced) {
reasoningControl = (
<Badge size="sm" variant="light" color="gray">
Max
</Badge>
);
} else {
reasoningControl = (
<SegmentedControl
className={classes.reasoningSegmentedControl}
value={reasoning}
onChange={(value) => {
if (assistantReasoningModes.includes(value as AssistantReasoningMode)) {
onReasoningChange(value as AssistantReasoningMode);
}
}}
size="xs"
fullWidth
data={assistantReasoningModes.map((mode) => ({
value: mode,
label: t(`runtime.reasoning.${mode}`),
}))}
/>
);
}
const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase();
const visibleModels =
normalizedModelSearch.length === 0
? models
: models.filter(
(model) =>
model.name.toLocaleLowerCase().includes(normalizedModelSearch) ||
model.id.toLocaleLowerCase().includes(normalizedModelSearch),
);
const groupedModels = visibleModels.reduce((groups, model) => {
const provider = getModelProviderLabel(model.id, t("runtime.otherModels"));
const providerModels = groups.get(provider);
if (providerModels) providerModels.push(model);
else groups.set(provider, [model]);
return groups;
}, new Map<string, AssistantRuntimeModelOption[]>());
const modelCombobox = useCombobox({
onDropdownOpen: () => {
modelCombobox.selectActiveOption();
requestAnimationFrame(() => modelCombobox.focusSearchInput());
},
onDropdownClose: () => {
modelCombobox.resetSelectedOption();
setModelSearch("");
},
});
const selectModel = (value: string) => {
if (!models.some((model) => model.id === value)) return;
onModelChange(value);
modelCombobox.closeDropdown();
};
return (
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={340}>
<Combobox.Target>
{compact ? (
<Button
className={classes.composerModelButton}
classNames={{ label: classes.runtimeSelectorName }}
variant="subtle"
color="gray"
size="compact-sm"
type="button"
disabled={modelOptionsLoading || models.length === 0}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${reasoningLabel}`}
aria-expanded={modelCombobox.dropdownOpened}
>
{modelOptionsLoading && <Loader size="xs" />}
<Text component="span" size="sm" fw={300}>
{selectedModel?.name ?? t("runtime.model")}
</Text>{" "}
<Text component="span" size="sm" fw={300} c="dimmed">
{reasoningLabel}
</Text>
</Button>
) : (
<UnstyledButton
className={classes.runtimeSelectorTrigger}
type="button"
disabled={modelOptionsLoading || models.length === 0}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${reasoningLabel}`}
aria-expanded={modelCombobox.dropdownOpened}
>
<Group gap="xs" wrap="nowrap">
{modelOptionsLoading && <Loader size={12} />}
<Text className={classes.runtimeSelectorName} size="xs" fw={400} lineClamp={1}>
{selectedModel?.name ?? t("runtime.model")}
</Text>
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
{reasoningLabel}
</Badge>
<Combobox.Chevron size="xs" />
</Group>
</UnstyledButton>
)}
</Combobox.Target>
<Combobox.Dropdown className={classes.modelDropdown}>
<Combobox.Search
value={modelSearch}
onChange={(event) => {
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.searchModels")}
aria-label={t("runtime.searchModels")}
size="xs"
/>
<Combobox.Options className={classes.modelOptions}>
{[...groupedModels.entries()].map(([provider, providerModels]) => (
<Combobox.Group key={provider} label={provider}>
{providerModels.map((model) => (
<Combobox.Option
className={classes.modelOption}
key={model.id}
value={model.id}
active={model.id === modelId}
>
<Group gap="xs" wrap="nowrap">
<Divider
className={classes.modelOptionDivider}
orientation="vertical"
data-active={model.id === modelId || undefined}
/>
<Stack gap={1} className={classes.modelOptionText}>
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
{model.name}
</Text>
<Text size="xs" className={classes.modelOptionDescription} lineClamp={1}>
{model.description?.trim() || model.id}
</Text>
</Stack>
<Group className={classes.modelOptionMeta} gap={4} wrap="nowrap">
{model.inputModalities.includes("image") && (
<Tooltip label={t("runtime.imageInput")}>
<IconPhoto size={14} aria-label={t("runtime.imageInput")} />
</Tooltip>
)}
{model.contextLength && (
<Badge size="xs" variant="light" color="gray">
{formatCompactModelNumber(model.contextLength)}
</Badge>
)}
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
</Group>
</Group>
</Combobox.Option>
))}
</Combobox.Group>
))}
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
</Combobox.Options>
<Divider />
<Box className={classes.reasoningSelector}>
<Text size="xs" fw={650} mb={5}>
{t("runtime.thinking")}
</Text>
{reasoningControl}
</Box>
</Combobox.Dropdown>
</Combobox>
);
};
const providerQuotaIcons = {
ok: IconBatteryFilled,
warning: IconBattery3,
bad: IconBattery1,
dead: IconBatteryOff,
};
const providerQuotaColors = {
ok: "green",
warning: "yellow",
bad: "orange",
dead: "red",
} as const;
export const HomarrProviderQuota = () => {
const t = useI18n("assistant");
const locale = useCurrentIntlLocale();
const preferences = useAssistantPreferences();
const quota = preferences.quota;
const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]);
const resetRelative = useTimeAgo(resetAt, 30_000);
if (preferences.provider !== "homarr") return null;
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
const Icon = providerQuotaIcons[level];
const color = providerQuotaColors[level];
const percentage = preferences.quota
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
: 0;
let label = t("providerQuota.signInRequired");
if (preferences.providerUser && !quota) label = t("providerQuota.loading");
if (preferences.providerUser && quota) {
label = t("providerQuota.remainingLabel", {
remaining: quota.remaining,
limit: quota.limit,
});
}
const resetTime = quota ? (
<time dateTime={quota.resetsAt} title={new Date(quota.resetsAt).toLocaleString(locale)}>
{new Date(quota.resetsAt).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</time>
) : null;
let quotaContent: ReactNode;
if (!preferences.providerUser) {
quotaContent = (
<>
<Text size="sm" c="dimmed">
{t("providerQuota.signInDescription")}
</Text>
<Button
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.signInToProvider().catch(() => undefined)}
>
{t("providerQuota.signIn")}
</Button>
</>
);
} else if (quota) {
quotaContent = (
<>
<Box>
<Group justify="space-between" gap="xs" mb={5}>
<Text size="xs" c="dimmed">
{t("providerQuota.dailyAllowance")}
</Text>
<Text size="xs" fw={650}>
{quota.remaining} / {quota.limit}
</Text>
</Group>
<Progress value={percentage} color={color} size="sm" aria-label={label} />
</Box>
<Text size="xs" c="dimmed">
{t.rich("providerQuota.reset", {
relative: resetRelative,
time: () => resetTime,
})}
</Text>
<Text size="xs" c="dimmed">
{t("providerQuota.toolCalls")}
</Text>
</>
);
} else {
quotaContent = (
<Button
variant="light"
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.refreshQuota()}
>
{t("providerQuota.retry")}
</Button>
);
}
return (
<Popover position="top" width={290} shadow="md" withinPortal>
<Popover.Target>
<UnstyledButton className={classes.providerQuotaTrigger} data-level={level} type="button" aria-label={label}>
{preferences.quotaLoading && !preferences.quota ? <Loader size={14} /> : <Icon size={16} aria-hidden />}
<Text component="span" size="xs" fw={650}>
{preferences.quota?.remaining ?? "–"}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={color} variant="light" size="sm" radius="xl">
<Icon size={15} aria-hidden />
</ThemeIcon>
<Box>
<Text size="sm" fw={700}>
{t("providerQuota.title")}
</Text>
<Text size="xs" c="dimmed">
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
</Text>
</Box>
</Group>
{preferences.quota && (
<Badge color={color} variant="light" size="sm">
{t(`providerQuota.level.${level}`)}
</Badge>
)}
</Group>
{quotaContent}
{preferences.quotaError && (
<Text size="xs" c="red">
{preferences.quotaError}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,9 @@
import { describe, expect, test } from "vitest";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
describe("assistantAiSdkRuntimeOptions", () => {
test("preserves pending server tools across automatic agent continuations", () => {
expect(assistantAiSdkRuntimeOptions.cancelPendingToolCallsOnSend).toBe(false);
});
});
@@ -0,0 +1,5 @@
export const assistantAiSdkRuntimeOptions = {
// Automatic frontend-tool continuations are sends too. Letting assistant-ui cancel every
// pending tool on send incorrectly turns earlier server tools into user cancellations.
cancelPendingToolCallsOnSend: false,
} as const;
@@ -0,0 +1,578 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { act, createElement, useEffect } from "react";
import { createRoot } from "react-dom/client";
import type {
AssistantClient,
AssistantRuntime,
AttachmentAdapter,
ChatModelAdapter,
ThreadMessageLike,
} from "@assistant-ui/react";
import {
defineToolkit,
INTERNAL,
MessagePrimitive,
ThreadPrimitive,
useAui,
useAuiEvent,
useLocalRuntime,
} from "@assistant-ui/react";
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
import { afterEach, describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { showErrorNotification } from "@homarr/notifications";
import {
AssistantComposerRuntimeProvider,
AssistantComposerSurfaceBoundary,
AssistantRunFocusPreserver,
AssistantRuntimeProviderWithTools,
assistantSurfaceComposerCacheLimit,
} from "./assistant-runtime-provider";
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key }));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const noOpAdapter: ChatModelAdapter = { async *run() {} };
const createAttachmentAdapter = (accept: string): AttachmentAdapter => ({
accept,
async add({ file }) {
return {
id: `${file.name}-${file.size}`,
type: file.type.startsWith("image/") ? "image" : "document",
name: file.name,
contentType: file.type,
file,
status: { type: "requires-action", reason: "composer-send" },
};
},
async send(attachment) {
return {
...attachment,
status: { type: "complete" },
content: [{ type: "text", text: attachment.name }],
};
},
async remove() {},
});
const toolkit = defineToolkit({
ask_user: {
type: "human",
display: "standalone",
parameters: z.object({ question: z.string() }),
render: ({ args }) => createElement("div", { "data-testid": "ask-user-ui" }, `Question: ${args.question}`),
},
});
const messages: ThreadMessageLike[] = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "ask-1",
toolName: "ask_user",
args: { question: "Which board?" },
argsText: JSON.stringify({ question: "Which board?" }),
},
],
status: { type: "requires-action", reason: "tool-calls" },
},
];
const Message = () =>
createElement(MessagePrimitive.Parts, {
components: {
tools: {
Fallback: () => createElement("div", { "data-testid": "fallback-tool-ui" }, "Fallback"),
},
},
});
const TestAssistant = () => {
const runtime = useLocalRuntime(noOpAdapter, { initialMessages: messages });
return createElement(
AssistantRuntimeProviderWithTools,
{ runtime, toolkit },
createElement(ThreadPrimitive.Messages, { components: { Message } }),
);
};
interface SurfaceProbeProps {
id: string;
onReady: (id: string, aui: AssistantClient) => void;
}
const SurfaceProbe = ({ id, onReady }: SurfaceProbeProps) => {
const aui = useAui();
useEffect(() => {
onReady(id, aui);
}, [aui, id, onReady]);
return createElement(LexicalComposerInput, {
"data-assistant-composer-input": true,
"data-testid": `input-${id}`,
} as ComponentProps<typeof LexicalComposerInput>);
};
const LexicalRunFocusProbe = () => {
useAuiEvent("thread.runStart", () => {
queueMicrotask(() => {
document.querySelector<HTMLElement>('[data-testid="input-b"] [contenteditable="true"]')?.focus();
});
});
return null;
};
const SurfaceTestAssistant = ({
onReady,
onRuntimeReady,
allowImages = true,
simulateRunFocus = false,
}: {
onReady: SurfaceProbeProps["onReady"];
onRuntimeReady: (runtime: AssistantRuntime) => void;
allowImages?: boolean;
simulateRunFocus?: boolean;
}) => {
const runtime = useLocalRuntime(noOpAdapter, {
adapters: { attachments: createAttachmentAdapter(allowImages ? "image/png,text/plain" : "text/plain") },
});
useEffect(() => onRuntimeReady(runtime), [onRuntimeReady, runtime]);
return createElement(
AssistantRuntimeProviderWithTools,
{ runtime, toolkit },
createElement(AssistantRunFocusPreserver),
createElement(
AssistantComposerRuntimeProvider,
null,
createElement(
AssistantComposerSurfaceBoundary,
{ surfaceId: "surface-a" },
createElement(SurfaceProbe, { id: "a", onReady }),
),
),
createElement(
AssistantComposerRuntimeProvider,
null,
createElement(
AssistantComposerSurfaceBoundary,
{ surfaceId: "surface-b" },
createElement(SurfaceProbe, { id: "b", onReady }),
),
),
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
);
};
const renderSurfaceTestAssistant = async ({
allowImages = true,
simulateRunFocus = false,
}: { allowImages?: boolean; simulateRunFocus?: boolean } = {}) => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
const clients = new Map<string, AssistantClient>();
let sharedRuntime: AssistantRuntime | undefined;
await act(async () => {
root.render(
createElement(SurfaceTestAssistant, {
allowImages,
simulateRunFocus,
onReady: (id, aui) => clients.set(id, aui),
onRuntimeReady: (runtime) => {
sharedRuntime = runtime;
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
});
const a = clients.get("a");
const b = clients.get("b");
if (!a || !b || !sharedRuntime) throw new Error("The assistant runtime and both surfaces must be registered");
return {
container,
root,
a,
b,
sharedRuntime,
};
};
const getRequiredComposerInput = (container: HTMLElement, testId: string) => {
const wrapper = container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
const target = wrapper?.querySelector<HTMLElement>('[contenteditable="true"]');
if (!target) throw new Error(`Missing composer input ${testId}`);
return target;
};
const getUserMessageText = (aui: AssistantClient) =>
aui
.thread()
.getState()
.messages.filter((message) => message.role === "user")
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text);
const containers: HTMLDivElement[] = [];
afterEach(() => {
for (const container of containers.splice(0)) container.remove();
});
describe("AssistantRuntimeProviderWithTools", () => {
test("makes the registered human-tool renderer available to message parts", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(createElement(TestAssistant));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(container.querySelector('[data-testid="ask-user-ui"]')?.textContent).toBe("Question: Which board?");
expect(container.querySelector('[data-testid="fallback-tool-ui"]')).toBeNull();
await act(async () => root.unmount());
});
test("keeps drafts and attachments independent while both surfaces share one thread", async () => {
const { container, root, a, b } = await renderSurfaceTestAssistant();
act(() => {
a.composer().setText("Draft in the board widget");
b.composer().setText("Draft in the floating panel");
});
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
await act(async () => {
await a.composer().addAttachment({
id: "board-file",
name: "board.txt",
contentType: "text/plain",
content: [{ type: "text", text: "board attachment" }],
});
});
expect(a.composer().getState().text).toBe("Draft in the board widget");
expect(b.composer().getState().text).toBe("Draft in the floating panel");
expect(
a
.composer()
.getState()
.attachments.map((attachment) => attachment.name),
).toEqual(["board.txt"]);
expect(b.composer().getState().attachments).toHaveLength(0);
expect(a.thread().getState().messages).toEqual(b.thread().getState().messages);
expect(getRequiredComposerInput(container, "input-a").textContent).toBe("Draft in the board widget");
expect(getRequiredComposerInput(container, "input-b").textContent).toBe("Draft in the floating panel");
await act(async () => root.unmount());
});
test("sends either local composer into the same shared conversation without clearing the other draft", async () => {
const { root, a, b } = await renderSurfaceTestAssistant();
act(() => {
a.composer().setText("Message from the widget");
b.composer().setText("Message from the panel");
a.composer().send();
});
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
expect(getUserMessageText(a)).toContain("Message from the widget");
expect(getUserMessageText(b)).toContain("Message from the widget");
expect(a.composer().getState().text).toBe("");
expect(b.composer().getState().text).toBe("Message from the panel");
act(() => b.composer().send());
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
expect(getUserMessageText(a)).toContain("Message from the panel");
expect(getUserMessageText(b)).toContain("Message from the panel");
await act(async () => root.unmount());
});
test("keeps each surface draft scoped to the selected conversation", async () => {
const { root, a, b } = await renderSurfaceTestAssistant();
act(() => {
a.composer().setText("Initialize the first conversation");
a.composer().send();
});
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
const firstThreadId = a.threads().getState().mainThreadId;
act(() => {
a.composer().setText("First thread / widget");
b.composer().setText("First thread / panel");
});
await act(async () => a.threads().switchToNewThread());
expect(a.threads().getState().mainThreadId).not.toBe(firstThreadId);
expect(a.composer().getState().text).toBe("");
expect(b.composer().getState().text).toBe("");
act(() => a.composer().setText("Second thread / widget"));
await act(async () => a.threads().switchToThread(firstThreadId));
expect(a.composer().getState().text).toBe("First thread / widget");
expect(b.composer().getState().text).toBe("First thread / panel");
await act(async () => root.unmount());
});
test("forwards main-thread reloads through the surface runtime", async () => {
const { root, a, sharedRuntime } = await renderSurfaceTestAssistant();
const reloadMainThread = vi.spyOn(sharedRuntime.threads, "reloadMainThread").mockResolvedValue();
await act(async () => a.threads().reloadMainThread());
expect(reloadMainThread).toHaveBeenCalledOnce();
await act(async () => root.unmount());
});
test("restores focus after the Lexical run-start behavior tries to focus every surface", async () => {
const outsideInput = document.createElement("input");
document.body.append(outsideInput);
const { root, a } = await renderSurfaceTestAssistant();
outsideInput.focus();
act(() => {
a.composer().setText("Run without stealing focus");
a.composer().send();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(document.activeElement).toBe(outsideInput);
outsideInput.remove();
await act(async () => root.unmount());
});
test("keeps focus and dashboard scroll on the surface that started the run", async () => {
const { container, root, a } = await renderSurfaceTestAssistant({ simulateRunFocus: true });
const panelInput = getRequiredComposerInput(container, "input-a");
const widgetInput = getRequiredComposerInput(container, "input-b");
const scrollContainer = container;
scrollContainer.scrollTop = 120;
const focusWidget = widgetInput.focus.bind(widgetInput);
const widgetFocus = vi.spyOn(widgetInput, "focus").mockImplementation((options) => {
// jsdom does not implement native focus scrolling, so reproduce the dashboard jump that a
// browser performs when the off-screen widget composer receives programmatic focus.
scrollContainer.scrollTop = 640;
focusWidget(options);
});
panelInput.focus();
act(() => {
a.composer().setText("Send from the floating panel");
a.composer().send();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(widgetFocus).toHaveBeenCalled();
expect(document.activeElement).toBe(panelInput);
expect(scrollContainer.scrollTop).toBe(120);
await act(async () => root.unmount());
});
test("adds pasted images only to the active surface composer", async () => {
const { container, root, a, b } = await renderSurfaceTestAssistant();
const target = getRequiredComposerInput(container, "input-a");
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", {
value: { files: [new File(["image"], "dashboard.png", { type: "image/png" })] },
});
await act(async () => {
target.dispatchEvent(paste);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(paste.defaultPrevented).toBe(true);
expect(
a
.composer()
.getState()
.attachments.map((attachment) => attachment.name),
).toEqual(["dashboard.png"]);
expect(b.composer().getState().attachments).toHaveLength(0);
await act(async () => root.unmount());
});
test("rejects a pasted image with an actionable message when the selected model is text-only", async () => {
vi.mocked(showErrorNotification).mockClear();
const { container, root, a } = await renderSurfaceTestAssistant({ allowImages: false });
const target = getRequiredComposerInput(container, "input-a");
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", {
value: { files: [new File(["image"], "dashboard.png", { type: "image/png" })] },
});
await act(async () => target.dispatchEvent(paste));
expect(a.composer().getState().attachments).toHaveLength(0);
expect(showErrorNotification).toHaveBeenCalledWith({
title: "attachments.errorTitle",
message: "attachments.imageUnsupported",
});
await act(async () => root.unmount());
});
test("does not capture file paste from a non-composer textarea", async () => {
const { container, root, a } = await renderSurfaceTestAssistant();
const textarea = document.createElement("textarea");
container.querySelector('[data-testid="input-a"]')?.parentElement?.append(textarea);
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", {
value: { files: [new File(["image"], "dashboard.png", { type: "image/png" })] },
});
await act(async () => textarea.dispatchEvent(paste));
expect(paste.defaultPrevented).toBe(false);
expect(a.composer().getState().attachments).toHaveLength(0);
await act(async () => root.unmount());
});
test("enforces the attachment limit independently for each surface", async () => {
vi.mocked(showErrorNotification).mockClear();
const { root, a, b } = await renderSurfaceTestAssistant();
await act(async () => {
await Promise.all(
Array.from({ length: 5 }, (_, index) =>
a.composer().addAttachment(new File([`a-${index}`], `a-${index}.txt`, { type: "text/plain" })),
),
);
await Promise.all(
Array.from({ length: 5 }, (_, index) =>
b.composer().addAttachment(new File([`b-${index}`], `b-${index}.txt`, { type: "text/plain" })),
),
);
});
expect(a.composer().getState().attachments).toHaveLength(5);
expect(b.composer().getState().attachments).toHaveLength(5);
await expect(a.composer().addAttachment(new File(["six"], "a-six.txt", { type: "text/plain" }))).rejects.toThrow(
"A message can include up to 5 attachments.",
);
expect(b.composer().getState().attachments).toHaveLength(5);
await act(async () => root.unmount());
});
test("disposes scoped composer subscriptions when its surfaces unmount", async () => {
const originalConnect = INTERNAL.DefaultThreadComposerRuntimeCore.prototype.connect;
let activeConnections = 0;
const connectSpy = vi
.spyOn(INTERNAL.DefaultThreadComposerRuntimeCore.prototype, "connect")
.mockImplementation(function (this: InstanceType<typeof INTERNAL.DefaultThreadComposerRuntimeCore>) {
const unsubscribe = originalConnect.call(this);
activeConnections += 1;
let connected = true;
return () => {
if (!connected) return;
connected = false;
activeConnections -= 1;
unsubscribe();
};
});
const { root } = await renderSurfaceTestAssistant();
const mountedConnections = activeConnections;
expect(mountedConnections).toBeGreaterThanOrEqual(3);
await act(async () => root.unmount());
expect(activeConnections).toBeLessThan(mountedConnections);
expect(mountedConnections - activeConnections).toBe(2);
connectSpy.mockRestore();
});
test("recreates scoped composers when the shared conversation core is replaced", async () => {
const originalConnect = INTERNAL.DefaultThreadComposerRuntimeCore.prototype.connect;
let connectCalls = 0;
let disconnectCalls = 0;
const connectSpy = vi
.spyOn(INTERNAL.DefaultThreadComposerRuntimeCore.prototype, "connect")
.mockImplementation(function (this: InstanceType<typeof INTERNAL.DefaultThreadComposerRuntimeCore>) {
connectCalls += 1;
const unsubscribe = originalConnect.call(this);
let connected = true;
return () => {
if (!connected) return;
connected = false;
disconnectCalls += 1;
unsubscribe();
};
});
const { root, a, b, sharedRuntime } = await renderSurfaceTestAssistant();
const mountedConnectCalls = connectCalls;
act(() => {
a.composer().setText("Draft on the first board core");
b.composer().setText("Draft on the first panel core");
});
const sharedThread = sharedRuntime.thread as InstanceType<typeof INTERNAL.ThreadRuntimeImpl>;
// oxlint-disable-next-line no-underscore-dangle -- replacing the bound core reproduces assistant-ui's outer runtime swap.
const binding = sharedThread.__internal_threadBinding;
const originalGetState = binding.getState;
const originalCore = originalGetState();
const replacementCore = new Proxy(originalCore, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
return typeof value === "function" ? value.bind(target) : value;
},
});
binding.getState = () => replacementCore;
act(() => originalCore.reset());
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
expect(connectCalls - mountedConnectCalls).toBeGreaterThanOrEqual(2);
expect(disconnectCalls).toBeGreaterThanOrEqual(2);
expect(a.composer().getState().text).toBe("Draft on the first board core");
expect(b.composer().getState().text).toBe("Draft on the first panel core");
await act(async () => root.unmount());
binding.getState = originalGetState;
connectSpy.mockRestore();
});
test("evicts the least recently used inactive conversation draft", async () => {
const { root, a } = await renderSurfaceTestAssistant();
act(() => {
a.composer().setText("Initialize the oldest conversation");
a.composer().send();
});
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
const oldestThreadId = a.threads().getState().mainThreadId;
act(() => a.composer().setText("Draft that should eventually be evicted"));
for (let index = 0; index < assistantSurfaceComposerCacheLimit; index += 1) {
await act(async () => a.threads().switchToNewThread());
act(() => {
a.composer().setText(`Initialize conversation ${index}`);
a.composer().send();
});
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
}
await act(async () => a.threads().switchToThread(oldestThreadId));
expect(a.composer().getState().text).toBe("");
await act(async () => root.unmount());
});
});
@@ -0,0 +1,597 @@
"use client";
import type { ClipboardEvent, PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef } from "react";
import type { AssistantRuntime, AttachmentAdapter, ThreadListRuntime, Toolkit } from "@assistant-ui/react";
import { AssistantRuntimeProvider, INTERNAL, Tools, useAui, useAuiEvent } from "@assistant-ui/react";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
interface AssistantRuntimeProviderWithToolsProps extends PropsWithChildren {
runtime: AssistantRuntime;
toolkit: Toolkit;
}
export const AssistantRuntimeProviderWithTools = ({
children,
runtime,
toolkit,
}: AssistantRuntimeProviderWithToolsProps) => {
const toolsAui = useAui({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider aui={toolsAui} runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
};
type ThreadCore = INTERNAL.ThreadRuntimeCore;
type ThreadCoreWithAdapters = ConstructorParameters<typeof INTERNAL.DefaultThreadComposerRuntimeCore>[0];
type ComposerConnectionDisposer = () => void;
export const assistantSurfaceComposerCacheLimit = 12;
const assistantSurfaceAttachmentLimit = 5;
const composerConnectionDisposers = new WeakMap<object, ComposerConnectionDisposer>();
class AssistantSurfaceComposerRuntimeCore extends INTERNAL.DefaultThreadComposerRuntimeCore {
public override connect() {
const dispose = super.connect();
composerConnectionDisposers.set(this, dispose);
return dispose;
}
public dispose() {
composerConnectionDisposers.get(this)?.();
composerConnectionDisposers.delete(this);
}
}
interface AssistantSurfaceRuntime extends AssistantRuntime {
dispose(): void;
}
interface AssistantSurfaceComposerEntry {
composer: AssistantSurfaceComposerRuntimeCore;
source: ThreadCore;
}
const getThreadAdapters = (threadCore: ThreadCore) => (threadCore as ThreadCoreWithAdapters).adapters;
const bindRuntimeValue = (target: object, property: PropertyKey, cache: WeakMap<object, Map<PropertyKey, unknown>>) => {
const value = Reflect.get(target, property, target) as unknown;
if (typeof value !== "function") return value;
let targetCache = cache.get(target);
if (!targetCache) {
targetCache = new Map();
cache.set(target, targetCache);
}
const cached = targetCache.get(property);
if (cached) return cached;
const bound = value.bind(target) as unknown;
targetCache.set(property, bound);
return bound;
};
const createSurfaceAttachmentAdapter = (threadCore: ThreadCore): AttachmentAdapter => {
const pendingAttachmentIds = new Set<string>();
let pendingAdds = 0;
const getAdapter = () => {
const adapter = getThreadAdapters(threadCore)?.attachments;
if (!adapter) throw new Error("Attachments are not supported");
return adapter;
};
return {
get accept() {
return getThreadAdapters(threadCore)?.attachments?.accept ?? "";
},
add({ file }) {
if (pendingAttachmentIds.size + pendingAdds >= assistantSurfaceAttachmentLimit) {
throw new Error(`A message can include up to ${assistantSurfaceAttachmentLimit} attachments.`);
}
pendingAdds += 1;
let result: ReturnType<AttachmentAdapter["add"]>;
try {
result = getAdapter().add({ file });
} catch (error) {
pendingAdds -= 1;
throw error;
}
if (Symbol.asyncIterator in result) {
return (async function* () {
let reservationPending = true;
try {
for await (const attachment of result) {
if (reservationPending) {
reservationPending = false;
pendingAdds -= 1;
}
pendingAttachmentIds.add(attachment.id);
yield attachment;
}
} finally {
if (reservationPending) pendingAdds -= 1;
}
})();
}
return result.then(
(attachment) => {
pendingAdds -= 1;
pendingAttachmentIds.add(attachment.id);
return attachment;
},
(error: unknown) => {
pendingAdds -= 1;
throw error;
},
);
},
async remove(attachment) {
try {
await getAdapter().remove(attachment);
} finally {
pendingAttachmentIds.delete(attachment.id);
}
},
async send(attachment) {
try {
return await getAdapter().send(attachment);
} finally {
pendingAttachmentIds.delete(attachment.id);
}
},
};
};
const createSurfaceComposerHost = (
threadCore: ThreadCore,
boundRuntimeValues: WeakMap<object, Map<PropertyKey, unknown>>,
) => {
const attachments = createSurfaceAttachmentAdapter(threadCore);
const adapters = {
get attachments() {
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
},
};
return new Proxy(threadCore as ThreadCoreWithAdapters, {
get(target, property) {
if (property === "adapters") return adapters;
return bindRuntimeValue(target, property, boundRuntimeValues);
},
});
};
/**
* assistant-ui intentionally stores the thread composer on the thread runtime. That is the right
* default for a single chat surface, but Homarr can render the same conversation in the floating
* panel and in one or more board widgets at the same time. A second runtime-bound Lexical input
* would otherwise mirror the first input's draft, attachments and selection.
*
* This adapter keeps the thread runtime itself shared and replaces only its composer core. The
* local composer still delegates send/cancel to the shared thread, so messages, runs, tools,
* branches and history remain one conversation. Composers are cached per conversation id because
* some assistant-ui runtimes reuse the same underlying core while switching conversations.
*/
export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): AssistantSurfaceRuntime => {
const sharedThread = runtime.thread as InstanceType<typeof INTERNAL.ThreadRuntimeImpl>;
// oxlint-disable-next-line no-underscore-dangle -- assistant-ui exposes the binding only through its internal adapter API.
const sharedBinding = sharedThread.__internal_threadBinding;
const composerByConversation = new Map<string, AssistantSurfaceComposerEntry>();
const surfaceCoreByConversation = new Map<string, { source: ThreadCore; surface: ThreadCore }>();
const activeConversationCounts = new Map<string, number>();
const boundRuntimeValues = new WeakMap<object, Map<PropertyKey, unknown>>();
const disposeComposer = (conversationId: string, entry: AssistantSurfaceComposerEntry) => {
const reset = entry.composer.reset();
entry.composer.dispose();
composerByConversation.delete(conversationId);
surfaceCoreByConversation.delete(conversationId);
void reset.catch(() => {});
};
const evictInactiveComposers = () => {
while (composerByConversation.size > assistantSurfaceComposerCacheLimit) {
let candidate: [string, AssistantSurfaceComposerEntry] | undefined;
for (const entry of composerByConversation) {
if (!activeConversationCounts.has(entry[0])) {
candidate = entry;
break;
}
}
if (!candidate) return;
disposeComposer(candidate[0], candidate[1]);
}
};
const retainConversation = (conversationId: string) => {
activeConversationCounts.set(conversationId, (activeConversationCounts.get(conversationId) ?? 0) + 1);
};
const releaseConversation = (conversationId: string) => {
const count = activeConversationCounts.get(conversationId);
if (count === undefined) return;
if (count <= 1) activeConversationCounts.delete(conversationId);
else activeConversationCounts.set(conversationId, count - 1);
evictInactiveComposers();
};
const getComposer = (conversationId: string, threadCore: ThreadCore) => {
const existing = composerByConversation.get(conversationId);
if (existing?.source === threadCore) {
composerByConversation.delete(conversationId);
composerByConversation.set(conversationId, existing);
return existing.composer;
}
const draft = existing
? {
attachments: existing.composer.attachments,
quote: existing.composer.quote,
role: existing.composer.role,
runConfig: existing.composer.runConfig,
text: existing.composer.text,
}
: undefined;
if (existing) disposeComposer(conversationId, existing);
const host = createSurfaceComposerHost(threadCore, boundRuntimeValues);
const composer = new AssistantSurfaceComposerRuntimeCore(host);
composerByConversation.set(conversationId, { composer, source: threadCore });
if (draft) {
composer.setText(draft.text);
composer.setRole(draft.role);
composer.setRunConfig(draft.runConfig);
composer.setQuote(draft.quote);
for (const attachment of draft.attachments) {
if (attachment.status.type === "complete" && attachment.content) {
void composer
.addAttachment({
id: attachment.id,
type: attachment.type,
name: attachment.name,
contentType: attachment.contentType,
content: attachment.content,
})
.catch(() => {});
} else if (attachment.file) {
void composer.addAttachment(attachment.file).catch(() => {});
}
}
}
evictInactiveComposers();
return composer;
};
const getSurfaceCore = (conversationId: string, threadCore: ThreadCore) => {
const existing = surfaceCoreByConversation.get(conversationId);
if (existing?.source === threadCore) return existing.surface;
const composer = getComposer(conversationId, threadCore);
const surfaceCore = new Proxy(threadCore, {
get(target, property) {
if (property === "composer") return composer;
return bindRuntimeValue(target, property, boundRuntimeValues);
},
});
surfaceCoreByConversation.set(conversationId, { source: threadCore, surface: surfaceCore });
return surfaceCore;
};
const getConversationId = () => runtime.threads.mainItem.getState().id;
const surfaceBinding: INTERNAL.ThreadRuntimeCoreBinding = {
path: sharedBinding.path,
getState: () => getSurfaceCore(getConversationId(), sharedBinding.getState()),
outerSubscribe: (callback) => sharedBinding.outerSubscribe(callback),
subscribe(callback) {
let activeCore = sharedBinding.getState();
let activeConversationId = getConversationId();
let activeComposer = getComposer(activeConversationId, activeCore);
retainConversation(activeConversationId);
let unsubscribeComposer = activeComposer.subscribe(callback);
const handleSharedUpdate = () => {
const nextCore = sharedBinding.getState();
const nextConversationId = getConversationId();
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
unsubscribeComposer();
releaseConversation(activeConversationId);
activeCore = nextCore;
activeConversationId = nextConversationId;
retainConversation(activeConversationId);
activeComposer = getComposer(activeConversationId, activeCore);
unsubscribeComposer = activeComposer.subscribe(callback);
}
callback();
};
const unsubscribeThread = sharedBinding.subscribe(handleSharedUpdate);
const unsubscribeThreadItem = runtime.threads.mainItem.subscribe(handleSharedUpdate);
return () => {
unsubscribeComposer();
releaseConversation(activeConversationId);
unsubscribeThread();
unsubscribeThreadItem();
};
},
};
const sharedMainItem = runtime.threads.mainItem;
const mainItemBinding: INTERNAL.ThreadListItemRuntimeBinding = {
path: sharedMainItem.path,
getState: () => sharedMainItem.getState(),
subscribe: (callback) => sharedMainItem.subscribe(callback),
};
const surfaceThread = new INTERNAL.ThreadRuntimeImpl(surfaceBinding, mainItemBinding);
const sharedThreads = runtime.threads;
const getSurfaceThreadById = (threadId: string) => {
const requestedItem = sharedThreads.getItemById(threadId).getState();
if (requestedItem.id === sharedMainItem.getState().id) return surfaceThread;
// Homarr renders only the selected conversation. Preserve the upstream behavior for inactive
// thread inspection without manufacturing another composer outside this surface's LRU cache.
return sharedThreads.getById(threadId);
};
const surfaceThreads: ThreadListRuntime = {
main: surfaceThread,
mainItem: sharedMainItem,
getState: () => sharedThreads.getState(),
subscribe: (callback) => sharedThreads.subscribe(callback),
getById: getSurfaceThreadById,
getItemById: (threadId) => sharedThreads.getItemById(threadId),
getItemByIndex: (index) => sharedThreads.getItemByIndex(index),
getArchivedItemByIndex: (index) => sharedThreads.getArchivedItemByIndex(index),
switchToThread: (threadId, options) => sharedThreads.switchToThread(threadId, options),
switchToNewThread: () => sharedThreads.switchToNewThread(),
getLoadThreadsPromise: () => sharedThreads.getLoadThreadsPromise(),
reload: () => sharedThreads.reload(),
reloadMainThread: () => sharedThreads.reloadMainThread(),
loadMore: () => sharedThreads.loadMore(),
};
return {
threads: surfaceThreads,
thread: surfaceThread,
// The root runtime already owns the inherited model-context client. Re-registering that same
// provider for every surface would make one unmount remove it from the shared runtime's Set.
registerModelContextProvider: () => () => {},
dispose() {
activeConversationCounts.clear();
const threadCores = new Set([
...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source),
...Array.from(composerByConversation.values(), (entry) => entry.source),
]);
for (const threadCore of threadCores) {
if (threadCore.speech !== undefined) threadCore.stopSpeaking();
}
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
},
};
};
interface AssistantComposerSurfaceValue {
id: string;
}
const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceValue | null>(null);
export const useAssistantComposerSurface = () => {
const value = useContext(AssistantComposerSurfaceContext);
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary");
return value;
};
const surfaceElements = new Map<string, HTMLElement>();
let lastFocusedSurfaceElement: HTMLElement | null = null;
let lastFocusedSurfaceAt = 0;
interface AssistantSurfaceScrollPosition {
element: HTMLElement;
left: number;
top: number;
}
const captureAssistantSurfaceScrollPositions = () => {
const elements = new Set<HTMLElement>();
for (const surfaceElement of surfaceElements.values()) {
let element: HTMLElement | null = surfaceElement;
while (element) {
elements.add(element);
element = element.parentElement;
}
}
return Array.from(
elements,
(element): AssistantSurfaceScrollPosition => ({
element,
left: element.scrollLeft,
top: element.scrollTop,
}),
);
};
const restoreAssistantSurfaceScrollPositions = (positions: AssistantSurfaceScrollPosition[]) => {
for (const { element, left, top } of positions) {
if (!element.isConnected) continue;
element.scrollLeft = left;
element.scrollTop = top;
}
};
const getSurfaceIdForElement = (element: Element | null) => {
if (!element) return null;
for (const [surfaceId, surfaceElement] of surfaceElements) {
if (surfaceElement.contains(element)) return surfaceId;
}
return null;
};
const getClipboardAttachmentName = (file: File, index: number) => {
if (file.name.trim().length > 0) return file.name;
const extension = file.type.split("/")[1]?.replace("jpeg", "jpg") ?? "bin";
return `pasted-file-${index + 1}.${extension}`;
};
const AssistantComposerSurfaceEvents = () => {
const t = useI18n("assistant");
useAuiEvent("composer.attachmentAddError", ({ message }) => {
showErrorNotification({ title: t("attachments.errorTitle"), message });
});
return null;
};
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
const t = useI18n("assistant");
const aui = useAui();
const instanceId = useId();
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
const elementRef = useRef<HTMLDivElement>(null);
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]);
useLayoutEffect(() => {
const element = elementRef.current;
if (!element) return undefined;
surfaceElements.set(resolvedSurfaceId, element);
return () => {
if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId);
};
}, [resolvedSurfaceId]);
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.closest("[data-assistant-composer-input]")) return;
const files = Array.from(event.clipboardData.files);
if (files.length === 0) return;
event.preventDefault();
const composer = aui.composer();
const acceptedTypes = new Set(
composer
.getState()
.attachmentAccept.split(",")
.map((type) => type.trim())
.filter(Boolean),
);
files.forEach((file, index) => {
if (
file.type.startsWith("image/") &&
!acceptedTypes.has("*") &&
!acceptedTypes.has(file.type) &&
!acceptedTypes.has("image/*")
) {
showErrorNotification({
title: t("attachments.errorTitle"),
message: t("attachments.imageUnsupported"),
});
return;
}
const attachment =
file.name.trim().length > 0
? file
: new File([file], getClipboardAttachmentName(file, index), {
type: file.type,
lastModified: file.lastModified,
});
void composer.addAttachment(attachment).catch(() => {
// The scoped composer event above presents the adapter's actionable error message.
});
});
};
return (
<AssistantComposerSurfaceContext.Provider value={contextValue}>
<div
ref={elementRef}
style={{ display: "contents" }}
onFocusCapture={(event) => {
if (!(event.target instanceof HTMLElement)) return;
lastFocusedSurfaceElement = event.target;
lastFocusedSurfaceAt = performance.now();
}}
onPasteCapture={handlePasteCapture}
>
{children}
</div>
</AssistantComposerSurfaceContext.Provider>
);
};
/**
* The current react-lexical FocusPlugin focuses every mounted composer when a run starts. Mount
* this once above all surfaces so programmatic focus returns to the element/surface the user was
* actually interacting with after those listeners have run.
*/
export const AssistantRunFocusPreserver = () => {
useAuiEvent("thread.runStart", () => {
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
// Sending can briefly move focus to <body> while Lexical reconciles the editor. Retain the
// last connected assistant input so the run still belongs to the surface the user submitted.
const recentlyFocusedSurface = performance.now() - lastFocusedSurfaceAt < 1000;
const focusedBeforeRun =
activeElement && activeElement !== document.body
? activeElement
: recentlyFocusedSurface && lastFocusedSurfaceElement?.isConnected
? lastFocusedSurfaceElement
: activeElement;
const surfaceIdBeforeRun = getSurfaceIdForElement(focusedBeforeRun);
const scrollPositionsBeforeRun = captureAssistantSurfaceScrollPositions();
let isRestoring = false;
const restoreFocus = () => {
if (isRestoring) return;
const surfaceIdAfterRun = getSurfaceIdForElement(document.activeElement);
const movedToAnotherSurface = surfaceIdAfterRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun;
const leftInitiatingSurface = surfaceIdBeforeRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun;
if (!movedToAnotherSurface && !leftInitiatingSurface) return;
isRestoring = true;
try {
if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true });
if (
getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
// Focusing the other mounted Lexical composer may already have scrolled the dashboard or
// one of its nested board containers. Put every assistant surface ancestor back before the
// browser paints so a send from the floating panel never jumps to the board widget.
restoreAssistantSurfaceScrollPositions(scrollPositionsBeforeRun);
} finally {
isRestoring = false;
}
};
queueMicrotask(() => queueMicrotask(restoreFocus));
requestAnimationFrame(restoreFocus);
});
return null;
};
/**
* Gives every rendered assistant surface one shared local composer while preserving the shared
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI.
*/
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => {
const parentAui = useAui();
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
const surfaceRuntime = useMemo(() => {
if (!sharedRuntime) throw new Error("Assistant runtime is not available for this composer surface");
return createAssistantSurfaceRuntime(sharedRuntime);
}, [sharedRuntime]);
useEffect(() => () => surfaceRuntime.dispose(), [surfaceRuntime]);
return (
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
<AssistantComposerSurfaceEvents />
{children}
</AssistantRuntimeProvider>
);
};
@@ -0,0 +1,46 @@
import { describe, expect, test, vi } from "vitest";
import { sendAssistantPrompt } from "./assistant-send";
describe("sendAssistantPrompt", () => {
test("appends a trimmed user prompt and explicitly starts a run", () => {
const append = vi.fn();
const runtime = {
composer: () => ({ getState: () => ({ runConfig: { custom: { source: "spotlight" } } }) }),
thread: () => ({ append }),
};
expect(sendAssistantPrompt(runtime, " Check my services ")).toBe(true);
expect(append).toHaveBeenCalledWith({
role: "user",
content: [{ type: "text", text: "Check my services" }],
runConfig: { custom: { source: "spotlight" } },
startRun: true,
});
});
test("does not replace an existing composer draft", () => {
const append = vi.fn();
const getState = vi.fn(() => ({ text: "Keep this draft", runConfig: {} }));
const runtime = {
composer: () => ({ getState }),
thread: () => ({ append }),
};
sendAssistantPrompt(runtime, "Run a health check");
expect(getState().text).toBe("Keep this draft");
expect(append).toHaveBeenCalledOnce();
});
test("ignores empty prompts without touching the runtime", () => {
const runtime = {
composer: vi.fn(),
thread: vi.fn(),
};
expect(sendAssistantPrompt(runtime, " ")).toBe(false);
expect(runtime.composer).not.toHaveBeenCalled();
expect(runtime.thread).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
import type { AssistantClient } from "@assistant-ui/react";
type AssistantRunConfig = ReturnType<ReturnType<AssistantClient["composer"]>["getState"]>["runConfig"];
type AssistantAppendMessage = Parameters<ReturnType<AssistantClient["thread"]>["append"]>[0];
type AssistantPromptRuntime = {
composer: () => { getState: () => { runConfig: AssistantRunConfig } };
thread: () => { append: (message: AssistantAppendMessage) => void };
};
export const sendAssistantPrompt = (runtime: AssistantPromptRuntime, prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return false;
runtime.thread().append({
role: "user",
content: [{ type: "text", text }],
runConfig: runtime.composer().getState().runConfig,
startRun: true,
});
return true;
};
@@ -0,0 +1,29 @@
"use client";
import { useMemo } from "react";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
/**
* Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the
* assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap.
*/
export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
const t = useI18n("assistant");
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description,
unavailable: true,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (_query: string) => ({ type: "none" as const }),
}),
[description, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
};
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
describe("createAssistantPromptInteraction", () => {
it("sends an existing default-search query immediately", () => {
const sendPrompt = vi.fn(() => true);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted,
prompt: " Check my services ",
});
expect(interaction.type).toBe("javaScript");
if (interaction.type !== "javaScript") return;
interaction.onSelect();
expect(interaction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).toHaveBeenCalledOnce();
});
it("keeps Spotlight open when the prompt is empty", () => {
const sendPrompt = vi.fn();
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
expect(interaction.type).toBe("children");
if (interaction.type !== "children") return;
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();
expect(onPromptAccepted).not.toHaveBeenCalled();
});
it("trims and sends a prompt in the background", () => {
const sendPrompt = vi.fn(() => true);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
expect(interaction.type).toBe("children");
if (interaction.type !== "children") return;
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(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).toHaveBeenCalledOnce();
});
it("keeps Spotlight open when the runtime rejects the prompt", () => {
const sendPrompt = vi.fn(() => false);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted,
prompt: "Check my services",
});
expect(interaction.type).toBe("javaScript");
if (interaction.type !== "javaScript") return;
interaction.onSelect();
expect(interaction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).not.toHaveBeenCalled();
});
it("cannot trigger while the assistant is already handling a request", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted: vi.fn(),
prompt: "Check my services",
canSend: false,
});
expect(interaction).toEqual({ type: "none" });
expect(sendPrompt).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
"use client";
import { Group, Kbd, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconArrowUp, IconRobot } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface AssistantPromptInteractionOptions {
sendPrompt: (prompt: string) => boolean;
onPromptAccepted: () => void;
prompt?: string;
canSend?: boolean;
}
const AssistantPromptDetail = () => {
const t = useI18n("assistant");
return (
<Group px="md" py="sm" wrap="nowrap">
<ThemeIcon 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 = useI18n("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,
onPromptAccepted,
prompt = "",
canSend = true,
}: AssistantPromptInteractionOptions) => {
if (!canSend) return { type: "none" as const };
const trimmedPrompt = prompt.trim();
if (trimmedPrompt.length > 0) {
return {
type: "javaScript" as const,
onSelect: () => {
if (sendPrompt(trimmedPrompt)) onPromptAccepted();
},
closeSpotlightOnTrigger: false,
};
}
return {
type: "children",
option: {},
DetailComponent: AssistantPromptDetail,
useActions: (_options: Record<string, unknown>, query: string) => {
const nextPrompt = query.trim();
return [
{
key: "send-assistant-prompt",
Component: () => <AssistantPromptAction hasPrompt={nextPrompt.length > 0} />,
useInteraction: () => ({
type: "javaScript" as const,
onSelect: () => {
if (nextPrompt.length > 0 && sendPrompt(nextPrompt)) onPromptAccepted();
},
closeSpotlightOnTrigger: false,
}),
},
];
},
} as const;
};
@@ -0,0 +1,162 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { browserToolContracts, normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
const supportedToolArguments = {
ask_user: {
question: "How should the app be added?",
options: [
{ id: "yes", label: "Yes", kind: "affirmative" },
{ id: "no", label: "No", kind: "negative" },
],
},
configure_app: {
name: "Wikipedia",
description: "The Free Encyclopedia",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
pingUrl: "https://wikipedia.org",
},
configure_board_settings: {
boardId: "board-1",
boardName: "Home",
summary: "Improve dashboard contrast.",
changes: { primaryColor: "#7C3AED" },
},
configure_widget: {
boardId: "board-1",
boardName: "Home",
kind: "notebook",
summary: "Add a formatted Plex self-hosting guide.",
options: { content: "<h2>Self-host Plex</h2><p>Install Docker first.</p>" },
integrationIds: [],
},
navigate_to_route: { path: "/manage/apps" },
open_command_menu: {},
open_media_request_search: {},
refresh_current_view: {},
} satisfies Record<keyof typeof browserToolContracts, unknown>;
describe("assistant human tool contracts", () => {
test("keeps a valid fixture and JSON schema for every supported browser tool", () => {
expect(Object.keys(supportedToolArguments).toSorted()).toEqual(Object.keys(browserToolContracts).toSorted());
for (const [toolName, contract] of Object.entries(browserToolContracts)) {
const args = supportedToolArguments[toolName as keyof typeof supportedToolArguments];
expect(contract.parameters.safeParse(args).success, toolName).toBe(true);
expect(() => z.toJSONSchema(contract.parameters), toolName).not.toThrow();
}
});
test("accepts a structured question with reusable choices", () => {
expect(
browserToolContracts.ask_user.parameters.safeParse({
question: "How should the app be added?",
options: [
{ id: "yes", label: "Yes", kind: "affirmative" },
{ id: "no", label: "No", kind: "negative" },
{
id: "alternative",
label: "Choose an alternative",
description: "Adjust the proposed app first.",
kind: "alternative",
},
],
}).success,
).toBe(true);
});
test("rejects questions without enough choices", () => {
expect(
browserToolContracts.ask_user.parameters.safeParse({
question: "Continue?",
options: [{ id: "yes", label: "Yes", kind: "affirmative" }],
}).success,
).toBe(false);
});
test("requires every structured choice to declare its category", () => {
expect(
browserToolContracts.ask_user.parameters.safeParse({
question: "Continue?",
options: [
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
],
}).success,
).toBe(false);
});
test("allows partial app defaults for the native form", () => {
expect(
browserToolContracts.configure_app.parameters.safeParse({
name: "YouTube",
href: "https://youtube.com",
}).success,
).toBe(true);
});
test("requires an app name before opening the native form", () => {
expect(
browserToolContracts.configure_app.parameters.safeParse({
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
}).success,
).toBe(false);
});
test("accepts a scoped board settings proposal", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
summary: "Improve dashboard contrast and tighten card spacing.",
changes: {
primaryColor: "#7C3AED",
customCss: ".mantine-Card-root { padding: 0.75rem; }",
},
}).success,
).toBe(true);
});
test("rejects invalid board colors and oversized CSS", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
summary: "Change styling",
changes: { primaryColor: "purple", customCss: "a".repeat(16_385) },
}).success,
).toBe(false);
});
test("converts the board review parameters to a provider JSON schema", () => {
expect(() => z.toJSONSchema(browserToolContracts.configure_board_settings.parameters)).not.toThrow();
});
test("accepts a native widget configuration with proposed options and integrations", () => {
expect(
browserToolContracts.configure_widget.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
kind: "mediaServer",
summary: "Show current Plex streams.",
options: { showOnlyPlaying: true },
integrationIds: ["plex-1"],
}).success,
).toBe(true);
expect(() => z.toJSONSchema(browserToolContracts.configure_widget.parameters)).not.toThrow();
});
test("only preserves usable app icon URLs", () => {
expect(normalizeAssistantAppIconUrl("youtube")).toBe("");
expect(normalizeAssistantAppIconUrl("/api/media/youtube.svg")).toBe("/api/media/youtube.svg");
expect(normalizeAssistantAppIconUrl("https://icons.example/youtube.svg")).toBe("https://icons.example/youtube.svg");
expect(normalizeAssistantAppIconUrl("http://icons.example/youtube.svg")).toBe("http://icons.example/youtube.svg");
expect(normalizeAssistantAppIconUrl("//icons.example/youtube.svg")).toBe("");
expect(normalizeAssistantAppIconUrl("javascript:alert(1)")).toBe("");
expect(normalizeAssistantAppIconUrl("data:image/svg+xml,<svg />")).toBe("");
expect(normalizeAssistantAppIconUrl("file:///etc/passwd")).toBe("");
});
});
@@ -0,0 +1,163 @@
import { z } from "zod/v4";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
widgetKinds,
} from "@homarr/definitions";
export const assistantAskUserOptionKinds = ["affirmative", "negative", "alternative"] as const;
export type AssistantAskUserOptionKind = (typeof assistantAskUserOptionKinds)[number];
const askUserOptionSchema = z.object({
id: z.string().trim().min(1).max(48),
label: z.string().trim().min(1).max(80),
description: z.string().trim().max(180).optional(),
kind: z
.enum(assistantAskUserOptionKinds)
.describe(
"Classify agreement, approval, or proceeding as affirmative; refusal or stopping as negative; and unrelated selections as alternative.",
),
});
const boardImageValueSchema = z.string().trim().max(2_048).nullable();
const boardTitleValueSchema = z.string().trim().max(255).nullable();
const boardColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
export const assistantBoardSettingsChangesSchema = z
.object({
pageTitle: boardTitleValueSchema.optional(),
metaTitle: boardTitleValueSchema.optional(),
logoImageUrl: boardImageValueSchema.optional(),
faviconImageUrl: boardImageValueSchema.optional(),
backgroundImageUrl: boardImageValueSchema.optional(),
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
primaryColor: boardColorSchema.optional(),
secondaryColor: boardColorSchema.optional(),
opacity: z.number().min(0).max(100).optional(),
customCss: z
.string()
.max(16_384)
.optional()
.describe(
"The complete resulting board stylesheet. Preserve existing rules from board_getBoardSettings unless the user explicitly asks to replace them.",
),
iconColor: boardColorSchema.nullable().optional(),
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
disableStatus: z.boolean().optional(),
})
.describe("Only the board fields the user asked to change.");
export const browserToolContracts = {
ask_user: {
description:
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
parameters: z.object({
question: z.string().trim().min(1).max(240),
description: z.string().trim().max(400).optional(),
options: z.array(askUserOptionSchema).min(2).max(4),
allowOther: z.boolean().optional(),
}),
},
configure_app: {
description:
"Open Homarr's native app form with the best available app details already filled in. Its icon picker searches the local Homarr icon repository. Include the inferred description, href, and pingUrl when they are known. Use this before app.create instead of inventing an external icon URL.",
parameters: z.object({
name: z.string().trim().min(1).max(64),
description: z.string().trim().max(512).nullable().optional(),
iconUrl: z
.string()
.trim()
.max(2_048)
.optional()
.describe(
"An exact icon URL returned by Homarr's icon_findIcons tool. Omit this field if no icon tool result is available; the native form will search locally from the app name.",
),
href: z.string().trim().max(2_048).nullable().optional(),
pingUrl: z.string().trim().max(2_048).nullable().optional(),
}),
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
summary: z.string().trim().min(1).max(400),
changes: assistantBoardSettingsChangesSchema,
}),
},
configure_widget: {
description:
"Open Homarr's native widget editor with inferred options and integrations preselected. Use this before board_addItem for every dashboard widget, including app and notebook widgets. The editor applies widget defaults, validates supported options, and only offers compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds directly with board_addItem.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
kind: z.enum(widgetKinds),
summary: z.string().trim().min(1).max(400),
options: z.record(z.string(), z.unknown()).optional(),
integrationIds: z.array(z.string().trim().min(1).max(64)).max(32).optional(),
}),
},
navigate_to_route: {
description:
"Navigate the current Homarr tab to a safe internal route. Only same-origin paths beginning with a single slash are accepted.",
parameters: z.object({
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
}),
},
open_command_menu: {
description: "Open Homarr's command and search menu.",
parameters: z.object({}),
},
open_media_request_search: {
description: "Open Homarr's media request search interface.",
parameters: z.object({}),
},
refresh_current_view: {
description:
"Refetch the active Homarr page and its visible dashboard data. Use when the user asks to reload, or when the current view still needs to reflect an already completed change. Never use this before a mutation is approved and completed.",
parameters: z.object({}),
},
} as const;
export type AskUserArgs = z.infer<(typeof browserToolContracts)["ask_user"]["parameters"]>;
export type AskUserResult = {
answer: string;
optionId?: string;
optionKind?: AssistantAskUserOptionKind;
source: "option" | "other";
};
export type ConfigureAppArgs = z.infer<(typeof browserToolContracts)["configure_app"]["parameters"]>;
export type ConfigureBoardSettingsArgs = z.infer<
(typeof browserToolContracts)["configure_board_settings"]["parameters"]
>;
export type AssistantBoardSettingsChanges = z.infer<typeof assistantBoardSettingsChangesSchema>;
export type ConfigureBoardSettingsResult =
| (AssistantBoardSettingsChanges & { id: string })
| { id: string; cancelled: true };
export type ConfigureWidgetArgs = z.infer<(typeof browserToolContracts)["configure_widget"]["parameters"]>;
export type ConfigureWidgetResult =
| {
boardId: string;
kind: ConfigureWidgetArgs["kind"];
options: Record<string, unknown>;
integrationIds: string[];
}
| {
boardId: string;
kind: ConfigureWidgetArgs["kind"];
cancelled: true;
reason?: "no-compatible-integration" | "user-cancelled";
};
export const normalizeAssistantAppIconUrl = (value: string | undefined) => {
if (!value) return "";
if (value.startsWith("/") && !value.startsWith("//")) return value;
if (!URL.canParse(value)) return "";
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:" ? value : "";
};
@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
describe("getAssistantIconSearchQuery", () => {
test("extracts and trims the streamed icon search target", () => {
expect(getAssistantIconSearchQuery("icon_findIcons", { searchText: " homarr " })).toBe("homarr");
});
test("distinguishes browsing all icons from unrelated tools", () => {
expect(getAssistantIconSearchQuery("icon_findIcons", {})).toBe("");
expect(getAssistantIconSearchQuery("app_create", { searchText: "homarr" })).toBeNull();
});
});
@@ -0,0 +1,9 @@
import { isRecord } from "@homarr/common";
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
export const getAssistantIconSearchQuery = (toolName: string, args: unknown) => {
if (toolName !== "icon_findIcons") return null;
const searchText = asRecord(args)?.searchText;
return typeof searchText === "string" && searchText.trim().length > 0 ? searchText.trim() : "";
};
@@ -0,0 +1,435 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useId, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { ChainOfThoughtPrimitive } from "@assistant-ui/react";
import {
Badge,
Box,
Button,
Collapse,
Group,
Image,
Loader,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { IconCheck, IconChevronDown, IconLink, IconRobot, IconX } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { isRecord } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { hasModifiedLinkClick, ReasoningVisibilityContext } from "./assistant-message-content";
import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getToolResultPresentation, hasMeaningfulToolResultError } from "./assistant-tool-result";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
const formatToolResultValue = (value: string | number | boolean) =>
typeof value === "number" ? value.toLocaleString() : String(value);
const ToolResultPresentation = ({ result, toolName }: { result: unknown; toolName: string }) => {
const presentation = getToolResultPresentation(result, { toolName });
if (!presentation) return null;
if (presentation.type === "text") {
return (
<Text size="sm" className={classes.toolResultText}>
{presentation.text}
</Text>
);
}
if (presentation.type === "properties") {
return (
<Box className={classes.toolResultProperties}>
{presentation.fields.map((field) => (
<Box key={field.label} className={classes.toolResultProperty}>
<Text size="xs" c="dimmed" lineClamp={1}>
{field.label}
</Text>
<Text size="sm" fw={600} lineClamp={2}>
{formatToolResultValue(field.value)}
</Text>
</Box>
))}
</Box>
);
}
if (presentation.type === "icons") {
if (presentation.items.length === 0) return null;
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultIconCollection}>
{presentation.items.map((item) => (
<Box key={`${item.repository ?? "icon"}-${item.url}`} className={classes.toolResultIconItem}>
<Box className={classes.toolResultIconPreview}>
<Image
src={item.url}
alt={item.name}
className={classes.toolResultIconImage}
fit="contain"
loading="lazy"
/>
</Box>
<Group gap={4} mt="xs" wrap="nowrap" justify="space-between">
<Text size="xs" fw={650} lineClamp={1} title={item.name}>
{item.name}
</Text>
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
{item.variant}
</Badge>
</Group>
{item.repository && (
<Text size="xs" c="dimmed" lineClamp={1} title={item.repository}>
{item.repository}
</Text>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
}
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultCollection}>
{presentation.items.map((item, index) => (
<Box key={`${item.title}-${index}`} className={classes.toolResultItem}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Box className={classes.toolResultIdentity}>
<Text size="sm" fw={650} lineClamp={1}>
{item.title}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</Box>
{item.badges.length > 0 && (
<Group gap={4} wrap="nowrap">
{item.badges.map((badge) => (
<Badge key={badge} size="xs" variant="light" color="gray">
{badge}
</Badge>
))}
</Group>
)}
</Group>
{item.fields.length > 0 && (
<Group gap="xs" mt={6} wrap="wrap">
{item.fields.map((field) => (
<Text key={field.label} size="xs" c="dimmed">
{field.label}:{" "}
<Text component="span" inherit c="var(--mantine-color-text)" fw={600}>
{formatToolResultValue(field.value)}
</Text>
</Text>
))}
</Group>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
};
const getToolResultNavigation = (result: unknown) => {
if (!isRecord(result)) return [];
const record = result;
const candidates = [
{ href: record.previewPath, translationKey: "toolNavigation.openPreview" as const },
{ href: record.managementPath, translationKey: "toolNavigation.openWidget" as const },
];
return candidates.filter(
(candidate): candidate is { href: string; translationKey: (typeof candidate)["translationKey"] } =>
typeof candidate.href === "string" && candidate.href.startsWith("/") && !candidate.href.startsWith("//"),
);
};
const ToolResultNavigation = ({ result }: { result: unknown }) => {
const t = useI18n("assistant");
const router = useRouter();
const links = getToolResultNavigation(result);
if (links.length === 0) return null;
return (
<Group className={classes.toolResultNavigation} gap="xs" wrap="wrap">
{links.map((link) => (
<Button
key={`${link.translationKey}-${link.href}`}
component="a"
href={link.href}
size="compact-xs"
variant="light"
leftSection={<IconLink size={13} aria-hidden />}
onMouseEnter={() => router.prefetch(link.href)}
onFocus={() => router.prefetch(link.href)}
onClick={(event) => {
if (hasModifiedLinkClick(event)) return;
event.preventDefault();
router.push(link.href);
}}
>
{t(link.translationKey)}
</Button>
))}
</Group>
);
};
const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => {
const navigation = getToolResultNavigation(result);
if (navigation.length === 0) return <ToolResultPresentation result={result} toolName={toolName} />;
return (
<Stack gap="xs">
<ToolResultPresentation result={result} toolName={toolName} />
<ToolResultNavigation result={result} />
</Stack>
);
};
const AgentTraceToolContext = createContext(false);
const getToolStatusPresentation = (denied: boolean, failed: boolean, successful: boolean) => {
if (denied || failed) return { color: "red", Icon: IconX } as const;
if (successful) return { color: "green", Icon: IconCheck } as const;
return { color: "gray", Icon: IconRobot } as const;
};
export const ToolPart = ({
toolCallId,
toolName,
args,
result,
isError,
status,
approval,
respondToApproval,
timing,
}: ToolCallMessagePartProps) => {
const t = useI18n("assistant");
const compact = useContext(AgentTraceToolContext);
const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
const failed = !denied && (isError === true || status?.type === "incomplete" || hasMeaningfulToolResultError(result));
const successful = completed && !denied && !failed;
const compactPresentation = compact && !awaitingApproval;
const traceTarget = getAssistantToolTraceTarget(args);
const iconSearchQuery = getAssistantIconSearchQuery(toolName, args);
let displayName = toolName.replaceAll("_", " ");
if (iconSearchQuery !== null) {
displayName =
iconSearchQuery.length > 0
? t("toolActivity.iconSearch", { query: iconSearchQuery })
: t("toolActivity.iconBrowse");
}
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
const { color: statusColor, Icon: StatusIcon } = getToolStatusPresentation(denied, failed, successful);
let statusLabel = t("working");
if (successful) statusLabel = t("complete");
if (failed) statusLabel = t("failed");
if (denied) statusLabel = t("denied");
if (awaitingApproval) statusLabel = t("approvalRequired");
const autoApprovalInProgress = useAssistantAutomaticAction({
toolCallId,
ready: awaitingApproval,
completed: !awaitingApproval,
confirm: () => {
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
},
});
return (
<Box className={classes.tool} data-compact={compactPresentation || undefined}>
<UnstyledButton
className={classes.toolHeader}
onClick={() => setOpened((current) => !current)}
aria-expanded={opened}
>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color={statusColor}>
<StatusIcon size={13} />
</ThemeIcon>
<Box miw={0}>
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
{displayName}
</Text>
{compactPresentation && traceTarget && (
<Text size="xs" c="dimmed" lineClamp={1}>
{traceTarget}
</Text>
)}
</Box>
</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">
{statusLabel}
</Text>
<IconChevronDown
size={14}
className={classes.disclosureIcon}
data-opened={opened || awaitingApproval || undefined}
/>
</Group>
</Group>
</UnstyledButton>
{awaitingApproval && (
<Box className={classes.approvalPanel}>
<Text size="sm" fw={600}>
{t("approvalDescription")}
</Text>
<ToolResultPreview result={args} toolName={toolName} />
{autoApprovalInProgress ? (
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" />
<Text size="sm" fw={600}>
{t("autoApproval.approving")}
</Text>
</Group>
) : (
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
<Button
size="md"
fullWidth
leftSection={<IconCheck size={18} />}
loading={approvalResponse === "approve"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("approve");
respondToApproval({ approved: true });
}}
>
{t("approveAndRun")}
</Button>
<Button
size="md"
fullWidth
variant="default"
leftSection={<IconX size={18} />}
loading={approvalResponse === "deny"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("deny");
respondToApproval({ approved: false });
}}
>
{t("deny")}
</Button>
</Group>
)}
</Box>
)}
{!compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Collapse expanded={opened}>
<Stack gap="xs" mt="sm">
{compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<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>
);
};
export const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => (
<AgentTraceToolContext.Provider value>
<Stack className={classes.agentTraceTools} gap={4}>
{children}
</Stack>
</AgentTraceToolContext.Provider>
);
const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
<Stack className={classes.reasoningParts} gap={6}>
{children}
</Stack>
);
export const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => {
const t = useI18n("assistant");
const contentId = useId();
const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext);
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
const running = chainStatus.type === "running";
return (
<ChainOfThoughtPrimitive.Root className={classes.reasoning} data-opened={!collapsed || undefined}>
<ChainOfThoughtPrimitive.AccordionTrigger asChild>
<UnstyledButton
className={classes.reasoningToggle}
type="button"
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setPreferredCollapsed(!collapsed)}
>
<Group gap="xs" wrap="nowrap">
{(running || chainStatus.type === "requires-action") && (
<AssistantDotMatrix state={running ? "thinking" : "waiting"} role="presentation" aria-hidden />
)}
<Text size="xs" fw={650} c="dimmed">
{t("reasoning")}
</Text>
</Group>
<IconChevronDown size={14} className={classes.reasoningChevron} />
</UnstyledButton>
</ChainOfThoughtPrimitive.AccordionTrigger>
<Box id={contentId} className={classes.reasoningContent} aria-busy={running} hidden={collapsed}>
<ChainOfThoughtLayout>{children}</ChainOfThoughtLayout>
</Box>
</ChainOfThoughtPrimitive.Root>
);
};
@@ -0,0 +1,216 @@
import { describe, expect, test } from "vitest";
import {
getToolResultPresentation,
hasMeaningfulToolResultError,
humanizeToolResultKey,
} from "./assistant-tool-result";
describe("hasMeaningfulToolResultError", () => {
test.each([
[null, false],
[undefined, false],
[{ error: null, status: "ok", value: 42 }, false],
[{ error: " ", status: "ok" }, false],
[{ error: {}, status: "ok" }, true],
[{ error: { message: "failed" } }, true],
[{ error: "failed" }, true],
])("classifies %j", (value, expected) => {
expect(hasMeaningfulToolResultError(value)).toBe(expected);
});
});
describe("getToolResultPresentation", () => {
test("keeps successful fields when error is explicitly null", () => {
expect(getToolResultPresentation({ error: null, status: "ok", value: 42 })).toEqual({
type: "properties",
fields: [
{ label: "Status", value: "ok" },
{ label: "Value", value: 42 },
],
});
});
test("suppresses presentation for a meaningful error object", () => {
expect(getToolResultPresentation({ error: { message: "failed" }, status: "error" })).toBeUndefined();
});
test("presents icon search variants as image previews instead of the catalog count", () => {
expect(
getToolResultPresentation(
{
icons: [
{
id: "repository-id",
slug: "homarr-labs/dashboard-icons",
icons: [
{
id: "svg-id",
name: "discord.svg",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discord.svg",
},
{
id: "png-id",
name: "discord.png",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/discord.png",
},
],
},
{ id: "empty-repository", slug: "another/repository", icons: [] },
],
countIcons: 28_154,
},
{ toolName: "icon_findIcons" },
),
).toEqual({
type: "icons",
totalCount: 2,
items: [
{
name: "discord.svg",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discord.svg",
repository: "homarr-labs/dashboard-icons",
variant: "SVG",
},
{
name: "discord.png",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/discord.png",
repository: "homarr-labs/dashboard-icons",
variant: "PNG",
},
],
});
});
test("does not render unsafe icon URLs", () => {
expect(
getToolResultPresentation(
{
icons: [
{
slug: "unsafe/repository",
icons: [
{ name: "script.svg", url: "javascript:alert(1)" },
{ name: "credential.svg", url: "https://user:password@example.com/icon.svg" },
],
},
],
countIcons: 2,
},
{ toolName: "icon_findIcons" },
),
).toEqual({ type: "icons", totalCount: 0, items: [] });
});
test("presents common widget collections as compact cards", () => {
expect(
getToolResultPresentation({
containers: [
{ id: "one", name: "Plex", state: "running", image: "plex:latest", cpuUsage: 2.5 },
{ id: "two", name: "Sonarr", state: "stopped", image: "sonarr:latest", memoryUsage: 128 },
],
timestamp: "2026-07-30T10:00:00.000Z",
}),
).toEqual({
type: "collection",
totalCount: 2,
items: [
{
title: "Plex",
description: "plex:latest",
badges: ["running"],
fields: [{ label: "Cpu Usage", value: 2.5 }],
},
{
title: "Sonarr",
description: "sonarr:latest",
badges: ["stopped"],
fields: [{ label: "Memory Usage", value: 128 }],
},
],
});
});
test("uses integration identity and nested widget summary values", () => {
expect(
getToolResultPresentation([
{
integration: { id: "abc", name: "Pi-hole", kind: "pihole" },
summary: { queries: 1000, blocked: 125, token: "never render this" },
},
]),
).toEqual({
type: "collection",
totalCount: 1,
items: [
{
title: "Pi-hole",
badges: ["pihole"],
fields: [
{ label: "Queries", value: 1000 },
{ label: "Blocked", value: 125 },
],
},
],
});
});
test("never includes secret-looking values in an inline presentation", () => {
expect(
getToolResultPresentation({
version: "1.2.3",
apiKey: "secret",
access_token: "secret",
enabled: true,
}),
).toEqual({
type: "properties",
fields: [
{ label: "Version", value: "1.2.3" },
{ label: "Enabled", value: true },
],
});
});
test("removes URL credentials and query parameters from inline descriptions", () => {
expect(
getToolResultPresentation({
apps: [
{
name: "Sonarr",
url: "https://user:password@sonarr.example/api/v3/series?apikey=secret#calendar",
},
],
}),
).toEqual({
type: "collection",
totalCount: 1,
items: [
{
title: "Sonarr",
description: "https://sonarr.example/api/v3/series#calendar",
badges: [],
fields: [],
},
],
});
});
test("limits large collections while preserving their total", () => {
const result = getToolResultPresentation({
items: Array.from({ length: 8 }, (_, index) => ({ name: `Item ${index + 1}` })),
totalCount: 42,
});
expect(result?.type).toBe("collection");
if (result?.type !== "collection") return;
expect(result.items).toHaveLength(6);
expect(result.totalCount).toBe(42);
});
test("humanizes camel, snake, and kebab case labels", () => {
expect(humanizeToolResultKey("memoryUsage")).toBe("Memory Usage");
expect(humanizeToolResultKey("cached_input")).toBe("Cached input");
expect(humanizeToolResultKey("response-time")).toBe("Response time");
});
});
@@ -0,0 +1,257 @@
import { isRecord } from "@homarr/common";
const collectionKeys = [
"items",
"results",
"data",
"containers",
"apps",
"boards",
"integrations",
"widgets",
"users",
"requests",
"downloads",
"events",
] as const;
const titleKeys = ["name", "title", "label", "displayName", "hostname", "containerName", "id"] as const;
const descriptionKeys = ["description", "url", "href", "image", "version"] as const;
const badgeKeys = ["status", "state", "kind", "category", "type"] as const;
const excludedKeys = new Set([
...titleKeys,
...descriptionKeys,
...badgeKeys,
...collectionKeys,
"icon",
"iconUrl",
"logoImageUrl",
"imageUrl",
]);
const sensitiveKeyPattern = /(?:api.?key|authorization|cookie|credential|password|secret|token)/iu;
const maximumItems = 6;
const maximumIconItems = 8;
const maximumFields = 6;
type ToolResultPrimitive = string | number | boolean;
const redactUrlCredentials = (value: string) => {
if (!URL.canParse(value)) return value;
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return value;
if (!url.username && !url.password && !url.search) return value;
url.username = "";
url.password = "";
url.search = "";
return url.toString();
};
export interface ToolResultField {
label: string;
value: ToolResultPrimitive;
}
export interface ToolResultItem {
title: string;
description?: string;
badges: string[];
fields: ToolResultField[];
}
export interface ToolResultIconItem {
name: string;
url: string;
repository?: string;
variant: string;
}
export type ToolResultPresentation =
| {
type: "icons";
items: ToolResultIconItem[];
totalCount: number;
}
| {
type: "collection";
items: ToolResultItem[];
totalCount: number;
}
| {
type: "properties";
fields: ToolResultField[];
}
| {
type: "text";
text: string;
};
interface ToolResultPresentationOptions {
toolName?: string;
}
const asRecord = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);
export const hasMeaningfulToolResultError = (value: unknown) => {
const record = asRecord(value);
if (!record || !("error" in record)) return false;
const error = record.error;
if (error === null || error === undefined || error === false) return false;
if (typeof error === "string") return error.trim().length > 0;
return true;
};
const toDisplayValue = (value: unknown): ToolResultPrimitive | undefined => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 160) return undefined;
return redactUrlCredentials(trimmed);
}
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "boolean") return value;
return undefined;
};
export const humanizeToolResultKey = (key: string) =>
key
.replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2")
.replaceAll(/[_-]+/gu, " ")
.replace(/^./u, (character) => character.toUpperCase());
const getFirstString = (record: Record<string, unknown>, keys: readonly string[]) => {
for (const key of keys) {
const value = toDisplayValue(record[key]);
if (typeof value === "string") return value;
}
return undefined;
};
const getFields = (record: Record<string, unknown>, excludeIdentity = true) => {
const fields: ToolResultField[] = [];
const addEntries = (source: Record<string, unknown>) => {
for (const [key, value] of Object.entries(source)) {
if (fields.length >= maximumFields) return;
if ((excludeIdentity && excludedKeys.has(key)) || sensitiveKeyPattern.test(key)) continue;
const displayValue = toDisplayValue(value);
if (displayValue === undefined) continue;
fields.push({ label: humanizeToolResultKey(key), value: displayValue });
}
};
addEntries(record);
for (const nestedKey of ["data", "summary", "healthInfo", "metrics"] as const) {
const nested = asRecord(record[nestedKey]);
if (nested) addEntries(nested);
}
return fields;
};
const toItem = (value: unknown, index: number): ToolResultItem | undefined => {
const primitive = toDisplayValue(value);
if (primitive !== undefined) {
return { title: String(primitive), badges: [], fields: [] };
}
const record = asRecord(value);
if (!record) return undefined;
const integration = asRecord(record.integration);
const identity = integration ?? record;
const title = getFirstString(record, titleKeys) ?? getFirstString(identity, titleKeys) ?? `Result ${index + 1}`;
const description = getFirstString(record, descriptionKeys) ?? getFirstString(identity, descriptionKeys);
const badges = badgeKeys
.flatMap((key) => {
const badgeValue = toDisplayValue(record[key] ?? identity[key]);
return badgeValue === undefined ? [] : [String(badgeValue)];
})
.filter((badgeValue, badgeIndex, allBadges) => allBadges.indexOf(badgeValue) === badgeIndex)
.slice(0, 3);
return { title, description, badges, fields: getFields(record) };
};
const findCollection = (record: Record<string, unknown>) => {
for (const key of collectionKeys) {
const value = record[key];
if (Array.isArray(value)) return value;
}
return undefined;
};
const getIconVariant = (name: string) => {
const extension = name.slice(name.lastIndexOf(".") + 1).trim();
return extension.length > 0 && extension !== name ? extension.toUpperCase() : "ICON";
};
const getSafeIconUrl = (value: unknown) => {
if (typeof value !== "string" || !URL.canParse(value)) return undefined;
const url = new URL(value);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined;
return value;
};
const getIconPresentation = (record: Record<string, unknown>): ToolResultPresentation | undefined => {
const repositories = record.icons;
if (!Array.isArray(repositories)) return undefined;
const isIconSearchResult =
"countIcons" in record || repositories.some((value) => Array.isArray(asRecord(value)?.icons));
if (!isIconSearchResult) return undefined;
const items = repositories.flatMap((value) => {
const repository = asRecord(value);
if (!repository || !Array.isArray(repository.icons)) return [];
const repositoryName = toDisplayValue(repository.slug);
return repository.icons.flatMap((iconValue) => {
const icon = asRecord(iconValue);
if (!icon) return [];
const name = toDisplayValue(icon.name);
const url = getSafeIconUrl(icon.url);
if (typeof name !== "string" || !url) return [];
return [
{
name,
url,
repository: typeof repositoryName === "string" ? repositoryName : undefined,
variant: getIconVariant(name),
},
];
});
});
return { type: "icons", items: items.slice(0, maximumIconItems), totalCount: items.length };
};
export const getToolResultPresentation = (
result: unknown,
options: ToolResultPresentationOptions = {},
): ToolResultPresentation | undefined => {
if (typeof result === "string") {
const text = result.trim();
if (text.length === 0) return undefined;
return { type: "text", text: text.length > 280 ? `${text.slice(0, 277)}…` : text };
}
const record = asRecord(result);
const iconPresentation = record && options.toolName === "icon_findIcons" ? getIconPresentation(record) : undefined;
if (iconPresentation) return iconPresentation;
const collection = Array.isArray(result) ? result : record ? findCollection(record) : undefined;
if (collection) {
const items = collection
.slice(0, maximumItems)
.map(toItem)
.filter((item): item is ToolResultItem => item !== undefined);
if (items.length === 0) return undefined;
const reportedTotal = record ? toDisplayValue(record.totalCount) : undefined;
return {
type: "collection",
items,
totalCount: typeof reportedTotal === "number" ? reportedTotal : collection.length,
};
}
if (!record || hasMeaningfulToolResultError(record)) return undefined;
const fields = getFields(record, false);
return fields.length > 0 ? { type: "properties", fields } : undefined;
};
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
describe("getAssistantToolTraceTarget", () => {
test("uses a human-readable tool argument instead of an opaque id", () => {
expect(getAssistantToolTraceTarget({ id: "app-1", name: " Wikipedia " })).toBe("Wikipedia");
expect(getAssistantToolTraceTarget({ integrationIds: ["integration-1"], searchText: " discord icon " })).toBe(
"discord icon",
);
});
test("does not expose arbitrary argument values in the collapsed trace", () => {
expect(getAssistantToolTraceTarget({ id: "secret-looking-id", apiKey: "do-not-show" })).toBeNull();
});
test("bounds long labels so the trace remains compact", () => {
expect(getAssistantToolTraceTarget({ title: "a".repeat(100) })).toBe(`${"a".repeat(77)}…`);
});
});
@@ -0,0 +1,18 @@
import { isRecord } from "@homarr/common";
const targetKeys = ["name", "title", "query", "searchText", "appName", "widgetName"] as const;
export const getAssistantToolTraceTarget = (args: unknown): string | null => {
if (!isRecord(args)) return null;
const record = args;
for (const key of targetKeys) {
const value = record[key];
if (typeof value !== "string") continue;
const normalized = value.trim().replaceAll(/\s+/gu, " ");
if (normalized.length === 0) continue;
return normalized.length > 80 ? `${normalized.slice(0, 77)}…` : normalized;
}
return null;
};
@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
describe("getNearestTriggerScrollTop", () => {
test("keeps a fully visible highlighted item in place", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 120,
viewportTop: 100,
viewportBottom: 400,
itemTop: 180,
itemBottom: 228,
}),
).toBe(120);
});
test("scrolls down only enough to reveal the highlighted item", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 120,
viewportTop: 100,
viewportBottom: 400,
itemTop: 388,
itemBottom: 436,
}),
).toBe(156);
});
test("scrolls up only enough to reveal the highlighted item", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 120,
viewportTop: 100,
viewportBottom: 400,
itemTop: 72,
itemBottom: 120,
}),
).toBe(92);
});
test("clamps upward scrolling at the start of the list", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 12,
viewportTop: 100,
viewportBottom: 400,
itemTop: 40,
itemBottom: 88,
}),
).toBe(0);
});
});
@@ -0,0 +1,17 @@
export const getNearestTriggerScrollTop = ({
scrollTop,
viewportTop,
viewportBottom,
itemTop,
itemBottom,
}: {
scrollTop: number;
viewportTop: number;
viewportBottom: number;
itemTop: number;
itemBottom: number;
}) => {
if (itemTop < viewportTop) return Math.max(0, scrollTop - (viewportTop - itemTop));
if (itemBottom > viewportBottom) return scrollTop + (itemBottom - viewportBottom);
return scrollTop;
};
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { getSafeAssistantHttpUrl } from "./assistant-url";
describe("getSafeAssistantHttpUrl", () => {
test("allows ordinary HTTP and HTTPS source URLs", () => {
expect(getSafeAssistantHttpUrl("https://example.com/source?q=homarr")).toBe("https://example.com/source?q=homarr");
expect(getSafeAssistantHttpUrl("http://example.com/source")).toBe("http://example.com/source");
});
test("rejects unsafe protocols, relative URLs, and embedded credentials", () => {
expect(getSafeAssistantHttpUrl("javascript:alert(1)")).toBeNull();
expect(getSafeAssistantHttpUrl("data:text/html;base64,PGgxPkJhZDwvaDE+")).toBeNull();
expect(getSafeAssistantHttpUrl("/manage/custom-widgets")).toBeNull();
expect(getSafeAssistantHttpUrl("https://user:secret@example.com/source")).toBeNull();
});
});
@@ -0,0 +1,11 @@
export const getSafeAssistantHttpUrl = (source: unknown) => {
if (typeof source !== "string" || !source) return null;
try {
const url = new URL(source);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return null;
return url.toString();
} catch {
return null;
}
};
@@ -0,0 +1,52 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
interface AssistantViewRefreshContextValue {
isRefreshing: boolean;
refreshCurrentView: () => Promise<void>;
}
const AssistantViewRefreshContext = createContext<AssistantViewRefreshContextValue | null>(null);
export const AssistantViewRefreshProvider = ({ children }: PropsWithChildren) => {
const router = useRouter();
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const refreshPromiseRef = useRef<Promise<void> | null>(null);
const refreshAgainRef = useRef(false);
const refreshCurrentView = useCallback(() => {
if (refreshPromiseRef.current) {
refreshAgainRef.current = true;
return refreshPromiseRef.current;
}
setIsRefreshing(true);
const refreshPromise = (async () => {
do {
refreshAgainRef.current = false;
router.refresh();
await queryClient.invalidateQueries({ refetchType: "active" });
} while (refreshAgainRef.current);
})().finally(() => {
refreshPromiseRef.current = null;
setIsRefreshing(false);
});
refreshPromiseRef.current = refreshPromise;
return refreshPromise;
}, [queryClient, router]);
const value = useMemo(() => ({ isRefreshing, refreshCurrentView }), [isRefreshing, refreshCurrentView]);
return <AssistantViewRefreshContext.Provider value={value}>{children}</AssistantViewRefreshContext.Provider>;
};
export const useAssistantViewRefresh = () => {
const value = useContext(AssistantViewRefreshContext);
if (!value) throw new Error("useAssistantViewRefresh must be used within AssistantViewRefreshProvider");
return value;
};
Loaded 100 of 3286 files, more files were not shown because too many files have changed in this diff. Show more