feat: improve assistant confirmations

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
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 : "";
};