Files
homarr/apps/nextjs/src/components/assistant/assistant-human-tools.tsx
T

351 lines
11 KiB
TypeScript

"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 } 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 { useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import { getAssistantAffirmativeResult, 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 = useScopedI18n("common.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,
toolCallId,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useScopedI18n("common.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 : [];
const affirmativeResult = getAssistantAffirmativeResult(options);
const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2;
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined,
completed: result !== undefined,
confirm: () => {
if (!affirmativeResult) return;
setSubmitting(true);
addResult(affirmativeResult);
},
});
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">
{autoConfirming ? (
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
) : (
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 (
<Button
key={`${option.id}:${index}`}
className={classes.humanToolOption}
variant={kind === "alternative" ? "default" : "light"}
color={color}
size="md"
fullWidth
justify="space-between"
leftSection={icon}
rightSection={
<Badge variant="light" color={color} size="xs">
{t(`optionKind.${kind}`)}
</Badge>
}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<Box ta="start">
<Text component="span" size="sm" fw={650}>
{option.label}
</Text>
{option.description && (
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
{option.description}
</Text>
)}
</Box>
</Button>
);
})
)}
{!autoConfirming &&
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 = useScopedI18n("common.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;
setSubmitting(true);
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>
);
};