feat: improve assistant confirmations
This commit is contained in:
1 parent
41389b61e2
commit
efe477e5b5
12 files changed
+509
-18
No files matched your search
@@ -0,0 +1,58 @@
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
|
||||
type AutoSubmitOptions = Parameters<typeof lastAssistantMessageIsCompleteWithToolCalls>[0];
|
||||
|
||||
export const shouldAutomaticallyContinueAssistant = (options: AutoSubmitOptions) =>
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(options) || lastAssistantMessageIsCompleteWithToolCalls(options);
|
||||
@@ -0,0 +1,217 @@
|
||||
"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 {
|
||||
type AskUserArgs,
|
||||
type AskUserResult,
|
||||
type ConfigureAppArgs,
|
||||
normalizeAssistantAppIconUrl,
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
@@ -336,6 +336,61 @@
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.approvalPanel {
|
||||
margin-top: 0.8rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.approvalActions {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.humanTool,
|
||||
.appTool {
|
||||
width: 100%;
|
||||
margin-block: 0.65rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.humanToolQuestion {
|
||||
line-height: 1.35;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.humanToolOption {
|
||||
height: auto;
|
||||
min-height: 3.25rem;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.8rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.humanToolOption :global(.mantine-Button-inner),
|
||||
.humanToolOption :global(.mantine-Button-label) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.humanToolOtherInput {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.humanToolCompleted {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-block: 0.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.toolResultText,
|
||||
.toolResultProperties,
|
||||
.toolResultCollection {
|
||||
@@ -918,6 +973,15 @@
|
||||
.reasoningSelect {
|
||||
width: 7.75rem;
|
||||
}
|
||||
|
||||
.approvalActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.humanTool,
|
||||
.appTool {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -357,7 +357,7 @@ const ToolPart = ({
|
||||
<UnstyledButton
|
||||
className={classes.toolHeader}
|
||||
onClick={() => setOpened((current) => !current)}
|
||||
aria-expanded={opened || awaitingApproval}
|
||||
aria-expanded={opened}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
@@ -399,20 +399,34 @@ const ToolPart = ({
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{awaitingApproval && (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Text size="sm">{t("approvalDescription")}</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-sm" onClick={() => respondToApproval({ approved: true })}>
|
||||
{t("allowOnce")}
|
||||
<Box className={classes.approvalPanel}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("approvalDescription")}
|
||||
</Text>
|
||||
<ToolResultPreview result={args} />
|
||||
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
|
||||
<Button
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<IconCheck size={18} />}
|
||||
onClick={() => respondToApproval({ approved: true })}
|
||||
>
|
||||
{t("approveAndRun")}
|
||||
</Button>
|
||||
<Button size="compact-sm" variant="default" onClick={() => respondToApproval({ approved: false })}>
|
||||
<Button
|
||||
size="md"
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<IconX size={18} />}
|
||||
onClick={() => respondToApproval({ approved: false })}
|
||||
>
|
||||
{t("deny")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
{successful && result !== undefined && <ToolResultPreview result={result} />}
|
||||
<Collapse expanded={opened || awaitingApproval}>
|
||||
<Collapse expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Box>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -38,6 +37,8 @@ import {
|
||||
useRegisterSpotlightContextResults,
|
||||
} from "@homarr/spotlight";
|
||||
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
|
||||
@@ -391,7 +392,7 @@ const AssistantThreadRuntime = () => {
|
||||
return useChatRuntime<AssistantUIMessage>({
|
||||
transport,
|
||||
onError,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
|
||||
adapters: {
|
||||
history,
|
||||
attachments,
|
||||
@@ -410,6 +411,18 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
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,
|
||||
},
|
||||
navigate_to_route: {
|
||||
type: "frontend",
|
||||
...browserToolContracts.navigate_to_route,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { browserToolContracts, normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
|
||||
|
||||
describe("assistant human tool contracts", () => {
|
||||
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" },
|
||||
{ id: "no", label: "No" },
|
||||
{ id: "alternative", label: "Choose an alternative", description: "Adjust the proposed app first." },
|
||||
],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects questions without enough choices", () => {
|
||||
expect(
|
||||
browserToolContracts.ask_user.parameters.safeParse({
|
||||
question: "Continue?",
|
||||
options: [{ id: "yes", label: "Yes" }],
|
||||
}).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("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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,40 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export const browserToolContracts = {
|
||||
ask_user: {
|
||||
description:
|
||||
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options; 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 so the user can review or complete app details. Its icon picker searches the local Homarr icon repository. Use this before app.create instead of inventing an external icon URL.",
|
||||
parameters: z.object({
|
||||
name: z.string().trim().max(64).optional(),
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
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.",
|
||||
@@ -17,3 +51,16 @@ export const browserToolContracts = {
|
||||
parameters: z.object({}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AskUserArgs = z.infer<(typeof browserToolContracts)["ask_user"]["parameters"]>;
|
||||
export type AskUserResult = {
|
||||
answer: string;
|
||||
optionId?: string;
|
||||
source: "option" | "other";
|
||||
};
|
||||
export type ConfigureAppArgs = z.infer<(typeof browserToolContracts)["configure_app"]["parameters"]>;
|
||||
|
||||
export const normalizeAssistantAppIconUrl = (value: string | undefined) => {
|
||||
if (!value) return "";
|
||||
return value.startsWith("/") || URL.canParse(value) ? value : "";
|
||||
};
|
||||
Reference in new issue
Block a user