diff --git a/apps/nextjs/src/app/[locale]/_client-providers/floating-chat.tsx b/apps/nextjs/src/app/[locale]/_client-providers/floating-chat.tsx deleted file mode 100644 index 60180c76a..000000000 --- a/apps/nextjs/src/app/[locale]/_client-providers/floating-chat.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -const FloatingChatButton = dynamic( - () => import("~/components/board/floating-chat-button").then((mod) => mod.FloatingChatButton), - { ssr: false }, -); - -export const FloatingChatButtonProvider = () => { - return ; -}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/open-webui-chat.tsx b/apps/nextjs/src/app/[locale]/_client-providers/open-webui-chat.tsx new file mode 100644 index 000000000..9c1309fb7 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/open-webui-chat.tsx @@ -0,0 +1,11 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), { + ssr: false, +}); + +export const OpenWebUiChatProvider = () => { + return ; +}; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index c1790ee2f..0f9450f4b 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -32,7 +32,7 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { DayJsLoader } from "./_client-providers/dayjs-loader"; -import { FloatingChatButtonProvider } from "./_client-providers/floating-chat"; +import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat"; import { JotaiProvider } from "./_client-providers/jotai"; import { CustomMantineProvider } from "./_client-providers/mantine"; import { AuthProvider } from "./_client-providers/session"; @@ -148,7 +148,7 @@ export default async function Layout(props: { - + {props.children} diff --git a/apps/nextjs/src/components/board/floating-chat-button.tsx b/apps/nextjs/src/components/board/floating-chat-button.tsx deleted file mode 100644 index dee33f18e..000000000 --- a/apps/nextjs/src/components/board/floating-chat-button.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { ActionIcon, Box, Dialog, Modal } from "@mantine/core"; -import { useDisclosure, useMediaQuery } from "@mantine/hooks"; -import { IconRobot } from "@tabler/icons-react"; - -import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; -import { useI18n } from "@homarr/translation/client"; -import { Chat } from "@homarr/widgets/_open-webui/chat"; - -export const FloatingChatButton = () => { - const t = useI18n(); - const { data: session } = useSession(); - const [opened, { open, close }] = useDisclosure(false); - const isMobile = useMediaQuery("(max-width: 767px)"); - - const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, { - enabled: !!session?.user?.id, - refetchOnWindowFocus: false, - staleTime: 5 * 60 * 1000, - }); - - // Find the first OpenWebUI integration where the current user is the creator. - const myOpenWebUiIntegration = useMemo(() => { - if (!session?.user?.id) return null; - return integrations.find( - (integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id, - ); - }, [integrations, session?.user?.id]); - - if (!myOpenWebUiIntegration) return null; - - return ( - <> - {!opened && ( - - - - )} - - {isMobile ? ( - - - { - // No-op: options are fixed for the floating chat. - }} - width={800} - height={600} - /> - - - ) : ( - - - { - // No-op: options are fixed for the floating chat. - }} - width={800} - height={600} - /> - - - )} - - ); -}; diff --git a/apps/nextjs/src/components/open-webui-chat.tsx b/apps/nextjs/src/components/open-webui-chat.tsx new file mode 100644 index 000000000..491823091 --- /dev/null +++ b/apps/nextjs/src/components/open-webui-chat.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, Drawer } from "@mantine/core"; +import { useHotkeys, useMediaQuery } from "@mantine/hooks"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { hotkeys } from "@homarr/definitions"; +import { openWebUiChatEvent } from "@homarr/spotlight"; +import { useI18n } from "@homarr/translation/client"; +import { Chat } from "@homarr/widgets/_open-webui/chat"; + +/** + * App-wide launcher for the Open WebUI chat. Renders a right-side panel that can + * be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind, + * or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window + * event). Renders nothing unless the signed-in user owns an Open WebUI + * integration. + */ +export const OpenWebUiChat = () => { + const t = useI18n(); + const { data: session } = useSession(); + const isMobile = useMediaQuery("(max-width: 767px)"); + + const [opened, setOpened] = useState(false); + const [initialQuery, setInitialQuery] = useState(undefined); + // Bumped on every open so the chat remounts and a fresh initial query is sent. + const [openNonce, setOpenNonce] = useState(0); + + const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session?.user?.id), + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + }); + + const myOpenWebUiIntegration = useMemo(() => { + if (!session?.user?.id) return null; + return integrations.find( + (integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id, + ); + }, [integrations, session?.user?.id]); + + const open = useCallback((query?: string) => { + setInitialQuery(query?.trim() ? query.trim() : undefined); + setOpenNonce((nonce) => nonce + 1); + setOpened(true); + }, []); + + const toggle = useCallback(() => { + setOpened((previous) => { + if (previous) return false; + setInitialQuery(undefined); + setOpenNonce((nonce) => nonce + 1); + return true; + }); + }, []); + + // Open (with an optional query) when the spotlight "ask AI" mode dispatches. + useEffect(() => { + const handler = (event: Event) => { + if (!myOpenWebUiIntegration) return; + open((event as CustomEvent<{ query?: string }>).detail?.query); + }; + window.addEventListener(openWebUiChatEvent, handler); + return () => window.removeEventListener(openWebUiChatEvent, handler); + }, [open, myOpenWebUiIntegration]); + + useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]); + + if (!myOpenWebUiIntegration) return null; + + return ( + setOpened(false)} + position="right" + size={isMobile ? "100%" : 480} + title={t("widget.openWebUi.name")} + keepMounted={false} + styles={{ + body: { + height: "calc(100dvh - 60px)", + display: "flex", + flexDirection: "column", + padding: 0, + }, + }} + > + + { + // No-op: options are fixed for the launcher chat. + }} + width={480} + height={600} + initialQuery={initialQuery} + /> + + + ); +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index cf6d013a2..6d867076f 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -11,14 +11,17 @@ import { IconHome, IconLogin, IconLogout, + IconRobot, IconSettings, IconTool, } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; import { signOut, useSession } from "@homarr/auth/client"; import { hotkeys } from "@homarr/definitions"; import { createModal, useModalAction } from "@homarr/modals"; +import { openOpenWebUiChat } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -46,6 +49,15 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab const { openModal } = useModalAction(LogoutModal); const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); + const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session.data?.user?.id), + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + }); + const hasOpenWebUiIntegration = integrations.some( + (integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id, + ); + const handleSignout = useCallback(async () => { await signOut({ redirect: false, @@ -96,6 +108,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab {t("docker")} )} + {hasOpenWebUiIntegration && ( + } onClick={() => openOpenWebUiChat()}> + {t("aiChat")} + + )} )} diff --git a/packages/definitions/src/hotkeys.ts b/packages/definitions/src/hotkeys.ts index b3a8cab42..28fff84bb 100644 --- a/packages/definitions/src/hotkeys.ts +++ b/packages/definitions/src/hotkeys.ts @@ -3,4 +3,5 @@ export const hotkeys = { toggleColorScheme: "mod+j", saveNotebook: "mod+s", openSpotlight: "mod+k", + toggleAiChat: "mod+shift+a", }; diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index 3433b7125..b1d3deaf6 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -1,9 +1,9 @@ "use client"; -import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store"; +import { mediaRequestSearchEvent, openWebUiChatEvent, spotlightActions } from "./spotlight-store"; export { Spotlight } from "./components/spotlight"; -export { openSpotlight, openMediaRequestSearch }; +export { openSpotlight, openMediaRequestSearch, openOpenWebUiChat }; export { SpotlightProvider, useRegisterSpotlightContextResults, @@ -25,4 +25,14 @@ const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => spotlightActions.open(); }; -export { mediaRequestSearchEvent }; +export { mediaRequestSearchEvent, openWebUiChatEvent }; + +// Opens the Open WebUI chat side panel, optionally seeding it with a query to +// start a conversation from. Closes the spotlight so the panel takes over. +const openOpenWebUiChat = (query?: string) => { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } })); + } + + spotlightActions.close(); +}; diff --git a/packages/spotlight/src/modes/ask-ai/ask-ai-group.tsx b/packages/spotlight/src/modes/ask-ai/ask-ai-group.tsx new file mode 100644 index 000000000..6ca6760e7 --- /dev/null +++ b/packages/spotlight/src/modes/ask-ai/ask-ai-group.tsx @@ -0,0 +1,61 @@ +import { Group, Text } from "@mantine/core"; +import { IconRobot } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; +import { openWebUiChatEvent } from "../../spotlight-store"; + +interface AskAiOption { + key: string; + query: string; +} + +// Opening the panel is decoupled from the app via a window event so the +// spotlight package doesn't depend on the Next.js app. +const dispatchOpenChat = (query: string) => { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } })); + } +}; + +// "Ask AI" — starts an Open WebUI conversation from the spotlight query. +export const askAiGroup = createGroup({ + keyPath: "key", + title: (t) => t("search.mode.askAi.title"), + Component: (option) => { + const t = useScopedI18n("search.mode.askAi.group.ask"); + return ( + + + {option.query ? t("startWithQuery", { query: option.query }) : t("hint")} + + ); + }, + useInteraction: interaction.javaScript((_option, query) => ({ + onSelect() { + dispatchOpenChat(query); + }, + closeSpotlightOnTrigger: true, + })), + useQueryOptions(query) { + const { data: session } = useSession(); + const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session?.user?.id), + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + }); + const hasOpenWebUiIntegration = integrations.some( + (integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id, + ); + + return { + data: hasOpenWebUiIntegration ? [{ key: "ask-ai", query }] : [], + isLoading: false, + isError: false, + }; + }, +}); diff --git a/packages/spotlight/src/modes/ask-ai/index.tsx b/packages/spotlight/src/modes/ask-ai/index.tsx new file mode 100644 index 000000000..5ed36dee7 --- /dev/null +++ b/packages/spotlight/src/modes/ask-ai/index.tsx @@ -0,0 +1,8 @@ +import type { SearchMode } from "../../lib/mode"; +import { askAiGroup } from "./ask-ai-group"; + +export const askAiMode = { + modeKey: "askAi", + character: "?", + groups: [askAiGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/help/home-empty-groups.tsx b/packages/spotlight/src/modes/help/home-empty-groups.tsx index 459e69df0..6a6ae3a8a 100644 --- a/packages/spotlight/src/modes/help/home-empty-groups.tsx +++ b/packages/spotlight/src/modes/help/home-empty-groups.tsx @@ -8,6 +8,7 @@ import { IconSettings, } from "@tabler/icons-react"; +import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createDocumentationLink } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; @@ -17,6 +18,7 @@ import { createGroup } from "../../lib/group"; import { interaction } from "../../lib/interaction"; import type { SearchMode } from "../../lib/mode"; import { appIntegrationBoardMode } from "../app-integration-board"; +import { askAiMode } from "../ask-ai"; import { commandMode } from "../command"; import { externalMode } from "../external"; import { mediaMode } from "../media"; @@ -40,6 +42,16 @@ export const useHomeEmptyGroups = () => { visibleSearchModes.unshift(userGroupMode); } + // Only surface the "Ask AI" mode when the user owns an Open WebUI integration. + const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session?.user?.id), + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + }); + if (integrations.some((integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id)) { + visibleSearchModes.push(askAiMode); + } + return [ createGroup({ keyPath: "key", diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx index 0c860069f..6b62f24cb 100644 --- a/packages/spotlight/src/modes/index.tsx +++ b/packages/spotlight/src/modes/index.tsx @@ -1,4 +1,5 @@ import { appIntegrationBoardMode } from "./app-integration-board"; +import { askAiMode } from "./ask-ai"; import { commandMode } from "./command"; import { externalMode } from "./external"; import { homeMode } from "./home"; @@ -13,5 +14,6 @@ export const searchModes = [ commandMode, pageMode, mediaMode, + askAiMode, homeMode, ] as const; diff --git a/packages/spotlight/src/spotlight-store.ts b/packages/spotlight/src/spotlight-store.ts index a144cdc94..350b8cdb2 100644 --- a/packages/spotlight/src/spotlight-store.ts +++ b/packages/spotlight/src/spotlight-store.ts @@ -14,6 +14,10 @@ export interface MediaRequestSearchScope { export const mediaRequestSearchScopeAtom = atom({}); export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search"; +// Dispatched to open the Open WebUI chat side panel (optionally with a query to +// start a conversation from). The app-level provider listens for this. +export const openWebUiChatEvent = "homarr:spotlight:open-web-ui-chat"; + export const setSelectedAction = (index: number, store: SpotlightStore) => { store.updateState((state) => ({ ...state, selected: index })); }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a2f2bed8a..674cb96fe 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1489,6 +1489,7 @@ "switchToLightMode": "Switch to light mode", "management": "Management", "docker": "Docker", + "aiChat": "AI Chat", "preferences": "Your preferences", "logout": "Logout", "login": "Login", @@ -5787,6 +5788,16 @@ "fetch": "An error occurred while fetching data" }, "mode": { + "askAi": { + "help": "Start an AI chat", + "title": "AI Chat", + "group": { + "ask": { + "startWithQuery": "Ask AI: {query}", + "hint": "Start an AI chat" + } + } + }, "appIntegrationBoard": { "help": "Search for apps, integrations or boards", "group": { diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx index 266e02d77..fd08afbc9 100644 --- a/packages/widgets/src/open-webui/chat.tsx +++ b/packages/widgets/src/open-webui/chat.tsx @@ -49,7 +49,15 @@ import { useCameraCapture } from "./use-camera-capture"; import { useChatStream } from "./use-chat-stream"; import { useVoiceRecording } from "./use-voice-recording"; -export function Chat({ options, integrationIds, isEditMode }: WidgetComponentProps<"openWebUi">) { +// `initialQuery` is only supplied when the chat is opened from outside the board +// (spotlight / side panel "start chat with query"); the board widget renderer +// never passes it. +export function Chat({ + options, + integrationIds, + isEditMode, + initialQuery, +}: WidgetComponentProps<"openWebUi"> & { initialQuery?: string }) { const t = useI18n(); const integrationId = integrationIds.at(0); @@ -151,6 +159,19 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" }); }, [stream.messages, stream.streamingText, composerHeight]); + // When opened with an initial query (spotlight / side panel "start chat with + // query"), send it once the model has loaded. The caller remounts the chat per + // open (via key) so a fresh query is always delivered. + const autoSentRef = useRef(false); + useEffect(() => { + if (autoSentRef.current) return; + const trimmed = initialQuery?.trim(); + if (!trimmed || !model || !integrationId || stream.isStreaming) return; + autoSentRef.current = true; + stream.send({ role: "user", content: trimmed }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialQuery, model, integrationId]); + // Nothing to send yet: no typed text and no inline image attachments. const composerEmpty = !input.trim() && attachments.images.length === 0;