fix: harden assistant integration

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent 10ef871777
commit 5db95f2def
14 files changed
+349 -189

No files matched your search

@@ -1,7 +1,3 @@
.drawer {
--assistant-panel-width: min(32rem, 100vw);
}
.content {
height: 100dvh;
background: var(--mantine-color-body);
@@ -13,6 +13,7 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
IconArchive,
IconArrowUp,
@@ -206,8 +207,17 @@ const ArchivedThreadListItem = () => {
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => void }) => {
const t = useScopedI18n("common.assistant");
const isMobile = useMediaQuery("(max-width: 48em)");
const hidden = isMobile && !opened;
return (
<Stack className={classes.history} gap="xs" p="sm" data-opened={opened}>
<Stack
className={classes.history}
gap="xs"
p="sm"
data-opened={opened}
inert={hidden ? true : undefined}
aria-hidden={hidden || undefined}
>
<Group gap="xs" wrap="nowrap">
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onClose}>
@@ -3,7 +3,6 @@
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useChat } from "@ai-sdk/react";
import type { MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, Toolkit } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
@@ -13,12 +12,11 @@ import {
useAuiState,
useRemoteThreadListRuntime,
} from "@assistant-ui/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
import type { UIMessage } from "ai";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { z } from "zod/v4";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -32,6 +30,7 @@ import {
} from "@homarr/spotlight";
import { AssistantPanel } from "./assistant-panel";
import { browserToolContracts } from "./assistant-tool-contracts";
interface AssistantContextValue {
enabled: boolean;
@@ -178,22 +177,15 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
});
const AssistantThreadRuntime = () => {
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const aui = useAui();
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
const chat = useChat<UIMessage>({
id: localThreadId,
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
return useChatRuntime<UIMessage>({
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
adapters: { history },
});
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const runtime = useAISDKRuntime(chat, { adapters: { history } });
transport.setRuntime(runtime);
transport["__internal_setGetThreadListItem"](() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
return runtime;
};
const AssistantRuntime = ({ children }: PropsWithChildren) => {
@@ -208,17 +200,20 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
defineToolkit({
navigate_to_route: {
type: "frontend",
description:
"Navigate the current Homarr tab to a safe internal route. Only paths beginning with a single slash are accepted.",
parameters: z.object({
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
}),
...browserToolContracts.navigate_to_route,
execute: async ({ path }) => {
if (!path.startsWith("/") || path.startsWith("//")) {
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." };
}
router.push(path);
return { success: true, path };
const internalPath = `${target.pathname}${target.search}${target.hash}`;
router.push(internalPath);
return { success: true, path: internalPath };
},
renderText: {
running: ({ args }) => `Opening ${args.path}…`,
@@ -227,8 +222,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
},
open_command_menu: {
type: "frontend",
description: "Open Homarr's command and search menu.",
parameters: z.object({}),
...browserToolContracts.open_command_menu,
execute: async () => {
openSpotlight();
return { success: true };
@@ -237,8 +231,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
},
open_media_request_search: {
type: "frontend",
description: "Open Homarr's media request search interface.",
parameters: z.object({}),
...browserToolContracts.open_media_request_search,
execute: async () => {
openMediaRequestSearch();
return { success: true };
@@ -0,0 +1,19 @@
import { z } from "zod/v4";
export const browserToolContracts = {
navigate_to_route: {
description:
"Navigate the current Homarr tab to a safe internal route. Only same-origin paths beginning with a single slash are accepted.",
parameters: z.object({
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
}),
},
open_command_menu: {
description: "Open Homarr's command and search menu.",
parameters: z.object({}),
},
open_media_request_search: {
description: "Open Homarr's media request search interface.",
parameters: z.object({}),
},
} as const;