Files
homarr/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx
T

234 lines
8.6 KiB
TypeScript

"use client";
import { useMemo } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { WidgetEditModal } from "@homarr/widgets/modals";
import classes from "./assistant-panel.module.css";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
interface AssistantIntegration extends IntegrationSelectOption {
permissions: { hasUseAccess: boolean };
}
export const getAssistantWidgetConfiguration = (
args: ConfigureWidgetArgs,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrations: AssistantIntegration[],
) => {
const definition = widgetImports[args.kind].definition;
const integrationSupport = "supportedIntegrations" in definition;
const supportedIntegrations = integrationSupport
? (definition.supportedIntegrations as readonly IntegrationKind[])
: [];
const integrationData = integrations.filter(
(integration) => integration.permissions.hasUseAccess && supportedIntegrations.includes(integration.kind),
);
const requestedIds = new Set(args.integrationIds ?? []);
const selectedIntegrations =
requestedIds.size > 0 ? integrationData.filter((integration) => requestedIds.has(integration.id)) : integrationData;
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const integrationIds = selectedIntegrations.slice(0, maxIntegrations).map((integration) => integration.id);
return {
integrationData,
integrationSupport,
integrationsRequired:
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
integrationIds,
},
};
};
export const AssistantConfigureWidgetTool = ({
args,
result,
addResult,
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
const t = useScopedI18n("common.assistant.configureWidget");
const fullT = useI18n();
const settings = useSettings();
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
const integrations = clientApi.integration.all.useQuery(undefined, {
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
retry: false,
});
const configuration = useMemo(() => {
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
const missingRequiredIntegration =
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
const { openModal } = useModalAction(WidgetEditModal);
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && configuration !== null,
completed: result !== undefined,
confirm: () => {
if (!args || !configuration) return;
if (missingRequiredIntegration) {
addResult({ boardId: args.boardId, kind: args.kind, cancelled: true, reason: "no-compatible-integration" });
return;
}
addResult({
boardId: args.boardId,
kind: args.kind,
options: configuration.value.options,
integrationIds: configuration.value.integrationIds,
});
},
});
if (result) {
const cancelled = "cancelled" in result && result.cancelled;
return (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color={cancelled ? "orange" : "green"}>
{cancelled ? <IconAlertTriangle size={13} /> : <IconCheck size={13} />}
</ThemeIcon>
<Box miw={0}>
<Text size="xs" c="dimmed">
{cancelled ? t("cancelled") : t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{args?.kind ? fullT(`widget.${args.kind}.name`) : t("title")}
</Text>
</Box>
</Box>
);
}
if (hasFailedAssistantToolArguments(status)) {
return (
<Alert color="red" variant="light" title={t("errorTitle")} icon={<IconAlertTriangle size={18} />}>
{t("errorDescription")}
</Alert>
);
}
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
return (
<Box className={classes.appTool} aria-label={t("preparing")}>
<Stack gap="sm">
<Skeleton height={18} width="45%" />
<Skeleton height={14} width="76%" />
<Skeleton height={38} />
</Stack>
</Box>
);
}
if (integrations.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={() => integrations.refetch()}>
{t("retry")}
</Button>
</Stack>
</Alert>
);
}
if (!configuration) return null;
if (autoConfirming) {
return (
<Box className={classes.appTool}>
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
</Box>
);
}
if (missingRequiredIntegration) {
return (
<Alert color="orange" variant="light" title={t("noIntegrationTitle")} icon={<IconAlertTriangle size={18} />}>
<Stack gap="sm">
<Text size="sm">{t("noIntegrationDescription")}</Text>
<Group gap="xs">
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
<Button
size="compact-sm"
variant="default"
onClick={() =>
addResult({
boardId: args.boardId,
kind: args.kind,
cancelled: true,
reason: "no-compatible-integration",
})
}
>
{t("skip")}
</Button>
</Group>
</Stack>
</Alert>
);
}
const WidgetIcon = widgetImports[args.kind].definition.icon;
return (
<Box className={classes.appTool}>
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" size="lg" radius="md">
<WidgetIcon size={20} />
</ThemeIcon>
<Box miw={0} flex={1}>
<Text fw={700}>{fullT(`widget.${args.kind}.name`)}</Text>
<Text size="sm" c="dimmed">
{args.summary}
</Text>
</Box>
</Group>
<Button
mt="md"
fullWidth
leftSection={<IconSettings size={17} />}
onClick={() =>
openModal(
{
kind: args.kind,
value: configuration.value,
integrationData: configuration.integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
)
}
>
{t("review")}
</Button>
</Box>
);
};