"use client"; import type { PropsWithChildren } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import type { AttachmentAdapter, MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, ThreadMessage, Toolkit, } from "@assistant-ui/react"; import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react"; import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk"; import { useHotkeys } from "@mantine/hooks"; import { createAssistantStream } from "assistant-stream"; import { clientApi, fetchApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId } from "@homarr/common"; import { hotkeys } from "@homarr/definitions"; import { showErrorNotification, showWarningNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { closeSpotlight, openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults, } from "@homarr/spotlight"; 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"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences"; import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"; import { sendAssistantPrompt as sendPromptThroughRuntime } from "./assistant-send"; import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { browserToolContracts } from "./assistant-tool-contracts"; import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool"; import { AssistantConfigureWidgetTool } from "./assistant-widget-tool"; import type { AssistantUIMessage } from "./assistant-message-metadata"; import { getSuccessfulApprovedAssistantMutationIds, updateAssistantMutationRefreshState, } from "./assistant-mutation-refresh"; import { initialAssistantNotificationState, updateAssistantNotificationState } from "./assistant-notifications"; import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh"; import { AssistantBoardWidget } from "./assistant-widget"; interface AssistantContextValue { enabled: boolean; unavailableDescription: string | null; opened: boolean; isRunning: boolean; isRefreshing: boolean; unreadCount: number; open: () => void; close: () => void; toggle: () => void; sendPrompt: (prompt: string) => boolean; refreshCurrentView: () => Promise; } const AssistantContext = createContext(null); interface AssistantPreferencesContextValue { defaultModelId: string | null; modelId: string | null; models: AssistantRuntimeModelOption[]; reasoning: AssistantReasoningMode; isLoading: boolean; setModelId: (modelId: string) => void; setReasoning: (reasoning: AssistantReasoningMode) => void; getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode }; } const AssistantPreferencesContext = createContext(null); const ignoreUnsupportedArchiveAction = () => Promise.resolve(); const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"]; const assistantDocumentAttachmentTypes = [ "application/json", "text/csv", "text/html", "text/markdown", "text/plain", "text/xml", ]; const fileToDataUrlAsync = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => resolve(String(reader.result)), { once: true }); reader.addEventListener("error", () => reject(new Error("The attachment could not be read.")), { once: true }); reader.readAsDataURL(file); }); const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapter => { const pendingAttachmentIds = new Set(); return { accept: [...(allowImages ? assistantImageAttachmentTypes : []), ...assistantDocumentAttachmentTypes].join(","), async add({ file }) { if (pendingAttachmentIds.size >= 5) { throw new Error("A message can include up to 5 attachments."); } const isImage = file.type.startsWith("image/"); if (isImage && !allowImages) { throw new Error("The selected model does not support image input."); } if ( (!isImage && !assistantDocumentAttachmentTypes.includes(file.type)) || (isImage && !assistantImageAttachmentTypes.includes(file.type)) ) { throw new Error("This file type is not supported."); } const sizeLimit = isImage ? 1_000_000 : 350_000; if (file.size === 0) throw new Error("Empty files cannot be attached."); if (file.size > sizeLimit) { throw new Error(isImage ? "Images must be smaller than 1 MB." : "Documents must be smaller than 350 KB."); } const id = createId(); pendingAttachmentIds.add(id); return { id, type: isImage ? "image" : "document", name: file.name, file, contentType: file.type, status: { type: "requires-action", reason: "composer-send" }, }; }, async send(attachment) { try { return { ...attachment, status: { type: "complete" }, content: [ { type: "file", filename: attachment.name, mimeType: attachment.contentType ?? "application/octet-stream", data: await fileToDataUrlAsync(attachment.file), }, ], }; } finally { pendingAttachmentIds.delete(attachment.id); } }, async remove(attachment) { pendingAttachmentIds.delete(attachment.id); }, }; }; export const useHomarrAssistant = () => { const value = useContext(AssistantContext); if (!value) { throw new Error("useHomarrAssistant must be used within AssistantProvider"); } return value; }; export const useOptionalHomarrAssistant = () => useContext(AssistantContext); export const useAssistantPreferences = () => { const value = useContext(AssistantPreferencesContext); if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider"); return value; }; const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => { const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, { staleTime: 10 * 60_000, }); const [modelId, setModelIdState] = useState(null); const [reasoning, setReasoningState] = useState("auto"); const previousDefaultModelIdRef = useRef(undefined); const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({ modelId: null, reasoning: "auto", }); const models = useMemo( () => data?.models.map(({ id, name, inputModalities }) => ({ id, name, inputModalities })) ?? [], [data?.models], ); const setModelId = useCallback((nextModelId: string) => { preferencesRef.current.modelId = nextModelId; setModelIdState(nextModelId); }, []); const setReasoning = useCallback((nextReasoning: AssistantReasoningMode) => { preferencesRef.current.reasoning = nextReasoning; setReasoningState(nextReasoning); }, []); const getRequestBody = useCallback(() => { const current = preferencesRef.current; return { ...(current.modelId ? { modelId: current.modelId } : {}), reasoning: current.reasoning, }; }, []); useEffect(() => { if (!data) return; const nextModelId = resolveAssistantPreferenceModelId({ currentModelId: preferencesRef.current.modelId, previousDefaultModelId: previousDefaultModelIdRef.current, defaultModelId: data.defaultModelId, models: data.models, }); previousDefaultModelIdRef.current = data.defaultModelId; if (nextModelId === preferencesRef.current.modelId) return; preferencesRef.current.modelId = nextModelId; setModelIdState(nextModelId); }, [data]); const value = useMemo( () => ({ defaultModelId: data?.defaultModelId ?? null, modelId, models, reasoning, isLoading, setModelId, setReasoning, getRequestBody, }), [data?.defaultModelId, getRequestBody, isLoading, modelId, models, reasoning, setModelId, setReasoning], ); return {children}; }; const threadAdapter: RemoteThreadListAdapter = { async list() { const threads = await fetchApi.assistant.listThreads.query(); return { threads: threads.map((thread) => ({ remoteId: thread.id, status: "regular", title: thread.title ?? undefined, lastMessageAt: thread.updatedAt, custom: { modelId: thread.modelId }, })), }; }, async initialize(localId) { const thread = await fetchApi.assistant.createThread.mutate({ localId }); return { remoteId: thread.id, externalId: undefined }; }, async fetch(threadId) { const { thread } = await fetchApi.assistant.getThread.query({ threadId }); return { remoteId: thread.id, status: "regular", title: thread.title ?? undefined, lastMessageAt: thread.updatedAt, custom: { modelId: thread.modelId }, }; }, async rename(threadId, title) { await fetchApi.assistant.renameThread.mutate({ threadId, title }); }, async updateCustom(threadId, custom) { const modelId = custom?.modelId; if (typeof modelId !== "string") return; await fetchApi.assistant.updateThreadModel.mutate({ threadId, modelId }); }, // assistant-ui requires these callbacks even when the product does not expose archiving. archive: ignoreUnsupportedArchiveAction, unarchive: ignoreUnsupportedArchiveAction, async delete(threadId) { await fetchApi.assistant.deleteThread.mutate({ threadId }); }, async generateTitle(_threadId, messages) { const firstUserMessage = messages.find((message) => message.role === "user"); const text = firstUserMessage?.content .filter((part) => part.type === "text") .map((part) => part.text) .join(" ") .trim() .slice(0, 72) || "New conversation"; return createAssistantStream((controller) => controller.appendText(text)); }, }; const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapter => ({ async load() { return { messages: [] }; }, async append() { // AI SDK runtimes use the typed adapter returned by withFormat below. }, withFormat>( formatAdapter: MessageFormatAdapter, ) { if (formatAdapter.format !== "ai-sdk/v6") { throw new Error(`Unsupported assistant message format: ${formatAdapter.format}`); } const storageFormat = formatAdapter.format; return { async load() { if (!threadId) return { messages: [] }; const { messages } = await fetchApi.assistant.getThread.query({ threadId }); return { messages: messages .filter((message) => message.format === formatAdapter.format) .map( (message) => formatAdapter.decode({ id: message.id, parent_id: message.parentId, format: message.format, content: message.content as TStorageFormat, }) as { parentId: string | null; message: TMessage }, ), }; }, async append(item) { if (!threadId) return; await fetchApi.assistant.appendMessage.mutate({ threadId, id: (formatAdapter as MessageFormatAdapter).getId(item.message), parentId: item.parentId, format: storageFormat, content: (formatAdapter as MessageFormatAdapter).encode( item as { parentId: string | null; message: unknown }, ), }); }, async update(item, localMessageId) { if (!threadId) return; const erasedFormatAdapter = formatAdapter as MessageFormatAdapter; const id = erasedFormatAdapter.getId(item.message); await fetchApi.assistant.appendMessage.mutate({ threadId, id, parentId: item.parentId, format: storageFormat, content: erasedFormatAdapter.encode(item as { parentId: string | null; message: unknown }), }); if (localMessageId !== id) { await fetchApi.assistant.deleteMessages.mutate({ threadId, ids: [localMessageId] }); } }, async delete(items) { if (!threadId || items.length === 0) return; await fetchApi.assistant.deleteMessages.mutate({ threadId, ids: items.map((item) => (formatAdapter as MessageFormatAdapter).getId(item.message), ), }); }, }; }, }); const AssistantThreadRuntime = () => { const t = useScopedI18n("common.assistant"); const threadId = useAuiState((state) => state.threadListItem.remoteId); const preferences = useAssistantPreferences(); const transport = useMemo( () => new AssistantChatTransport({ api: "/api/assistant/chat", body: preferences.getRequestBody, }), [preferences.getRequestBody], ); const history = useMemo(() => createHistoryAdapter(threadId), [threadId]); const selectedModel = preferences.models.find((model) => model.id === preferences.modelId); const attachments = useMemo( () => createAssistantAttachmentAdapter( selectedModel === undefined || selectedModel.inputModalities.length === 0 || selectedModel.inputModalities.includes("image"), ), [selectedModel], ); const feedback = useMemo( () => ({ submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => { if (!threadId) return; void fetchApi.assistant.submitFeedback .mutate({ threadId, messageId: message.id, type }) .catch(() => showErrorNotification({ title: t("responseError.title"), message: t("feedbackError") })); }, }), [t, threadId], ); const onError = useCallback( () => showErrorNotification({ title: t("responseError.title"), message: t("responseError.description"), autoClose: 10_000, }), [t], ); return useChatRuntime({ transport, onError, sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant, adapters: { history, attachments, feedback, }, }); }; const AssistantRuntime = ({ children }: PropsWithChildren) => { const router = useRouter(); const { refreshCurrentView } = useAssistantViewRefresh(); const runtime = useRemoteThreadListRuntime({ adapter: threadAdapter, runtimeHook: AssistantThreadRuntime, }); const browserToolExecutors = useMemo( () => createAssistantBrowserToolExecutors({ getOrigin: () => window.location.origin, navigate: (path) => router.push(path), openCommandMenu: openSpotlight, openMediaRequestSearch, refreshCurrentView, }), [refreshCurrentView, router], ); const toolkit = useMemo( () => defineToolkit({ ask_user: { type: "human", display: "standalone", ...browserToolContracts.ask_user, render: AssistantAskUserTool, }, configure_app: { type: "human", display: "standalone", ...browserToolContracts.configure_app, render: AssistantConfigureAppTool, }, configure_board_settings: { type: "human", display: "standalone", ...browserToolContracts.configure_board_settings, render: AssistantConfigureBoardSettingsTool, }, configure_widget: { type: "human", display: "standalone", ...browserToolContracts.configure_widget, render: AssistantConfigureWidgetTool, }, navigate_to_route: { type: "frontend", ...browserToolContracts.navigate_to_route, execute: browserToolExecutors.navigate_to_route, renderText: { running: ({ args }) => `Opening ${args.path}…`, complete: ({ args }) => `Opened ${args.path}`, }, }, open_command_menu: { type: "frontend", ...browserToolContracts.open_command_menu, 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: browserToolExecutors.open_media_request_search, renderText: { running: "Opening media search…", complete: "Media search opened" }, }, refresh_current_view: { type: "frontend", ...browserToolContracts.refresh_current_view, execute: browserToolExecutors.refresh_current_view, renderText: { running: "Refreshing current view…", complete: "Current view refreshed" }, }, }) as Toolkit, [browserToolExecutors], ); return ( {children} ); }; const AssistantPreferenceSync = () => { const preferences = useAssistantPreferences(); // The local id distinguishes every thread immediately, including threads that do not have a remote id yet. const conversationId = useAuiState((state) => state.threadListItem.id); const remoteId = useAuiState((state) => state.threadListItem.remoteId); const threadCustom = useAuiState((state) => state.threadListItem.custom); const threadModelId = threadCustom?.modelId; const previousSyncKeyRef = useRef(null); const modelCatalogKey = preferences.models.map((model) => model.id).join("\0"); useEffect(() => { if (preferences.models.length === 0) return; const syncKey = `${conversationId}:${modelCatalogKey}`; if (previousSyncKeyRef.current === syncKey) return; const nextModelId = resolveAssistantThreadPreferenceModelId({ isRemote: remoteId !== undefined, metadataLoaded: threadCustom !== undefined, threadModelId, defaultModelId: preferences.defaultModelId, models: preferences.models, }); if (nextModelId === undefined) return; previousSyncKeyRef.current = syncKey; if (nextModelId) preferences.setModelId(nextModelId); }, [conversationId, modelCatalogKey, preferences, remoteId, threadCustom, threadModelId]); return null; }; const AssistantRuntimeEvents = () => { const t = useScopedI18n("common.assistant"); useAuiEvent("composer.attachmentAddError", ({ message }) => { showErrorNotification({ title: t("attachments.errorTitle"), message, }); }); return null; }; const getMessageText = (message: ThreadMessage | undefined) => message?.content .filter((part) => part.type === "text") .map((part) => part.text) .join("\n") .trim() ?? ""; const getLatestMessage = (messages: readonly ThreadMessage[], role: ThreadMessage["role"]) => messages.findLast((message) => message.role === role); const getNotificationKey = (message: ThreadMessage | undefined) => { if (message?.role !== "assistant") return null; if (message.status.type === "running") return null; return `${message.id}:${message.status.type}:${"reason" in message.status ? message.status.reason : ""}`; }; const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const t = useScopedI18n("common.assistant"); const [opened, setOpened] = useState(false); const [activityDismissed, setActivityDismissed] = useState(true); const [unreadCount, setUnreadCount] = useState(0); const [queuedPrompt, setQueuedPrompt] = useState(null); const aui = useAui(); const preferences = useAssistantPreferences(); const { isRefreshing, refreshCurrentView } = useAssistantViewRefresh(); const messages = useAuiState((state) => state.thread.messages); // The local id is stable while a new thread is initialized and changes for every real thread switch. const conversationId = useAuiState((state) => state.threadListItem.id); const isRunning = useAuiState((state) => state.thread.isRunning); const isLoading = useAuiState((state) => state.thread.isLoading); const latestAssistantMessage = getLatestMessage(messages, "assistant"); const latestUserMessage = getLatestMessage(messages, "user"); const latestAssistantText = getMessageText(latestAssistantMessage); const latestUserText = getMessageText(latestUserMessage); const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined; const pendingAction = getPendingAssistantAction(latestAssistantMessage); const assistantIsRunning = isRunning || queuedPrompt !== null; const notificationKey = getNotificationKey(latestAssistantMessage); const notificationStateRef = useRef(initialAssistantNotificationState); const mutationRefreshStateRef = useRef<{ conversationId: string | null; toolCallIds: Set }>({ conversationId: null, toolCallIds: new Set(), }); const successfulMutationIds = useMemo(() => getSuccessfulApprovedAssistantMutationIds(messages), [messages]); const markRead = useCallback(() => { notificationStateRef.current = { initialized: true, conversationId, notificationKey, }; setUnreadCount(0); }, [conversationId, notificationKey]); const open = useCallback(() => { markRead(); setActivityDismissed(false); setOpened(true); }, [markRead]); const close = useCallback(() => setOpened(false), []); const toggle = useCallback(() => { if (opened) close(); else open(); }, [close, open, opened]); const sendPrompt = useCallback( (prompt: string) => { const text = prompt.trim(); if (text.length === 0) return false; if (assistantIsRunning || latestStatus?.type === "requires-action") { showWarningNotification({ title: t("busy.title"), message: t("busy.description"), }); return false; } setActivityDismissed(false); if (isLoading) { setQueuedPrompt(text); return true; } return sendPromptThroughRuntime(aui, text); }, [assistantIsRunning, aui, isLoading, latestStatus?.type, t], ); const selectModel = useCallback( (modelId: string) => { preferences.setModelId(modelId); const threadListItem = aui.threadListItem(); if (!threadListItem.getState().remoteId) return; threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); }, [aui, preferences], ); useEffect(() => { if (assistantIsRunning) setActivityDismissed(false); }, [assistantIsRunning]); useEffect(() => { if (isLoading || isRunning || queuedPrompt === null) return; const prompt = queuedPrompt; setQueuedPrompt(null); sendPromptThroughRuntime(aui, prompt); }, [aui, isLoading, isRunning, queuedPrompt]); useEffect(() => { if (isLoading) return; const update = updateAssistantNotificationState(notificationStateRef.current, { conversationId, notificationKey, }); notificationStateRef.current = update.state; if (!update.shouldNotify) return; setActivityDismissed(false); if (opened) { setUnreadCount(0); return; } setUnreadCount((current) => current + 1); }, [conversationId, isLoading, notificationKey, opened]); useEffect(() => { if (isLoading) return; const update = updateAssistantMutationRefreshState( mutationRefreshStateRef.current, conversationId, successfulMutationIds, ); mutationRefreshStateRef.current = update.state; if (!update.shouldRefresh) return; void refreshCurrentView().catch(() => { showWarningNotification({ title: t("refresh.failedTitle"), message: t("refresh.failedDescription"), }); }); }, [conversationId, isLoading, refreshCurrentView, successfulMutationIds, t]); useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]); const spotlightItem = useMemo(() => { const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action"; return { id: "homarr-assistant", name: t("spotlight"), icon: "/logo/logo.png", description: canSend ? t("spotlightDescription") : t("busy.description"), unavailable: !canSend, alwaysVisible: true, interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, onPromptAccepted: closeSpotlight, prompt: query, canSend, }), }; }, [assistantIsRunning, latestStatus?.type, sendPrompt, t]); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( () => ({ enabled: true, unavailableDescription: null, opened, isRunning: assistantIsRunning, isRefreshing, unreadCount, open, close, toggle, sendPrompt, refreshCurrentView, }), [assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount], ); 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} isRefreshing={isRefreshing} onRefresh={refreshCurrentView} onModelChange={selectModel} onReasoningChange={preferences.setReasoning} /> ); }; interface DisabledAssistantProviderProps extends PropsWithChildren { description: string; } const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => { const t = useScopedI18n("common.assistant"); const spotlightItem = useMemo( () => ({ id: "homarr-assistant", name: t("spotlight"), icon: "/logo/logo.png", description, unavailable: true, alwaysVisible: true, interaction: (_query: string) => ({ type: "none" as const }), }), [description, t], ); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( () => ({ enabled: false, unavailableDescription: description, opened: false, isRunning: false, isRefreshing: false, unreadCount: 0, open: () => undefined, close: () => undefined, toggle: () => undefined, sendPrompt: () => false, refreshCurrentView: () => Promise.resolve(), }), [description], ); return ( {children} ); }; export const AssistantProvider = ({ children }: PropsWithChildren) => { const t = useScopedI18n("common.assistant"); const session = useSession(); const { data, isLoading, isError } = clientApi.assistant.getAvailability.useQuery(undefined, { enabled: session.status === "authenticated", staleTime: 60_000, }); const enabled = Boolean(data?.enabled && session.status === "authenticated"); if (enabled) { return ( {children} ); } const unavailableDescription = session.status !== "authenticated" ? t("unavailable.signIn") : isLoading ? t("unavailable.checking") : isError ? t("unavailable.error") : t("unavailable.notConfigured"); return {children}; };