diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 623cca18a..2cb9f69c6 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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, diff --git a/apps/docs/docs/widgets/assistant/index.mdx b/apps/docs/docs/widgets/assistant/index.mdx index 9f7d174b7..1a0a2511f 100644 --- a/apps/docs/docs/widgets/assistant/index.mdx +++ b/apps/docs/docs/widgets/assistant/index.mdx @@ -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. diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index ff39b143c..f3e38dad2 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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. diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts new file mode 100644 index 000000000..b2656ff7d --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts @@ -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); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx new file mode 100644 index 000000000..962f48eef --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx @@ -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(); + + 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(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 {children}; +}; + +export const useAssistantAutoApproval = () => { + const value = useContext(AssistantAutoApprovalContext); + if (value === null) throw new Error("useAssistantAutoApproval must be used within AssistantAutoApprovalProvider"); + return value; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx index ca3c25012..b02708691 100644 --- a/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx @@ -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) => { 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 ; } diff --git a/apps/nextjs/src/components/assistant/assistant-browser-tool-executors.spec.ts b/apps/nextjs/src/components/assistant/assistant-browser-tool-executors.spec.ts new file mode 100644 index 000000000..e31225c8e --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-browser-tool-executors.spec.ts @@ -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(); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-browser-tool-executors.ts b/apps/nextjs/src/components/assistant/assistant-browser-tool-executors.ts new file mode 100644 index 000000000..1fadc8ccb --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-browser-tool-executors.ts @@ -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; diff --git a/apps/nextjs/src/components/assistant/assistant-human-tool-status.ts b/apps/nextjs/src/components/assistant/assistant-human-tool-status.ts new file mode 100644 index 000000000..9141e62a3 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-human-tool-status.ts @@ -0,0 +1,4 @@ +import type { ToolCallMessagePartStatus } from "@assistant-ui/react"; + +export const hasCompleteAssistantToolArguments = (status: ToolCallMessagePartStatus | undefined) => + status?.type !== "running"; diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.spec.ts b/apps/nextjs/src/components/assistant/assistant-human-tools.spec.ts new file mode 100644 index 000000000..24766e415 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.spec.ts @@ -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", + }); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index e5ea434c3..ecb006718 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -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; -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) => { 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 ( @@ -180,9 +187,12 @@ export const AssistantConfigureAppTool = ({ args, result, addResult, + status, + toolCallId, }: ToolCallMessagePartProps) => { 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 ( + + + + + + + + + + ); + } + return ( @@ -211,7 +235,8 @@ export const AssistantConfigureAppTool = ({ { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 41bba98c2..896435dd8 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 9e308b3a5..1f76f4edb 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 ( - - - - + {autoApproval.enabled ? ( + + + + {t("autoApproval.approving")} + + + ) : ( + + + + + )} )} {successful && result !== undefined && } @@ -1310,6 +1333,62 @@ const ConversationHistory = () => { ); }; +const AutoApprovalControl = () => { + const t = useScopedI18n("common.assistant.autoApproval"); + const [opened, setOpened] = useState(false); + const { enabled, setEnabled } = useAssistantAutoApproval(); + + return ( + setOpened(false)} + position="bottom-end" + width="min(19rem, calc(100vw - 1rem))" + shadow="md" + withinPortal + > + + setOpened((current) => !current)} + aria-label={t("label")} + aria-pressed={enabled} + title={enabled ? t("enabled") : t("label")} + > + + + + + + + + {t("title")} + + + {t("description")} + + + + + + + ); +}; + const EmptyThread = () => { const t = useScopedI18n("common.assistant"); return ( @@ -1944,6 +2023,7 @@ export const AssistantConversationSurface = ({ + { 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 ( @@ -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 ( - - {children} - { - 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} - /> - + + + {children} + { + 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} + /> + + ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts b/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts index b5ceede57..270f357fc 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts @@ -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; + 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({ diff --git a/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts b/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts index ab557c514..8ce0e6bf3 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts @@ -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() diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a1c76e573..aa0de9a0a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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",