feat: improve assistant tool workflows

This commit is contained in:
ajnart committed 2026-08-13 19:56:27 +02:00
1 parent 4060f9299f
commit 5a65ff47bf
17 files changed
+525 -88

No files matched your search

+9 -1
View File
@@ -134,6 +134,12 @@ validated input, result, and error state without flooding the conversation with
Common collection and widget results are summarized inline as compact responsive cards, while the full
structured result remains available in the tool card details.
The shield control in the conversation header can enable automatic approvals for the current conversation.
While enabled, Homarr approves protected mutation calls as they arrive. Human questions and editable review
forms still wait for input. Automatic approvals turn off when you switch conversations, and the shared
approval tracker prevents the same call from being approved twice when both a board widget and the floating
assistant are visible.
When the assistant genuinely needs missing information or a choice, it can pause with a structured
question. Users can select one of the proposed answers or choose **Other** and enter their own response.
Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the
@@ -143,7 +149,9 @@ tool call with a new message.
App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker,
including automatic matching and searches through the `icon.findIcons` procedure, before the final
`app.create` approval is requested. The model is instructed not to invent external icon CDN URLs.
`app.create` approval is requested. App fields are filled only after the complete tool input has arrived, so
streaming partial arguments cannot replace the proposed values with empty fields. The model is instructed not
to invent external icon CDN URLs.
Board appearance changes use a similar review flow. The assistant first reads the board's current settings,
including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance,
@@ -45,6 +45,7 @@ active conversation silently: select **Open conversation** inside the widget bef
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
- Use `/` shortcuts for common dashboard tasks.
- Review tool calls, approve mutations, and answer structured assistant questions inline.
- Enable conversation-scoped automatic approvals from the shield control when you want protected mutations to run without individual approval clicks.
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
speed, provider routing, and cost.
@@ -97,7 +97,7 @@ Action rules:
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- 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.
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- 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.
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { createAssistantAutoApprovalTracker } from "./assistant-auto-approval";
describe("assistant automatic approval tracker", () => {
test("claims a tool call only once across duplicate conversation renderers", () => {
const tracker = createAssistantAutoApprovalTracker();
expect(tracker.claim("app-create-1")).toBe(true);
expect(tracker.claim("app-create-1")).toBe(false);
expect(tracker.claim("board-save-1")).toBe(true);
});
test("can retry a failed response and clears claims for another conversation", () => {
const tracker = createAssistantAutoApprovalTracker();
expect(tracker.claim("app-create-1")).toBe(true);
tracker.release("app-create-1");
expect(tracker.claim("app-create-1")).toBe(true);
tracker.clear();
expect(tracker.claim("app-create-1")).toBe(true);
});
});
@@ -0,0 +1,73 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
export const createAssistantAutoApprovalTracker = () => {
const claimedToolCalls = new Set<string>();
return {
claim(toolCallId: string) {
if (claimedToolCalls.has(toolCallId)) return false;
claimedToolCalls.add(toolCallId);
return true;
},
release(toolCallId: string) {
claimedToolCalls.delete(toolCallId);
},
clear() {
claimedToolCalls.clear();
},
};
};
interface AssistantAutoApprovalContextValue {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
requestApproval: (toolCallId: string, approve: () => void) => boolean;
}
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
export const AssistantAutoApprovalProvider = ({
children,
conversationId,
}: PropsWithChildren<{ conversationId: string | undefined }>) => {
const [enabled, setEnabled] = useState(false);
const trackerRef = useRef(createAssistantAutoApprovalTracker());
const previousConversationIdRef = useRef(conversationId);
useEffect(() => {
const previousConversationId = previousConversationIdRef.current;
previousConversationIdRef.current = conversationId;
if (previousConversationId === undefined || previousConversationId === conversationId) return;
trackerRef.current.clear();
setEnabled(false);
}, [conversationId]);
const requestApproval = useCallback(
(toolCallId: string, approve: () => void) => {
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
try {
approve();
return true;
} catch (error) {
trackerRef.current.release(toolCallId);
throw error;
}
},
[enabled],
);
const value = useMemo(() => ({ enabled, setEnabled, requestApproval }), [enabled, requestApproval]);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
export const useAssistantAutoApproval = () => {
const value = useContext(AssistantAutoApprovalContext);
if (value === null) throw new Error("useAssistantAutoApproval must be used within AssistantAutoApprovalProvider");
return value;
};
@@ -44,6 +44,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings";
import { hasCompleteAssistantToolArguments } from "./assistant-human-tool-status";
import classes from "./assistant-panel.module.css";
import type {
AssistantBoardSettingsChanges,
@@ -85,12 +86,14 @@ export const AssistantConfigureBoardSettingsTool = ({
args,
result,
addResult,
status,
}: ToolCallMessagePartProps<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
const t = useScopedI18n("common.assistant.configureBoardSettings");
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const boardId = args?.boardId ?? "";
const settings = clientApi.board.getBoardSettings.useQuery(
{ id: boardId },
{ enabled: result === undefined && boardId.length > 0, retry: false },
{ enabled: result === undefined && hasCompleteArguments && boardId.length > 0, retry: false },
);
if (result) {
@@ -113,7 +116,7 @@ export const AssistantConfigureBoardSettingsTool = ({
);
}
if (!args?.boardId || !args.boardName) {
if (!hasCompleteArguments || !args?.boardId || !args.boardName) {
return <BoardSettingsSkeleton label={t("loading")} />;
}
@@ -0,0 +1,54 @@
import { describe, expect, test, vi } from "vitest";
import { createAssistantBrowserToolExecutors, resolveAssistantInternalRoute } from "./assistant-browser-tool-executors";
describe("assistant browser tool executors", () => {
test("normalizes same-origin routes and rejects external or protocol-relative navigation", () => {
expect(resolveAssistantInternalRoute("/manage/apps?view=grid#new", "https://homarr.example")).toBe(
"/manage/apps?view=grid#new",
);
expect(resolveAssistantInternalRoute("https://example.com", "https://homarr.example")).toBeNull();
expect(resolveAssistantInternalRoute("//example.com/apps", "https://homarr.example")).toBeNull();
expect(resolveAssistantInternalRoute("/\\example.com/apps", "https://homarr.example")).toBeNull();
});
test("executes every supported frontend action through its injected Homarr dependency", async () => {
const navigate = vi.fn();
const openCommandMenu = vi.fn();
const openMediaRequestSearch = vi.fn();
const executors = createAssistantBrowserToolExecutors({
getOrigin: () => "https://homarr.example",
navigate,
openCommandMenu,
openMediaRequestSearch,
});
await expect(executors.navigate_to_route({ path: "/manage/apps" })).resolves.toEqual({
success: true,
path: "/manage/apps",
});
await expect(executors.open_command_menu()).resolves.toEqual({ success: true });
await expect(executors.open_media_request_search()).resolves.toEqual({ success: true });
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/manage/apps");
expect(openCommandMenu).toHaveBeenCalledOnce();
expect(openMediaRequestSearch).toHaveBeenCalledOnce();
});
test("does not navigate when the assistant requests an unsafe route", async () => {
const navigate = vi.fn();
const executors = createAssistantBrowserToolExecutors({
getOrigin: () => "https://homarr.example",
navigate,
openCommandMenu: vi.fn(),
openMediaRequestSearch: vi.fn(),
});
await expect(executors.navigate_to_route({ path: "https://example.com" })).resolves.toEqual({
success: false,
error: "Only internal Homarr paths are allowed.",
});
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,42 @@
interface AssistantBrowserToolDependencies {
getOrigin: () => string;
navigate: (path: string) => void;
openCommandMenu: () => void;
openMediaRequestSearch: () => void;
}
export const resolveAssistantInternalRoute = (path: string, origin: string) => {
if (!path.startsWith("/") || path.startsWith("/\\")) return null;
const target = URL.canParse(path, origin) ? new URL(path, origin) : null;
if (target === null || target.origin !== origin) return null;
return `${target.pathname}${target.search}${target.hash}`;
};
export const createAssistantBrowserToolExecutors = ({
getOrigin,
navigate,
openCommandMenu,
openMediaRequestSearch,
}: AssistantBrowserToolDependencies) => ({
navigate_to_route: async ({ path }: { path: string }) => {
const internalPath = resolveAssistantInternalRoute(path, getOrigin());
if (internalPath === null) {
return { success: false as const, error: "Only internal Homarr paths are allowed." };
}
navigate(internalPath);
return { success: true as const, path: internalPath };
},
open_command_menu: async () => {
openCommandMenu();
return { success: true as const };
},
open_media_request_search: async () => {
openMediaRequestSearch();
return { success: true as const };
},
});
export type AssistantBrowserToolExecutors = ReturnType<typeof createAssistantBrowserToolExecutors>;
@@ -0,0 +1,4 @@
import type { ToolCallMessagePartStatus } from "@assistant-ui/react";
export const hasCompleteAssistantToolArguments = (status: ToolCallMessagePartStatus | undefined) =>
status?.type !== "running";
@@ -0,0 +1,64 @@
import { describe, expect, test } from "vitest";
import { appManageSchema } from "@homarr/validation/app";
import { hasCompleteAssistantToolArguments } from "./assistant-human-tool-status";
import { getAssistantAppFormValues } from "./assistant-human-tools";
describe("assistant human tool forms", () => {
test("keeps every human tool in its loading state while arguments are streaming", () => {
expect(hasCompleteAssistantToolArguments({ type: "running" })).toBe(false);
expect(hasCompleteAssistantToolArguments({ type: "complete" })).toBe(true);
expect(hasCompleteAssistantToolArguments({ type: "requires-action", reason: "interrupt" })).toBe(true);
});
test("waits for streamed app arguments before initializing the form", () => {
expect(
getAssistantAppFormValues(
{ name: "Wiki" },
{
type: "running",
},
),
).toBeNull();
});
test("initializes the native app form with every completed tool argument", () => {
const values = getAssistantAppFormValues(
{
name: "Wikipedia",
description: "The Free Encyclopedia",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
pingUrl: "https://wikipedia.org",
},
{ type: "complete" },
);
expect(values).toEqual({
name: "Wikipedia",
description: "The Free Encyclopedia",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
pingUrl: "https://wikipedia.org",
});
expect(appManageSchema.safeParse(values).success).toBe(true);
});
test("drops unusable icon identifiers while preserving the other app fields", () => {
expect(
getAssistantAppFormValues(
{
name: "Wikipedia",
iconUrl: "wikipedia",
href: "https://wikipedia.org",
},
{ type: "complete" },
),
).toMatchObject({
name: "Wikipedia",
iconUrl: "",
href: "https://wikipedia.org",
});
});
});
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } 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";
@@ -11,12 +11,13 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import classes from "./assistant-panel.module.css";
import { hasCompleteAssistantToolArguments } 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>;
const toAppValues = (args: ConfigureAppArgs | undefined): AppValues => ({
export const toAssistantAppValues = (args: ConfigureAppArgs | undefined): AppValues => ({
name: args?.name ?? "",
description: args?.description ?? "",
iconUrl: normalizeAssistantAppIconUrl(args?.iconUrl),
@@ -24,10 +25,16 @@ const toAppValues = (args: ConfigureAppArgs | undefined): AppValues => ({
pingUrl: args?.pingUrl ?? "",
});
export const getAssistantAppFormValues = (
args: ConfigureAppArgs | undefined,
status: ToolCallMessagePartStatus | undefined,
) => (hasCompleteAssistantToolArguments(status) ? toAssistantAppValues(args) : null);
export const AssistantAskUserTool = ({
args,
result,
addResult,
status,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useScopedI18n("common.assistant.askUser");
const [showOther, setShowOther] = useState(false);
@@ -58,7 +65,7 @@ export const AssistantAskUserTool = ({
);
}
if (!args?.question || options.length < 2) {
if (!hasCompleteAssistantToolArguments(status) || !args?.question || options.length < 2) {
return (
<Box className={classes.humanTool} aria-label={t("preparing")}>
<Stack gap="xs">
@@ -180,9 +187,12 @@ 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);
if (result) {
return (
@@ -202,6 +212,20 @@ export const AssistantConfigureAppTool = ({
);
}
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>
);
}
return (
<Box className={classes.appTool}>
<Stack gap={2} mb="md">
@@ -211,7 +235,8 @@ export const AssistantConfigureAppTool = ({
</Text>
</Stack>
<AppForm
initialValues={toAppValues(args)}
key={toolCallId}
initialValues={initialValues}
buttonLabels={{ submit: t("continue") }}
showBackToOverview={false}
handleSubmit={(values) => {
@@ -557,6 +557,14 @@
margin-top: 0.85rem;
}
.autoApprovalProgress {
margin-top: 0.85rem;
padding: 0.7rem 0.8rem;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-green-light-color);
background: var(--mantine-color-green-light);
}
.pendingActionBanner {
flex: 0 0 auto;
padding: 0.6rem 0.75rem;
@@ -84,6 +84,7 @@ import {
IconRefresh,
IconRobot,
IconSearch,
IconShieldCheck,
IconSparkles,
IconThumbDown,
IconThumbUp,
@@ -99,6 +100,7 @@ import { assistantReasoningModes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { useAssistantAutoApproval } from "./assistant-auto-approval";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
import type { AssistantPendingAction } from "./assistant-pending-action";
@@ -341,6 +343,7 @@ const ToolResultPreview = ({ result }: { result: unknown }) => {
};
const ToolPart = ({
toolCallId,
toolName,
args,
result,
@@ -351,6 +354,7 @@ const ToolPart = ({
timing,
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const autoApproval = useAssistantAutoApproval();
const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
const completed = status?.type === "complete";
@@ -363,6 +367,16 @@ const ToolPart = ({
(typeof result === "object" && result !== null && "error" in result));
const successful = completed && !denied && !failed;
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
useEffect(() => {
if (!awaitingApproval || !autoApproval.enabled) return;
autoApproval.requestApproval(toolCallId, () => {
setApprovalResponse("approve");
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
});
}, [autoApproval, awaitingApproval, respondToApproval, toolCallId]);
return (
<Box className={classes.tool}>
<UnstyledButton
@@ -415,35 +429,44 @@ const ToolPart = ({
{t("approvalDescription")}
</Text>
<ToolResultPreview result={args} />
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
<Button
size="md"
fullWidth
leftSection={<IconCheck size={18} />}
loading={approvalResponse === "approve"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("approve");
respondToApproval({ approved: true });
}}
>
{t("approveAndRun")}
</Button>
<Button
size="md"
fullWidth
variant="default"
leftSection={<IconX size={18} />}
loading={approvalResponse === "deny"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("deny");
respondToApproval({ approved: false });
}}
>
{t("deny")}
</Button>
</Group>
{autoApproval.enabled ? (
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" />
<Text size="sm" fw={600}>
{t("autoApproval.approving")}
</Text>
</Group>
) : (
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
<Button
size="md"
fullWidth
leftSection={<IconCheck size={18} />}
loading={approvalResponse === "approve"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("approve");
respondToApproval({ approved: true });
}}
>
{t("approveAndRun")}
</Button>
<Button
size="md"
fullWidth
variant="default"
leftSection={<IconX size={18} />}
loading={approvalResponse === "deny"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("deny");
respondToApproval({ approved: false });
}}
>
{t("deny")}
</Button>
</Group>
)}
</Box>
)}
{successful && result !== undefined && <ToolResultPreview result={result} />}
@@ -1310,6 +1333,62 @@ const ConversationHistory = () => {
);
};
const AutoApprovalControl = () => {
const t = useScopedI18n("common.assistant.autoApproval");
const [opened, setOpened] = useState(false);
const { enabled, setEnabled } = useAssistantAutoApproval();
return (
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<ActionIcon
className={classes.panelAction}
variant={enabled ? "light" : "subtle"}
color={enabled ? "green" : "gray"}
onClick={() => setOpened((current) => !current)}
aria-label={t("label")}
aria-pressed={enabled}
title={enabled ? t("enabled") : t("label")}
>
<IconShieldCheck size={17} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" mt={3}>
{t("description")}
</Text>
</Box>
<Button
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
leftSection={<IconShieldCheck size={16} />}
onClick={() => {
setEnabled(!enabled);
setOpened(false);
}}
>
{enabled ? t("disable") : t("enable")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const EmptyThread = () => {
const t = useScopedI18n("common.assistant");
return (
@@ -1944,6 +2023,7 @@ export const AssistantConversationSurface = ({
</Group>
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<AutoApprovalControl />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
@@ -26,6 +26,8 @@ import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResul
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
import { AssistantPanel } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
@@ -409,6 +411,16 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
adapter: threadAdapter,
runtimeHook: AssistantThreadRuntime,
});
const browserToolExecutors = useMemo(
() =>
createAssistantBrowserToolExecutors({
getOrigin: () => window.location.origin,
navigate: (path) => router.push(path),
openCommandMenu: openSpotlight,
openMediaRequestSearch,
}),
[router],
);
const toolkit = useMemo(
() =>
@@ -434,20 +446,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
navigate_to_route: {
type: "frontend",
...browserToolContracts.navigate_to_route,
execute: async ({ path }) => {
const target = URL.canParse(path, window.location.origin) ? new URL(path, window.location.origin) : null;
if (
!path.startsWith("/") ||
path.startsWith("/\\") ||
target === null ||
target.origin !== window.location.origin
) {
return { success: false, error: "Only internal Homarr paths are allowed." };
}
const internalPath = `${target.pathname}${target.search}${target.hash}`;
router.push(internalPath);
return { success: true, path: internalPath };
},
execute: browserToolExecutors.navigate_to_route,
renderText: {
running: ({ args }) => `Opening ${args.path}…`,
complete: ({ args }) => `Opened ${args.path}`,
@@ -456,23 +455,17 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
open_command_menu: {
type: "frontend",
...browserToolContracts.open_command_menu,
execute: async () => {
openSpotlight();
return { success: true };
},
execute: browserToolExecutors.open_command_menu,
renderText: { running: "Opening command menu…", complete: "Command menu opened" },
},
open_media_request_search: {
type: "frontend",
...browserToolContracts.open_media_request_search,
execute: async () => {
openMediaRequestSearch();
return { success: true };
},
execute: browserToolExecutors.open_media_request_search,
renderText: { running: "Opening media search…", complete: "Media search opened" },
},
}) as Toolkit,
[router],
[browserToolExecutors],
);
return (
<AssistantRuntimeProviderWithTools runtime={runtime} toolkit={toolkit}>
@@ -542,6 +535,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const aui = useAui();
const preferences = useAssistantPreferences();
const messages = useAuiState((state) => state.thread.messages);
const conversationId = useAuiState((state) => state.threadListItem.remoteId);
const isRunning = useAuiState((state) => state.thread.isRunning);
const isLoading = useAuiState((state) => state.thread.isLoading);
const latestAssistantMessage = getLatestMessage(messages, "assistant");
@@ -663,31 +657,33 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
);
return (
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
markRead();
setActivityDismissed(true);
}}
activityDismissed={activityDismissed}
isRunning={assistantIsRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={queuedPrompt ?? latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
onModelChange={selectModel}
onReasoningChange={preferences.setReasoning}
/>
</AssistantContext.Provider>
<AssistantAutoApprovalProvider conversationId={conversationId}>
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
markRead();
setActivityDismissed(true);
}}
activityDismissed={activityDismissed}
isRunning={assistantIsRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={queuedPrompt ?? latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
onModelChange={selectModel}
onReasoningChange={preferences.setReasoning}
/>
</AssistantContext.Provider>
</AssistantAutoApprovalProvider>
);
};
@@ -3,7 +3,43 @@ import { z } from "zod/v4";
import { browserToolContracts, normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
const supportedToolArguments = {
ask_user: {
question: "How should the app be added?",
options: [
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
],
},
configure_app: {
name: "Wikipedia",
description: "The Free Encyclopedia",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
pingUrl: "https://wikipedia.org",
},
configure_board_settings: {
boardId: "board-1",
boardName: "Home",
summary: "Improve dashboard contrast.",
changes: { primaryColor: "#7C3AED" },
},
navigate_to_route: { path: "/manage/apps" },
open_command_menu: {},
open_media_request_search: {},
} satisfies Record<keyof typeof browserToolContracts, unknown>;
describe("assistant human tool contracts", () => {
test("keeps a valid fixture and JSON schema for every supported browser tool", () => {
expect(Object.keys(supportedToolArguments).toSorted()).toEqual(Object.keys(browserToolContracts).toSorted());
for (const [toolName, contract] of Object.entries(browserToolContracts)) {
const args = supportedToolArguments[toolName as keyof typeof supportedToolArguments];
expect(contract.parameters.safeParse(args).success, toolName).toBe(true);
expect(() => z.toJSONSchema(contract.parameters), toolName).not.toThrow();
}
});
test("accepts a structured question with reusable choices", () => {
expect(
browserToolContracts.ask_user.parameters.safeParse({
@@ -35,6 +71,15 @@ describe("assistant human tool contracts", () => {
).toBe(true);
});
test("requires an app name before opening the native form", () => {
expect(
browserToolContracts.configure_app.parameters.safeParse({
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wikipedia.svg",
href: "https://wikipedia.org",
}).success,
).toBe(false);
});
test("accepts a scoped board settings proposal", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
@@ -51,9 +51,9 @@ export const browserToolContracts = {
},
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.",
"Open Homarr's native app form with the best available app details already filled in. Its icon picker searches the local Homarr icon repository. Include the inferred description, href, and pingUrl when they are known. Use this before app.create instead of inventing an external icon URL.",
parameters: z.object({
name: z.string().trim().max(64).optional(),
name: z.string().trim().min(1).max(64),
description: z.string().trim().max(512).nullable().optional(),
iconUrl: z
.string()
+11 -1
View File
@@ -2146,6 +2146,7 @@
"configureApp": {
"title": "Review app details",
"description": "Choose a local icon and adjust anything before the assistant requests creation approval.",
"preparing": "Preparing app details",
"continue": "Use these details",
"ready": "App details ready"
},
@@ -2185,7 +2186,16 @@
"working": "Working",
"approvalDescription": "Review this change before it runs.",
"approveAndRun": "Approve and run",
"deny": "Deny"
"deny": "Deny",
"autoApproval": {
"label": "Automatic approvals",
"title": "Automatic approvals",
"description": "Approve every protected change in this conversation without another click. Your Homarr permissions still apply. This turns off when you switch conversations.",
"enable": "Enable for this conversation",
"disable": "Turn off automatic approvals",
"enabled": "Automatic approvals are on",
"approving": "Approving automatically"
}
},
"dangerZone": "Danger zone",
"noResults": "No results found",