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

214 lines
6.4 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { 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 classes from "./assistant-panel.module.css";
import { normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
import type { AskUserArgs, AskUserResult, ConfigureAppArgs } from "./assistant-tool-contracts";
type AppValues = z.infer<typeof appManageSchema>;
const toAppValues = (args: ConfigureAppArgs | undefined): AppValues => ({
name: args?.name ?? "",
description: args?.description ?? "",
iconUrl: normalizeAssistantAppIconUrl(args?.iconUrl),
href: args?.href ?? "",
pingUrl: args?.pingUrl ?? "",
});
export const AssistantAskUserTool = ({
args,
result,
addResult,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useScopedI18n("common.assistant.askUser");
const [showOther, setShowOther] = useState(false);
const [other, setOther] = useState("");
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 (!args?.question || options.length < 2) {
return (
<Box className={classes.humanTool} aria-label={t("preparing")}>
<Stack gap="xs">
<Skeleton height={18} width="62%" />
<Skeleton height={52} />
<Skeleton height={52} />
</Stack>
</Box>
);
}
const submitOther = () => {
const answer = other.trim();
if (!answer) return;
addResult({ answer, source: "other" });
};
return (
<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) => (
<Button
key={`${option.id}:${index}`}
className={classes.humanToolOption}
variant="default"
size="md"
fullWidth
justify="space-between"
onClick={() => addResult({ answer: option.label, optionId: option.id, 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>
))}
{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()}>
{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} />}
justify="flex-start"
onClick={() => setShowOther(true)}
>
{t("other")}
</Button>
))}
</Stack>
</Box>
);
};
export const AssistantConfigureAppTool = ({
args,
result,
addResult,
}: ToolCallMessagePartProps<ConfigureAppArgs, AppValues>) => {
const t = useScopedI18n("common.assistant.configureApp");
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>
);
}
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
initialValues={toAppValues(args)}
buttonLabels={{ submit: t("continue") }}
showBackToOverview={false}
handleSubmit={(values) => addResult(values)}
isPending={false}
/>
</Box>
);
};