fix: harden assistant integration
This commit is contained in:
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;
|
||||
Reference in new issue
Block a user