feat: improve assistant tool workflows
This commit is contained in:
1 parent
4060f9299f
commit
5a65ff47bf
17 files changed
+525
-88
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user