feat(open-webui): header entry, side panel, keybind and spotlight chat

Replace the bottom-right floating chat button with app-wide launchers, as
requested in review:

- side panel: a right-side Drawer (OpenWebUiChat) reusing the chat widget,
  mounted app-wide and shown only to authed users who own an Open WebUI
  integration
- header entry: an 'AI Chat' item in the user-avatar menu (like the Docker item)
- keybind: mod+shift+a toggles the panel (hotkeys.toggleAiChat)
- spotlight: a '?' 'Ask AI' mode that starts a conversation from the typed query
  and opens the panel pre-filled; also surfaced in the Cmd+K help/mode list when
  the integration is present
- the panel and spotlight are bridged to the app via an openWebUiChatEvent
  window event (mirrors the existing media-request search event)

The Chat widget gains an optional initialQuery that auto-sends once on open.
This commit is contained in:
Thomas Camlong committed 2026-07-11 19:11:12 +02:00
1 parent 3d2a2d7b55
commit e648abf78e
15 files changed
+272 -148

No files matched your search

@@ -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 <FloatingChatButton />;
};
@@ -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 <OpenWebUiChat />;
};
+2 -2
View File
@@ -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: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<FloatingChatButtonProvider />
<OpenWebUiChatProvider />
{props.children}
</StackedProvider>
</body>
@@ -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 && (
<ActionIcon
size={56}
radius="xl"
variant="filled"
onClick={open}
aria-label={t("widget.openWebUi.name")}
style={{
position: "fixed",
bottom: 24,
right: 24,
zIndex: 1000,
boxShadow: "var(--mantine-shadow-lg)",
}}
>
<IconRobot size={28} />
</ActionIcon>
)}
{isMobile ? (
<Modal
opened={opened}
onClose={close}
title={t("widget.openWebUi.name")}
fullScreen
styles={{
body: {
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
options={{
systemPrompt: "",
showHistory: true,
}}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the floating chat.
}}
width={800}
height={600}
/>
</Box>
</Modal>
) : (
<Dialog
opened={opened}
onClose={close}
withCloseButton
size="35vw"
position={{ bottom: 100, right: 24 }}
transitionProps={{ transition: "pop-top-right", duration: 200 }}
styles={{
root: {
height: "50dvh",
display: "flex",
flexDirection: "column",
padding: 0,
overflow: "hidden",
},
closeButton: {
zIndex: 10,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
options={{
systemPrompt: "",
showHistory: true,
}}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the floating chat.
}}
width={800}
height={600}
/>
</Box>
</Dialog>
)}
</>
);
};
@@ -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<string | undefined>(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 (
<Drawer
opened={opened}
onClose={() => 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,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
key={openNonce}
options={{ systemPrompt: "", showHistory: true }}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the launcher chat.
}}
width={480}
height={600}
initialQuery={initialQuery}
/>
</Box>
</Drawer>
);
};
@@ -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")}
</Menu.Item>
)}
{hasOpenWebUiIntegration && (
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
{t("aiChat")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+1
View File
@@ -3,4 +3,5 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
};
+13 -3
View File
@@ -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();
};
@@ -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<AskAiOption>({
keyPath: "key",
title: (t) => t("search.mode.askAi.title"),
Component: (option) => {
const t = useScopedI18n("search.mode.askAi.group.ask");
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<IconRobot stroke={1.5} />
<Text>{option.query ? t("startWithQuery", { query: option.query }) : t("hint")}</Text>
</Group>
);
},
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,
};
},
});
@@ -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;
@@ -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",
+2
View File
@@ -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;
@@ -14,6 +14,10 @@ export interface MediaRequestSearchScope {
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
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 }));
};
+11
View File
@@ -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": {
+22 -1
View File
@@ -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;