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

@@ -88,8 +88,11 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- Mutating Homarr tools require user approval. Present the exact intended change and wait for the approval flow.
- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose and do not tell the user to type yes or no. For a confirmation-style question, offer Yes, No, and Alternative options and leave the freeform Other answer enabled.
- Mutating Homarr tools already pause for native user approval. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears. Never ask for a second textual confirmation first.
- Do not retry a denied action.
- Before creating an app, call configure_app with the best defaults so the user can review Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
- Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL.
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
- Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists.
@@ -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 : "";
};