Compare commits

...
Author SHA1 Message Date
Thomas Camlong 68ce7e73c0 fix(open-webui): use current request-handler API to fix the build
The request handlers were written against an older caching API that dev has
since refactored ('TanStack Query as single source of truth'), breaking the
Turbopack build with a module-not-found on ./lib/cached-integration-request-handler.

- use createIntegrationRequestHandler from ./lib/integration-request-handler
- replace cacheDuration(dayjs)/queryKey with cacheTtlMs (ms)
- update the router consumers: getCachedOrUpdatedDataAsync -> getDataAsync and
  per-call invalidateAsync() -> handler-level invalidateCache()
2026-07-11 19:33:42 +02:00
ajnart 87f1f329da style: auto-fix lint and format 2026-07-11 17:12:44 +00:00
Thomas Camlong 9301e224ec feat(open-webui): tag chats per user, harden error/voice states, add tests
- persistence: tag created/updated Open WebUI chats with a homarr-user:<id> tag
  (via chat meta.tags) so conversations can be found back per Homarr user even
  when an integration/account is shared
- voice recording: distinguish a denied/dismissed microphone permission from a
  generic mic failure and show an actionable message
- widget: surface the models query loading and connection-error states in the
  chat empty state instead of silently showing 'no models'
- tests: cover the final-SSE-frame flush, the default request timeout (and the
  streaming opt-out), and multimodal chat parsing round-trip
2026-07-11 19:11:13 +02:00
Thomas Camlong e648abf78e 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.
2026-07-11 19:11:12 +02:00
Thomas Camlong 3d2a2d7b55 fix(open-webui): drop MCP exposure and address remaining review comments
Per ajnart's review (and CodeRabbit withdrawing its own MCP comment on the
predecessor PR), the Open WebUI chat isn't a meaningful MCP surface:
- remove the .meta({ mcp }) metadata from every openWebUi procedure

Remaining CodeRabbit comments from the predecessor PR that weren't carried over:
- processWeb: constrain the URL to http(s) and reject loopback/metadata hosts
  (private LAN ranges stay allowed for self-hosters); it is also creator-gated
- uploadFile/transcribe: bound the base64 payload and validate filename/type
- docs: align the example URL with the integration default port (8080)
- new chat: clear the composer draft so it doesn't leak into the fresh chat
- web ingestion: keep the typed URL when ingestion fails
- persistence: fall back to the default title for image-only opening turns, and
  don't persist a partial reply from a failed turn as finished history
- multimodal round-trip: make the native chat/history contracts accept the same
  text-or-parts content union as completions, and flatten (not drop) non-text
  nodes when rebuilding transcripts so attachment-backed chats aren't lost.
  Image data URLs are still kept out of persisted history by design.
2026-07-11 19:11:12 +02:00
ajnart c076a36fec style: auto-fix lint and format 2026-07-11 16:29:31 +00:00
Thomas Camlong 951e707438 fix: address CodeRabbit review comments
API / router (packages/api):
- open-webui: expose serializable procedures as MCP tools via .meta({ mcp })
  and gate knowledge/files/notes reads behind protectedProcedure + interact so
  anonymous board viewers can't enumerate private source content
- integration create: wrap integration + creator-permission + secret writes in
  a single cross-driver transaction so a partial failure can't leave an
  unusable integration
- integration test: scope the permission lookup to the created integration

Integration (packages/integrations):
- open-webui: log only safe metadata (not raw completion chunks) on parse
  failure, flush the final buffered SSE frame before treating the stream as
  truncated, and apply a default request timeout (streaming opts out)

Widget (packages/widgets):
- collapse knowledge rows by default to avoid an N+1 getKnowledgeFiles burst
- strip markdown images from assistant output to block untrusted fetches
- reconcile the selected model when the model list changes
- clear draft attachments when switching to a saved chat
- dedupe async note/chat attaches after the fetch resolves
- discard stale persist responses via a session token so a cleared chat can't
  be rebound to an old Open WebUI chat id
- release the mic stream on mid-start failures and mark the recording cancelled
  on unmount so partial audio isn't transcribed
2026-07-11 18:28:04 +02:00
Thomas Camlong e1ba193ea3 db: regenerate integration.creator_id migrations for all dialects
The PR only shipped a SQLite creator_id migration; MySQL and PostgreSQL were
missing (CodeRabbit) and the SQLite one lacked ON DELETE SET NULL. Regenerate
on top of dev's widget_secret base:

- sqlite 0041: ADD creator_id ... REFERENCES user(id) ON DELETE SET NULL
- mysql 0043: ADD creator_id + FK ON DELETE set null (hand-written snapshot
  from dev's 0042 to avoid touching the pre-existing custom_widget_secret FK
  that must stay under MySQL's 64-char identifier limit)
- postgresql 0009: ADD creator_id + FK ON DELETE set null

Schema already declares onDelete: set null on integration.creatorId.
2026-07-11 18:19:08 +02:00
Thomas Camlong 411c809c55 Merge branch 'dev' into feat/openwebui-integration
Resolve conflicts by keeping both openWebUi and dev-side registry entries
(integration/widget kinds, creators, docs sitemap). Take dev's next.config
turbopack settings. Reset SQLite migration base to dev's 0040_add_widget_secret;
the integration.creator_id migrations are regenerated for all dialects in a
follow-up commit.
2026-07-11 18:11:03 +02:00
Thomas 576856d634 fix(beszel): add reliable live stats and filesystem UI (#6289) 2026-07-11 17:55:04 +02:00
Thomas def3ede3bc fix(db): restore widget_secret migration journal entries accidentally removed by #6206 (#6288) 2026-07-11 15:40:06 +02:00
Lok3sh-kumar 1b3eb1ccc8 style: auto-fix lint and format 2026-07-01 15:27:12 +00:00
Lok3sh-Kumar 6f6a989ba6 openwebui: enable user scoped integrations to avoid cross user data leakage 2026-07-01 20:55:58 +05:30
Lok3sh-Kumar 3bb6037d35 db: fix sqlite migrations 2026-07-01 20:55:58 +05:30
Lok3sh-kumar c5e31513e2 style: auto-fix lint and format 2026-06-27 09:25:33 +00:00
Lok3sh-Kumar dae1cae894 Update docs sitemap 2026-06-27 14:53:20 +05:30
Lok3sh-Kumar 41b75ef5a0 feat(open-webui): searchable model picker in floating header
Replace the inline Select with a Combobox-based ModelSelect that filters
models via an in-dropdown search field, and move the model picker, new
chat and history controls into a floating header above the messages.
Assistant avatar now uses the Open WebUI brand logo.
2026-06-27 14:18:15 +05:30
Lok3sh-Kumar 8e0f8cd46a refactor(open-webui): split chat widget into hooks and components
Break the monolithic chat.tsx into focused hooks (use-attachments,
use-chat-stream, use-voice-recording, use-camera-capture) and
presentational components (chat-components), with shared types and
utils extracted to chat-types/chat-utils.

Review fixes applied on top:
- require auth for the streaming completion (protectedProcedure) so
  anonymous board viewers cannot spend the owner's LLM credits
- rename image-only state to images and the picker query options object
  to pickerQueryOptions for clarity
- extract AttachPopover, clearPending(), composerEmpty and
  releaseAudioResources() to remove duplication
2026-06-27 14:17:59 +05:30
Lok3sh-Kumar 84275bd19a fix(open-webui): show knowledge-base documents by default
Expand each knowledge base's file list by default in the picker (with a
"no files" hint when empty) so a base's documents are visible without an
extra click and can be selected individually.
2026-06-27 14:17:27 +05:30
Lok3sh-Kumar b232cc5bb0 feat(open-webui): redesign voice recording with live waveform
Move the mic to the right of the input (shown when the box is empty).
While recording, the input becomes a recording bar with a cancel button,
a live microphone waveform (Web Audio analyser), an elapsed timer and a
confirm button that stops and transcribes into the message box.
2026-06-27 14:17:10 +05:30
Lok3sh-Kumar 18afb866b7 fix(open-webui): list knowledge-base files for the sub-document picker
The knowledge detail endpoint returns `files: null` on recent Open WebUI
versions, so the expandable picker showed nothing to select. List the
files endpoint and filter by the base's collection id instead; each file
grounds via its own `file-{id}` collection.
2026-06-27 14:16:57 +05:30
Lok3sh-Kumar 46454cc0f0 feat(open-webui): voice messages, fix camera capture and full notes
- Voice messages: record from the microphone and transcribe to the input
  via Open WebUI's /api/v1/audio/transcriptions (mic button in composer).
- Camera capture: attach the stream via a ref callback so the preview is
  no longer a black frame (the ref could be read before the video mounted).
- Notes: fetch the full note body on attach. The list endpoint truncates
  content to 1000 chars, so attached notes were cut off; getNote returns
  the complete markdown.
2026-06-27 14:16:42 +05:30
Lok3sh-Kumar d74728c28d feat(open-webui): animated typing indicator while awaiting reply
Replace the static "…" placeholder with a three-dot wave animation shown
in the assistant bubble until the first token streams in. Falls back to a
fade for users who prefer reduced motion.
2026-06-27 14:16:29 +05:30
Lok3sh-Kumar 78bd66e803 feat(open-webui): full attachment menu with all source types
Replace the single attach popover with an Open WebUI-style multi-level
menu covering every attachment source:

- Upload Files: images stay inline (vision); other files are uploaded and
  grounded via retrieval (`file-{id}` collection).
- Capture: take a photo from the webcam as an inline image.
- Attach Webpage: ingest a URL (existing).
- Attach Files: pick files already stored in Open WebUI.
- Attach Notes: inject the contents of a note as context.
- Attach Knowledge: nested picker with checkboxes, expandable to a base's
  files (replaces the flat checkbox list).
- Reference Chats: bring a previous conversation in as context.

Knowledge bases, web pages and files are grounded by retrieval; notes and
referenced chats are injected verbatim as context blocks. Adds the
supporting integration methods (list files/notes, knowledge files, upload
file), request handlers and tRPC procedures.
2026-06-27 14:16:13 +05:30
Lok3sh-Kumar 800caf6e5e fix(open-webui): tidy model selector label and width
Trim the ":latest" tag from model labels, widen the selector and use
ellipsis truncation so long model names no longer clip mid-word.
2026-06-27 14:15:50 +05:30
Lok3sh-Kumar bc26231d13 fix(open-webui): always show Knowledge section in attach menu
The Knowledge picker was hidden entirely when the account had no knowledge
bases, making the feature look missing. Always render the section with an
empty-state hint so it stays discoverable.
2026-06-27 14:15:31 +05:30
Lok3sh-Kumar f61e7539d7 feat(open-webui): add image, knowledge and webpage attachments with retry
Extend the AI Chat widget with three ways to ground a conversation:

- Images: attach images to a message, sent inline as OpenAI multimodal
  content parts for vision-capable models. A hint warns when the selected
  model likely has no vision support.
- Knowledge bases: pick RAG collections exposed by /api/v1/knowledge/.
- Web pages: ingest a URL via /api/v1/retrieval/process/web.

Open WebUI does not run RAG from the chat-completions `files` parameter,
so attached collections (web pages and knowledge bases) are grounded by
retrieving the most relevant chunks at send time
(/api/v1/retrieval/query/collection) and injecting them as system context.

Also add a Retry action to re-run the last turn after a model failure, and
handle the paginated `{ items, total }` shape of the knowledge endpoint on
newer Open WebUI versions.
2026-06-27 14:15:08 +05:30
Lok3sh-Kumar 005c2e4cd1 feat: add Open WebUI AI chat integration and widget
Adds a new "openWebUi" integration kind (under a new "ai" category) and an
AI Chat widget for chatting with models served by Open WebUI.

- Integration: Bearer API key auth, model list, native chat history CRUD, and
  token-by-token streaming chat completions (SSE).
- Widget: in-input model picker, streaming responses, and an in-widget history
  panel. Reconstructs conversations from Open WebUI's message tree with a flat
  fallback.
- Streaming is delivered over the WebSocket subscription link.
- Adds integration and widget documentation.
2026-06-27 14:12:48 +05:30
94 changed files with 13203 additions and 711 deletions

No files matched your search

@@ -0,0 +1,39 @@
---
title: "Open WebUI"
description: "Self-hosted AI chat interface for local and remote LLM providers"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { openWebUiIntegration } from ".";
import { openWebUiWidget } from "../../widgets/open-webui";
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["apiKey"],
steps: [],
},
]}
/>
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const openWebUiIntegration: IntegrationDefinition = {
name: "Open WebUI",
description: "Self-hosted AI chat interface for local and remote LLM providers",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
path: "../../integrations/open-webui",
};
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Screenshots
@@ -1,24 +1,21 @@
---
title: 'Beszel System Stats'
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { beszelSystemStatsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from "@site/docs/integrations/beszel";
import beszelSystemStats from './img/beszel-system-stats.png';
import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader
widget={beszelSystemStatsWidget}
categories={['System Monitoring', 'Charts']}
/>
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Supported Integrations
@@ -0,0 +1,56 @@
---
title: "AI Chat"
description: "Chat with AI models through Open WebUI, with model picking and history."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { openWebUiWidget } from ".";
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: openWebUiIntegration,
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={openWebUiWidget.configuration} />
### Features
| Feature | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
| **Streaming responses** | Assistant replies stream in live as they are generated. |
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
| **Notes** | Attach the contents of your Open WebUI notes. |
| **Reference chats** | Bring a previous conversation in as context for the current one. |
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
### Notes
- The widget uses a single Open WebUI integration.
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
@@ -0,0 +1,25 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const openWebUiWidget: WidgetDefinition = {
icon: IconRobot,
name: "AI Chat",
description: "Chat with AI models through Open WebUI, with model picking and history.",
path: "../../widgets/open-webui",
configuration: {
items: [
{
name: "System prompt",
description: "Optional instruction prepended to every conversation.",
values: { type: "string" },
defaultValue: "empty",
},
{
name: "Show chat history",
description: "Show a button to browse and reopen previous Open WebUI chats.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -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 />;
};
@@ -10,6 +10,7 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => {
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) {
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
+2
View File
@@ -32,6 +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 { 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";
@@ -147,6 +148,7 @@ export default async function Layout(props: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<OpenWebUiChatProvider />
{props.children}
</StackedProvider>
</body>
@@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
@@ -49,7 +49,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
@@ -276,7 +279,7 @@ const WidgetCacheAge = ({
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
@@ -295,8 +298,8 @@ const WidgetCacheAge = ({
.filter(Boolean);
if (timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
const seconds = Math.floor((Date.now() - oldest) / 1000);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
@@ -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
@@ -4,6 +4,7 @@ export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});
+1 -1
View File
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
@@ -99,6 +99,7 @@ export const integrationRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
url: integration.url,
creatorId: integration.creatorId,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -344,23 +345,66 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
const userId = ctx.session.user.id;
const secretValues = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
});
logger.info("Created integration", {
id: integrationId,
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -301,6 +302,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -317,10 +322,23 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -336,6 +354,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
@@ -357,6 +379,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -374,6 +397,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -405,6 +432,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -431,6 +459,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -455,6 +487,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
+39 -1
View File
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0;
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
void (async () => {
const integration = ctx.integrations[0];
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
},
controller.signal,
);
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
}
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.error(
error instanceof TRPCError
? error
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
}
if (isActive) {
logger.debug("Beszel realtime subscription completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
emit.complete();
}
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
+1
View File
@@ -43,5 +43,6 @@ export const widgetRouter = createTRPCRouter({
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -0,0 +1,379 @@
import { observable } from "@trpc/server/observable";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import {
openWebUiChatRequestHandler,
openWebUiChatsRequestHandler,
openWebUiFilesRequestHandler,
openWebUiKnowledgeFilesRequestHandler,
openWebUiKnowledgeRequestHandler,
openWebUiModelsRequestHandler,
openWebUiNoteRequestHandler,
openWebUiNotesRequestHandler,
} from "@homarr/request-handler/open-webui";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "openWebUiRouter" });
/**
* Open WebUI integrations hold a personal API key that grants access to a
* single user's data (chat history, files, notes, knowledge bases). To prevent
* other Homarr users from reading that data, every procedure that touches the
* integration verifies that the caller is the integration's creator.
*
* The check is intentionally placed **after** the integration middleware (which
* already validates the caller's permission level) so the error message does
* not leak whether the integration exists.
*/
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
// Existing integrations that predate the creatorId column have a null
// creatorId; we skip the check for backward compatibility.
if (integration.creatorId && integration.creatorId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
}
};
// Hosts that should never be fetched server-side. processWeb is already
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
const isBlockedWebHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::" ||
host === "::1" ||
host.startsWith("127.") ||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
host === "metadata.google.internal"
);
};
// A user-provided URL for web-page ingestion: must be http(s) and not target a
// loopback/metadata host.
const webUrlSchema = z
.string()
.url()
.refine((value) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
// Bounds the upload/transcription payloads so a huge request can't pressure
// memory here or upstream before we even start decoding.
const MAX_BASE64_LENGTH = 25_000_000;
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
const uploadFilenameSchema = z.string().min(1).max(255);
const contentTypeSchema = z.string().min(1).max(255);
// Tag persisted conversations with the owning Homarr user so they can be found
// back in Open WebUI even when an integration/account is shared between users.
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
});
// Outbound completion messages may carry multimodal parts (text + images),
// so users can attach images to a message.
const contentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
const completionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(contentPartSchema)]),
});
const chatPayloadSchema = z.object({
title: z.string(),
models: z.array(z.string()),
messages: z.array(messageSchema),
});
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
type CompletionMessage = z.infer<typeof completionMessageSchema>;
// Extract the plain text of a message, flattening multimodal content parts.
const messageText = (message: CompletionMessage): string =>
typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
// Number of chunks to retrieve per collection when grounding a reply.
const RETRIEVAL_K = 4;
// Instruction prepended to the retrieved context so the model knows how to use
// it. Model-facing, so intentionally not localized.
const RETRIEVAL_INSTRUCTION =
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
"files, notes and chats) to answer. If the answer is not in the context, say so.";
// Retrieve relevant chunks from the attached collections and prepend them as a
// system context message so the model can ground its reply on them.
const groundMessagesAsync = async (
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
messages: CompletionMessage[],
collections: string[],
contextTexts: string[],
): Promise<CompletionMessage[]> => {
if (collections.length === 0 && contextTexts.length === 0) return messages;
// Use the most recent user message that has text. The latest turn can be
// image-only (no text), in which case we fall back to the prior question so
// attached collections still get a meaningful retrieval query.
const query =
messages
.filter((message) => message.role === "user")
.map(messageText)
.findLast((text) => text.trim() !== "") ?? "";
let documents: string[] = [];
if (collections.length > 0) {
try {
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
}
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
if (blocks.length === 0) return messages;
const contextMessage: CompletionMessage = {
role: "system",
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
};
return [contextMessage, ...messages];
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Knowledge bases, files and notes expose private source content/metadata, so
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getFiles: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNotes: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNote: protectedProcedure
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getDataAsync();
return data;
}),
getKnowledgeFiles: protectedProcedure
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
const { data } = await handler.getDataAsync();
return data;
}),
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
// the file id; its vector collection is `file-{id}`.
uploadFile: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
}),
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
return { text };
}),
// Full chat messages contain private user data; restrict to the creator.
getChat: protectedProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
const { data } = await handler.getDataAsync();
return data;
}),
createChat: protectedProcedure
.input(chatPayloadSchema)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.createChatAsync({
title: input.title,
models: input.models,
messages: input.messages,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
return chat;
}),
updateChat: protectedProcedure
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.updateChatAsync(input.chatId, {
...input.chat,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
openWebUiChatRequestHandler.invalidateCache();
return chat;
}),
// Streaming chat completion. Delivered over the WebSocket subscription link so
// tokens can be emitted as they arrive. A completion spends the owner's upstream
// LLM credits, so it must not be reachable by anonymous viewers of a public
// board: protectedProcedure requires a session (the WS context resolves it from
// the session-token cookie) and the "interact" middleware checks integration
// access, matching processWeb/uploadFile/transcribe above.
sendMessage: protectedProcedure
.input(
z.object({
model: z.string(),
messages: z.array(completionMessageSchema),
// Collections to ground the reply on (web pages, knowledge bases, files).
// We retrieve relevant chunks and inject them before streaming.
collections: z.array(z.string()).optional(),
// Verbatim context blocks (notes, referenced chats) injected as-is.
contextTexts: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
return observable<StreamEvent>((emit) => {
const controller = new AbortController();
void (async () => {
try {
const client = await createIntegrationAsync(ctx.integration);
const messages = await groundMessagesAsync(
client,
input.messages,
input.collections ?? [],
input.contextTexts ?? [],
);
await client.streamChatCompletionAsync(
{ model: input.model, messages },
(delta) => emit.next({ type: "delta", content: delta }),
controller.signal,
);
emit.next({ type: "done" });
emit.complete();
} catch (error) {
if (!controller.signal.aborted) {
logger.error("Open WebUI chat completion failed", {
integrationId: ctx.integration.id,
error,
});
emit.next({
type: "error",
message: error instanceof Error ? error.message : "Failed to stream chat completion",
});
}
emit.complete();
}
})();
return () => {
controller.abort();
};
});
}),
});
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
@@ -11,6 +11,7 @@ interface IntegrationContextProps {
name: string;
url: string;
kind: IntegrationKind;
creatorId: string | null;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -15,6 +15,7 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true,
url: true,
kind: true,
creatorId: true,
},
with: {
userPermissions: {
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
};
};
export const createCertificateAgentAsync = async (override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
}) => {
export const createCertificateAgentAsync = async (
override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
) => {
return new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number },
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
...options,
...fetchOptions,
signal,
dispatcher: agent,
}),
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, {
...options,
...fetchOptions,
dispatcher: agent,
});
};
@@ -0,0 +1,2 @@
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -295,6 +295,20 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783786572320,
"tag": "0043_add_integration_creator_id",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -57,6 +57,20 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786378808,
"tag": "0009_add_integration_creator_id",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
File diff suppressed because it is too large. Load diff
@@ -281,6 +281,20 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786326756,
"tag": "0041_add_integration_creator_id",
"breakpoints": true
}
]
}
+5
View File
@@ -209,6 +209,7 @@ export const integrations = mysqlTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -688,6 +689,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5
View File
@@ -208,6 +208,7 @@ export const integrations = pgTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -687,6 +688,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5
View File
@@ -195,6 +195,7 @@ export const integrations = sqliteTable(
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -677,6 +678,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
@@ -329,6 +329,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/open-webui"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -381,4 +382,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api";
| "/docs/widgets/custom-api"
| "/docs/widgets/open-webui";
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
openWebUi: "open-webui",
mock: null,
};
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
openWebUi: "open-webui",
};
+1
View File
@@ -3,4 +3,5 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
};
+10
View File
@@ -469,6 +469,15 @@ export const integrationDefs = {
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
openWebUi: {
name: "Open WebUI",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
category: ["ai"],
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
defaultPort: 8080,
apiKeySettingsPath: "/?settings=open#account",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -583,6 +592,7 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"ai",
"subtitleManager",
"reverseProxy",
] as const;
@@ -43,6 +43,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
openWebUi: ["openWebUi"],
traefik: ["traefik"],
};
+1
View File
@@ -53,6 +53,7 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"openWebUi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -32,6 +32,7 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
@@ -131,6 +132,7 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
openWebUi: OpenWebUiIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
@@ -19,15 +19,66 @@ import type {
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
BeszelSystemStats,
BeszelContainerStats,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
const supportsRealtimeMetrics = (version: string) => {
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
return major > 0 || minor >= 13;
};
const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
await new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const timeout = setTimeout(done, delayMs);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timeout);
signal.removeEventListener("abort", done);
resolve();
}
});
interface BeszelRealtimeSnapshot {
stats?: BeszelSystemStats;
container?: BeszelContainerStats[];
}
export const normalizeRealtimeSnapshot = (
payload: BeszelRealtimeSnapshot,
systemId: string,
receivedAt = new Date(),
): LiveStatsEvent[] => {
const created = receivedAt.toISOString();
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
});
}
if (Array.isArray(payload.container)) {
events.push({
type: "container_stats",
record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
});
}
return events;
};
interface BeszelSession {
token: string;
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
});
}
/**
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
/** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const session = await this.authenticateAsync();
const realtimeUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection for realtime metrics", {
integrationId: this.integration.id,
systemId,
url: realtimeUrl.pathname,
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
const systems = await this.getSystemsAsync();
const system = systems.find((candidate) => candidate.id === systemId);
if (!system) throw new Error("Selected Beszel system was not found");
if (system.status !== "up")
throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
if (!supportsRealtimeMetrics(system.info.v)) {
throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body in SSE stream");
const decoder = new TextDecoder();
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
const realtimeUrl = this.url("/api/realtime");
const topic = createRealtimeMetricsTopic(systemId);
let retryAttempt = 0;
let emittedSnapshotCount = 0;
while (!signal.aborted) {
try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
let session = await this.authenticateAsync();
const openStream = async () =>
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Content-Type": "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
bodyTimeout: 0,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id,
systemId,
retryAttempt,
});
let response = await openStream();
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
}
if (!response.ok) throw new ResponseError(response);
const reader = response.body?.getReader();
if (!reader) throw new Error("Beszel realtime response has no body");
const decoder = new TextDecoder();
const parser = new PocketBaseSseParser();
let clientId: string | undefined;
const processFrameAsync = async (frame: ReturnType<PocketBaseSseParser["push"]>[number]) => {
if (frame.event === "PB_CONNECT") {
let fallbackClientId: string | undefined;
try {
const connectData = JSON.parse(frame.data) as { clientId?: string };
fallbackClientId = connectData.clientId;
} catch {
// PocketBase normally provides the client ID in the SSE id field.
}
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
clientId,
});
throw new ResponseError(subscribeResponse);
return;
}
logger.debug("Beszel SSE subscribed to collections", {
if (!frame.event.startsWith("rt_metrics")) return;
try {
const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
const events = normalizeRealtimeSnapshot(snapshot, systemId);
if (events.length === 0) return;
emittedSnapshotCount += 1;
retryAttempt = 0;
for (const event of events) onMessage(event);
if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
logger.debug("Forwarded Beszel rt_metrics snapshots", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
containerCount: snapshot.container?.length ?? 0,
});
}
} catch (error) {
logger.warn("Failed to parse Beszel rt_metrics payload", {
integrationId: this.integration.id,
systemId,
error: error instanceof Error ? error.message : String(error),
});
}
};
logger.debug("Beszel rt_metrics SSE connection established", {
integrationId: this.integration.id,
systemId,
contentType: response.headers.get("content-type"),
});
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
await processFrameAsync(frame);
}
}
for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
for (const frame of parser.finish()) await processFrameAsync(frame);
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
continue;
}
const record = parsed.record as Record<string, unknown> | undefined;
if (!record || record.system !== systemId) return;
if (Array.isArray(record.stats)) {
onMessage({
type: "container_stats",
record: record as unknown as BeszelContainerStatsRecord,
});
} else if (record.stats && typeof record.stats === "object") {
onMessage({
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
retryAttempt += 1;
logger.warn("Beszel rt_metrics connection lost; retrying", {
integrationId: this.integration.id,
systemId,
retryAttempt,
delayMs,
error: error instanceof Error ? error.message : String(error),
});
await waitForRetryAsync(delayMs, signal);
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
logger.debug("Beszel rt_metrics subscription stopped", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
dr: number;
dr?: number;
/** disk write (MB/s) */
dw: number;
dw?: number;
/** disk read max (MB/s) */
drm?: number;
/** disk write max (MB/s) */
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
ns: number;
ns?: number;
/** network received — all interfaces (bytes/s) */
nr: number;
nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
}
export interface BeszelExtraFsStats {
/** total disk (bytes) */
/** total disk (GiB) */
d: number;
/** disk used (bytes) */
/** disk used (GiB) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
rm: number;
rm?: number;
/** write max (bytes/s) */
wm: number;
wm?: number;
/** read (IOPS) */
rb: number;
rb?: number;
/** write (IOPS) */
wb: number;
wb?: number;
/** read max (IOPS) */
rbm: number;
rbm?: number;
/** write max (IOPS) */
wbm: number;
wbm?: number;
}
export interface BeszelGPUData {
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
memory: number;
/** disk usage (%) */
disk: number;
/** extra filesystem usage (%) keyed by mount path */
extraFilesystems: Record<string, number>;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
@@ -0,0 +1,65 @@
export interface PocketBaseSseFrame {
event: string;
id?: string;
data: string;
}
export const createRealtimeMetricsTopic = (systemId: string) =>
`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
export class PocketBaseSseParser {
private buffer = "";
private event = "message";
private id: string | undefined;
private dataLines: string[] = [];
public push(chunk: string): PocketBaseSseFrame[] {
this.buffer += chunk;
const frames: PocketBaseSseFrame[] = [];
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const frame = this.processLine(line);
if (frame) frames.push(frame);
}
return frames;
}
public finish(): PocketBaseSseFrame[] {
const frames: PocketBaseSseFrame[] = [];
if (this.buffer !== "") {
const frame = this.processLine(this.buffer);
if (frame) frames.push(frame);
this.buffer = "";
}
const trailingFrame = this.dispatch();
if (trailingFrame) frames.push(trailingFrame);
return frames;
}
private processLine(line: string): PocketBaseSseFrame | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colonIndex = line.indexOf(":");
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.event = value;
else if (field === "id") this.id = value;
else if (field === "data") this.dataLines.push(value);
return null;
}
private dispatch(): PocketBaseSseFrame | null {
if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
this.event = "message";
this.id = undefined;
this.dataLines = [];
return frame;
}
}
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
import type { LiveStatsEvent } from "../beszel-types";
import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
describe("normalizeRealtimeSnapshot", () => {
test("normalizes current Beszel host and container snapshots", () => {
const receivedAt = new Date("2026-07-11T13:51:30.217Z");
const events = normalizeRealtimeSnapshot(
{
stats: {
cpu: 18.07,
m: 31.09,
mu: 16.53,
mp: 53.18,
mb: 14.32,
s: 4,
su: 3.9,
d: 938.8,
du: 455.81,
dp: 51.15,
efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
},
container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
},
"system-1",
receivedAt,
);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
type: "system_stats",
record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
});
expect(events[1]).toMatchObject({
type: "container_stats",
record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
});
});
test("ignores snapshots without metric data", () => {
expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
});
});
describe("subscribeRealtimeMetrics", () => {
test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
const originalFetch = globalThis.fetch;
const topic = createRealtimeMetricsTopic("system-1");
const subscriptionBodies: unknown[] = [];
const encoder = new TextEncoder();
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
return Response.json({ token: "test-token", record: { id: "user-1" } });
}
if (url.includes("/api/collections/systems/records")) {
return Response.json({
page: 1,
perPage: 500,
totalItems: 1,
totalPages: 1,
items: [
{
id: "system-1",
name: "Server",
host: "localhost",
port: "45876",
status: "up",
info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
created: "2026-07-11T00:00:00.000Z",
updated: "2026-07-11T00:00:00.000Z",
},
],
});
}
if (url.endsWith("/api/realtime") && init?.method === "POST") {
subscriptionBodies.push(JSON.parse(String(init.body)));
expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/realtime")) {
const snapshot = JSON.stringify({
stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
container: [{ n: "homarr", c: 1, m: 256 }],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
controller.close();
},
});
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
}
throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
}) as typeof fetch;
try {
const integration = createBeszelIntegration();
const controller = new AbortController();
const events: LiveStatsEvent[] = [];
await integration.subscribeRealtimeMetrics(
"system-1",
(event) => {
events.push(event);
if (events.length === 2) controller.abort();
},
controller.signal,
);
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function createBeszelIntegration() {
return new BeszelIntegration({
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
describe("PocketBaseSseParser", () => {
test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
});
test("joins multiline data and ignores keepalive comments", () => {
const parser = new PocketBaseSseParser();
expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
{ event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
]);
});
test("flushes a trailing frame when the stream ends without a blank line", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
});
});
describe("createRealtimeMetricsTopic", () => {
test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
const topic = createRealtimeMetricsTopic("system-1");
expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
});
});
+1
View File
@@ -42,6 +42,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
@@ -226,6 +226,7 @@ export class BeszelMockService {
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
@@ -0,0 +1,372 @@
import { FormData } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiChatPayload,
OpenWebUiCompletionMessage,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
OpenWebUiWebDocument,
} from "./open-webui-types";
import {
openWebUiChatListItemSchema,
openWebUiChatSchema,
openWebUiCollectionQueryResponseSchema,
openWebUiCompletionChunkSchema,
openWebUiFileListSchema,
openWebUiKnowledgeListSchema,
openWebUiModelsResponseSchema,
openWebUiNoteListSchema,
openWebUiNoteSchema,
openWebUiProcessWebResponseSchema,
openWebUiTranscriptionSchema,
openWebUiUploadedFileSchema,
} from "./open-webui-types";
const logger = createLogger({ module: "open-webui-integration" });
// Default per-request timeout so a stalled upstream can't hang model/file/note/
// chat calls indefinitely. Streaming completions opt out (they are long-lived).
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
// The (undici) request options accepted by the shared fetch helper. Derived so
// the request helpers stay in sync with the underlying fetch types.
type RequestOptions = NonNullable<Parameters<typeof fetchWithTrustedCertificatesAsync>[1]>;
export class OpenWebUiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/models"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return { success: true };
}
/**
* List the models served by Open WebUI (aggregates all configured providers).
*/
public async getModelsAsync(): Promise<OpenWebUiModel[]> {
const parsed = await this.getJsonAsync("/api/models", openWebUiModelsResponseSchema);
return parsed.data;
}
/**
* List the knowledge bases (RAG collections) the user can ground chats on.
*/
public async getKnowledgeAsync(): Promise<OpenWebUiKnowledge[]> {
const parsed = await this.getJsonAsync("/api/v1/knowledge/", openWebUiKnowledgeListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's uploaded files (for the "Attach Files" picker).
*/
public async listFilesAsync(): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/**
* List the files contained in a knowledge base (for the expandable picker).
* The knowledge detail endpoint returns `files: null` on recent versions, so
* we read the files list and filter by the base's collection id instead.
*/
public async getKnowledgeFilesAsync(knowledgeId: string): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files
.filter((file) => file.meta?.collection_name === knowledgeId)
.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/** Fetch the raw file list, normalizing the paginated and bare-array shapes. */
private async fetchFilesAsync() {
const parsed = await this.getJsonAsync("/api/v1/files/", openWebUiFileListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's notes (for the "Attach Notes" picker).
*/
public async listNotesAsync(): Promise<OpenWebUiNote[]> {
const parsed = await this.getJsonAsync("/api/v1/notes/", openWebUiNoteListSchema);
const notes = Array.isArray(parsed) ? parsed : parsed.items;
return notes.map((note) => ({
id: note.id,
title: note.title ?? "Untitled note",
content: note.data?.content?.md ?? "",
}));
}
/**
* Get a single note including its full markdown content. The list endpoint
* truncates the body, so we fetch the note directly when attaching it.
*/
public async getNoteAsync(noteId: string): Promise<OpenWebUiNote> {
const parsed = await this.getJsonAsync(`/api/v1/notes/${encodeURIComponent(noteId)}`, openWebUiNoteSchema);
return { id: parsed.id, title: parsed.title ?? "Untitled note", content: parsed.data?.content?.md ?? "" };
}
/**
* Transcribe recorded audio to text (speech-to-text) for voice messages.
*/
public async transcribeAudioAsync(filename: string, base64Content: string, contentType: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/audio/transcriptions", form, openWebUiTranscriptionSchema);
return parsed.text;
}
/**
* Upload a file to Open WebUI so it can be retrieved from later. Returns the
* new file id (its vector collection is `file-{id}`).
*/
public async uploadFileAsync(
filename: string,
base64Content: string,
contentType: string,
): Promise<OpenWebUiFileSummary> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/files/", form, openWebUiUploadedFileSchema);
return { id: parsed.id, name: parsed.meta?.name ?? parsed.filename ?? filename };
}
/**
* Ingest a web page into a temporary vector collection and return a handle to
* retrieve from it. Open WebUI does not crawl URLs at completion time, so we
* embed the page here and inject retrieved chunks ourselves at send time.
*/
public async processWebAsync(url: string): Promise<OpenWebUiWebDocument> {
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/process/web",
{ url, collection_name: "" },
openWebUiProcessWebResponseSchema,
);
return { collectionName: parsed.collection_name, title: parsed.file?.meta?.name ?? url };
}
/**
* Retrieve the most relevant chunks for a query across the given collections
* (web pages and/or knowledge bases). Returns the chunk texts, best-effort.
*/
public async queryCollectionsAsync(collectionNames: string[], query: string, k = 4): Promise<string[]> {
if (collectionNames.length === 0 || query.trim() === "") {
return [];
}
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/query/collection",
{ collection_names: collectionNames, query, k },
openWebUiCollectionQueryResponseSchema,
);
return (parsed.documents ?? []).flat();
}
/**
* List the user's chats (native Open WebUI history).
*/
public async listChatsAsync(): Promise<OpenWebUiChatListItem[]> {
return await this.getJsonAsync("/api/v1/chats/", openWebUiChatListItemSchema.array());
}
/**
* Get a single chat including its messages.
*/
public async getChatAsync(chatId: string): Promise<OpenWebUiChat> {
return await this.getJsonAsync(`/api/v1/chats/${encodeURIComponent(chatId)}`, openWebUiChatSchema);
}
/**
* Create a new chat in the native history.
*/
public async createChatAsync(payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync("/api/v1/chats/new", { chat: payload }, openWebUiChatSchema);
}
/**
* Update an existing chat in the native history.
*/
public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync(
`/api/v1/chats/${encodeURIComponent(chatId)}`,
{ chat: payload },
openWebUiChatSchema,
);
}
/**
* Stream an assistant completion token-by-token.
* Reads the OpenAI-compatible SSE response and invokes `onDelta` for each
* content delta. Resolves when the stream completes ([DONE]) or is aborted.
*/
public async streamChatCompletionAsync(
params: { model: string; messages: OpenWebUiCompletionMessage[] },
onDelta: (delta: string) => void,
signal: AbortSignal,
): Promise<void> {
const response = await this.requestAsync("/api/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
// Long-lived stream: opt out of the default request timeout and rely on the
// caller's abort signal instead (the timeout path would also replace this
// signal, breaking cancellation).
timeout: undefined,
signal,
});
if (!response.body) {
throw new ResponseError(response);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
const payload = trimmed.slice(5).trim();
if (payload === "") return false;
if (payload === "[DONE]") return true;
try {
const parsed = openWebUiCompletionChunkSchema.parse(JSON.parse(payload));
const choice = parsed.choices?.[0];
if (choice?.delta?.content) {
onDelta(choice.delta.content);
}
// Some providers end the stream with a finish_reason chunk and no
// explicit [DONE] sentinel; treat that as a clean completion too.
if (choice?.finish_reason) return true;
} catch (error) {
// The chunk can contain model output or grounded user data, so log only
// safe metadata rather than the raw payload.
logger.debug("Failed to parse Open WebUI completion chunk", {
error: error instanceof Error ? error.message : "unknown",
payloadLength: payload.length,
});
}
return false;
};
let completed = false;
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
// The server may close after the final data: event without a trailing
// newline; parse whatever is left so a valid completion isn't treated
// as a truncated stream.
if (buffer.trim() !== "" && processLine(buffer)) {
completed = true;
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processLine(line)) {
completed = true;
break;
}
}
if (completed) break;
}
} finally {
reader.cancel().catch(() => {
// ignore cancellation errors during teardown
});
}
// The reader ended without a [DONE]/finish_reason marker: the upstream
// connection was cut mid-stream. Surface it instead of presenting the
// partial reply as a complete answer.
if (!signal.aborted && !completed) {
throw new Error("Open WebUI stream ended before completion");
}
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
/**
* Issue an authenticated request against the Open WebUI API and throw a
* {@link ResponseError} on a non-2xx status. Centralizes the auth header,
* trusted-certificate fetch and error handling shared by every endpoint.
*/
private async requestAsync(
path: `/${string}`,
init?: Omit<RequestOptions, "headers"> & { headers?: Record<string, string> },
) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
// Apply a default timeout unless the caller opts out (e.g. streaming
// completions pass `timeout: undefined` to stay open indefinitely). A
// spread after this key lets an explicit caller value win.
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...init,
headers: { ...this.getAuthHeaders(), ...init?.headers },
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
/** GET an endpoint and validate the JSON body against `schema`. */
private async getJsonAsync<TOutput>(
path: `/${string}`,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path);
return schema.parse(await response.json());
}
/** POST a JSON body and validate the JSON response against `schema`. */
private async postJsonAsync<TOutput>(
path: `/${string}`,
body: unknown,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return schema.parse(await response.json());
}
/** POST a multipart form (file upload) and validate the JSON response. */
private async postFormAsync<TOutput>(
path: `/${string}`,
form: FormData,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, { method: "POST", body: form });
return schema.parse(await response.json());
}
}
@@ -0,0 +1,238 @@
// Open WebUI API types and schemas.
// Open WebUI exposes an OpenAI-compatible API plus its own native chat-history API.
// Schemas are intentionally defensive (`.nullish()`) because the native chat
// endpoints are undocumented and vary between Open WebUI versions.
import { z } from "zod/v4";
// GET /api/models — OpenAI-compatible. Open WebUI returns `{ data: [...] }`.
export const openWebUiModelSchema = z.object({
id: z.string(),
name: z.string().nullish(),
object: z.string().nullish(),
owned_by: z.string().nullish(),
// Open WebUI exposes per-model capabilities here when configured; `vision`
// tells us whether the model can accept image attachments.
info: z
.object({
meta: z
.object({
capabilities: z.object({ vision: z.boolean().nullish() }).nullish(),
})
.nullish(),
})
.nullish(),
});
export const openWebUiModelsResponseSchema = z.object({
data: z.array(openWebUiModelSchema),
});
export type OpenWebUiModel = z.infer<typeof openWebUiModelSchema>;
// POST /api/chat/completions (stream: true) — OpenAI-compatible SSE delta chunk.
export const openWebUiCompletionChunkSchema = z.object({
id: z.string().nullish(),
choices: z
.array(
z.object({
delta: z
.object({
role: z.string().nullish(),
content: z.string().nullish(),
})
.nullish(),
finish_reason: z.string().nullish(),
}),
)
.nullish(),
});
// Multimodal content part (OpenAI vision format). Lets a message carry text
// and/or images.
export const openWebUiContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
// Message content is either plain text or multimodal parts. Both the completion
// and the native chat/history contracts use this so an attachment-backed message
// round-trips through create/update/get without parse failures or lossy drops.
export const openWebUiMessageContentSchema = z.union([z.string(), z.array(openWebUiContentPartSchema)]);
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
// GET /api/v1/knowledge/ — knowledge bases (RAG collections owned by the user).
// Newer Open WebUI versions paginate the list as `{ items, total }`; older ones
// return a bare array. Accept both and normalise in the integration.
export const openWebUiKnowledgeSchema = z.object({
id: z.string(),
name: z.string().nullish(),
description: z.string().nullish(),
});
export type OpenWebUiKnowledge = z.infer<typeof openWebUiKnowledgeSchema>;
export const openWebUiKnowledgeListSchema = z.union([
z.array(openWebUiKnowledgeSchema),
z.object({ items: z.array(openWebUiKnowledgeSchema) }),
]);
// A file stored in Open WebUI (uploaded directly or part of a knowledge base).
// `meta.collection_name` is the knowledge base id when the file belongs to one.
export const openWebUiFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z
.object({
name: z.string().nullish(),
content_type: z.string().nullish(),
collection_name: z.string().nullish(),
})
.nullish(),
});
export type OpenWebUiFile = z.infer<typeof openWebUiFileSchema>;
export const openWebUiFileListSchema = z.union([
z.array(openWebUiFileSchema),
z.object({ items: z.array(openWebUiFileSchema) }),
]);
// POST /api/v1/files/ — upload response (we only need the new file id + name).
export const openWebUiUploadedFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z.object({ name: z.string().nullish() }).nullish(),
});
// GET /api/v1/knowledge/{id} — knowledge base with its files.
export const openWebUiKnowledgeDetailSchema = z.object({
id: z.string(),
name: z.string().nullish(),
files: z.array(openWebUiFileSchema).nullish(),
});
export type OpenWebUiKnowledgeDetail = z.infer<typeof openWebUiKnowledgeDetailSchema>;
// GET /api/v1/notes/ — user notes (markdown content under data.content.md).
export const openWebUiNoteSchema = z.object({
id: z.string(),
title: z.string().nullish(),
data: z.object({ content: z.object({ md: z.string().nullish() }).nullish() }).nullish(),
});
export const openWebUiNoteListSchema = z.union([
z.array(openWebUiNoteSchema),
z.object({ items: z.array(openWebUiNoteSchema) }),
]);
// A note flattened for the widget: id, title and its markdown body.
export interface OpenWebUiNote {
id: string;
title: string;
content: string;
}
// POST /api/v1/audio/transcriptions — speech-to-text result.
export const openWebUiTranscriptionSchema = z.object({ text: z.string() });
// A file flattened for the widget: id and a display name.
export interface OpenWebUiFileSummary {
id: string;
name: string;
}
// POST /api/v1/retrieval/process/web — ingests a URL into a temporary vector
// collection. We retrieve from that collection at send time and inject the
// results as context (Open WebUI does not fetch URLs at completion time).
export const openWebUiProcessWebResponseSchema = z.object({
collection_name: z.string(),
filename: z.string().nullish(),
file: z.object({ meta: z.object({ name: z.string().nullish() }).nullish() }).nullish(),
});
// POST /api/v1/retrieval/query/collection — top-k chunks per collection.
export const openWebUiCollectionQueryResponseSchema = z.object({
documents: z.array(z.array(z.string())).nullish(),
});
// A web page ingested for grounding: the collection to retrieve from + a label.
export interface OpenWebUiWebDocument {
collectionName: string;
title: string;
}
// GET /api/v1/chats/ — list of the user's chats (native Open WebUI shape).
export const openWebUiChatListItemSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
});
export type OpenWebUiChatListItem = z.infer<typeof openWebUiChatListItemSchema>;
// GET /api/v1/chats/{id} — full chat.
// Two message representations exist: a flat `chat.messages` list (used by chats
// we create) and a `chat.history` tree keyed by message id with a `currentId`
// leaf (used by chats created in the Open WebUI UI). The history tree is
// authoritative when present, since `chat.messages` can be stale/partial.
export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
chat: z
.object({
title: z.string().nullish(),
models: z.array(z.string()).nullish(),
messages: z
.array(
z.object({
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
history: z
.object({
currentId: z.string().nullish(),
messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
})
.nullish(),
})
.nullish(),
});
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
// Body used when creating/updating a chat through the native API. `meta.tags`
// lets Homarr tag a conversation (e.g. with the owning Homarr user id) so it can
// be found back in Open WebUI even when an integration/account is shared.
export interface OpenWebUiChatPayload {
title: string;
models: string[];
messages: OpenWebUiChatMessage[];
meta?: { tags?: string[] };
}
@@ -0,0 +1,230 @@
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OpenWebUiIntegration } from "../open-webui-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_API_KEY = "test-api-key-12345";
const TEST_URL = "https://openwebui.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const asFetchResult = (response: Response) =>
response as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const setupMockFetch = (responses: Record<string, Record<string, unknown> | unknown[]>) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const path = new URL(urlString).pathname;
if (path in responses) {
return Promise.resolve(
asFetchResult(
new Response(JSON.stringify(responses[path]), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
}
return Promise.resolve(asFetchResult(new Response(JSON.stringify({ error: "Not Found" }), { status: 404 })));
});
};
const streamFrom = (chunks: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const createIntegration = () =>
new OpenWebUiIntegration({
id: "test-open-webui",
name: "Test Open WebUI",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
describe("OpenWebUiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getModelsAsync", () => {
test("parses the OpenAI-compatible model list", async () => {
setupMockFetch({
"/api/models": { data: [{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }] },
});
const result = await createIntegration().getModelsAsync();
expect(result).toEqual([{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }]);
expect(mockFetch).toHaveBeenCalled();
});
test("sends the bearer token", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const headers = mockFetch.mock.calls.at(0)?.[1]?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBe(`Bearer ${TEST_API_KEY}`);
});
test("throws when the API returns an error", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Unauthorized", { status: 401 })));
await expect(createIntegration().getModelsAsync()).rejects.toThrow();
});
});
describe("streamChatCompletionAsync", () => {
test("emits each content delta and resolves on [DONE]", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hel" } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { content: "lo" } }] })}\n`,
"data: [DONE]\n",
]),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas).toEqual(["Hel", "lo"]);
});
test("handles a delta split across stream chunks", async () => {
const payload = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
const half = Math.floor(payload.length / 2);
mockFetch.mockResolvedValue(
asFetchResult(
new Response(streamFrom([`data: ${payload.slice(0, half)}`, `${payload.slice(half)}\n`, "data: [DONE]\n"]), {
status: 200,
}),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas.join("")).toBe("world");
});
test("throws when the completion endpoint errors", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Bad Gateway", { status: 502 })));
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
),
).rejects.toThrow();
});
test("flushes the final SSE frame when it has no trailing newline", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "done" } }] })}\n`,
// Final event: no trailing newline and ends via finish_reason (no [DONE]).
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`,
]),
{ status: 200 },
),
),
);
const deltas: string[] = [];
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
),
).resolves.toBeUndefined();
expect(deltas.join("")).toBe("done");
});
});
describe("requestAsync timeouts", () => {
test("applies a default timeout to non-streaming requests", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeGreaterThan(0);
});
test("does not time out the long-lived streaming completion", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response(streamFrom(["data: [DONE]\n"]), { status: 200 })));
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
);
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeUndefined();
});
});
describe("getChatAsync", () => {
test("parses a chat whose message content is multimodal (text + image parts)", async () => {
setupMockFetch({
"/api/v1/chats/abc": {
id: "abc",
chat: {
title: "Vision",
models: ["llava"],
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is this?" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
{ role: "assistant", content: "A cat." },
],
},
},
});
const chat = await createIntegration().getChatAsync("abc");
expect(chat.id).toBe("abc");
expect(chat.chat?.messages?.[0]?.content).toBeDefined();
});
});
});
+1
View File
@@ -26,4 +26,5 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./open-webui/open-webui-types";
export * from "./traefik/traefik-types";
+1
View File
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
+109
View File
@@ -0,0 +1,109 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
} from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const openWebUiModelsRequestHandler = createIntegrationRequestHandler<
OpenWebUiModel[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getModelsAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeRequestHandler = createIntegrationRequestHandler<
OpenWebUiKnowledge[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
{ knowledgeId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeFilesAsync(input.knowledgeId);
},
cacheTtlMs: dayjs.duration(1, "minute").asMilliseconds(),
});
export const openWebUiFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listFilesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNotesRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listNotesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNoteRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote,
"openWebUi",
{ noteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNoteAsync(input.noteId);
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiChatsRequestHandler = createIntegrationRequestHandler<
OpenWebUiChatListItem[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listChatsAsync();
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
export const openWebUiChatRequestHandler = createIntegrationRequestHandler<
OpenWebUiChat,
"openWebUi",
{ chatId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getChatAsync(input.chatId);
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
+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,18 @@ 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 }));
};
+68 -5
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",
@@ -3848,6 +3849,61 @@
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration."
},
"openWebUi": {
"name": "AI Chat",
"description": "Chat with AI models through Open WebUI, with model picking and history.",
"option": {
"systemPrompt": {
"label": "System prompt",
"description": "Optional instruction prepended to every conversation."
},
"showHistory": {
"label": "Show chat history",
"description": "Show a button to browse and reopen previous Open WebUI chats."
}
},
"selectModel": "Select a model",
"searchModel": "Search models…",
"noModels": "No models available",
"emptyState": "Start a conversation",
"messagePlaceholder": "Type a message…",
"newChat": "New chat",
"history": "History",
"back": "Back",
"send": "Send",
"stop": "Stop",
"retry": "Retry",
"visionWarning": "This model may not support images",
"attach": "Attach",
"uploadFiles": "Upload Files",
"capture": "Capture",
"attachImage": "Attach image",
"attachKnowledge": "Knowledge",
"noKnowledge": "No knowledge bases in Open WebUI",
"attachWebpage": "Webpage",
"attachFiles": "Files",
"attachNotes": "Notes",
"referenceChats": "Reference Chats",
"noFiles": "No files in Open WebUI",
"noNotes": "No notes in Open WebUI",
"noChats": "No previous chats",
"addUrl": "Add URL",
"removeAttachment": "Remove attachment",
"webError": "Could not load that web page",
"uploadError": "Could not upload the file",
"captureError": "Could not access the camera",
"chatError": "Could not load that chat",
"noteError": "Could not load that note",
"record": "Record voice message",
"stopRecording": "Stop recording",
"cancelRecording": "Cancel recording",
"micError": "Could not access the microphone",
"micPermissionError": "Microphone access was denied. Allow it in your browser to record voice messages.",
"transcribeError": "Could not transcribe the recording",
"error": {
"internalServerError": "Failed to reach Open WebUI"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -4031,9 +4087,6 @@
},
"showDockerNetwork": {
"label": "Show Docker network chart"
},
"showStorage": {
"label": "Show storage chart"
}
},
"selectSystem": "Select system",
@@ -4060,8 +4113,8 @@
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
"subtitle": "Usage by filesystem",
"series": "Root"
},
"diskIO": {
"title": "Disk I/O",
@@ -5736,6 +5789,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": {
+2 -1
View File
@@ -20,7 +20,8 @@
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
"./prefetch": "./src/prefetch.ts",
"./_open-webui/chat": "./src/open-webui/chat.tsx"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig {
iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => {
const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
};
interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={progress.value}
color={progress.color}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
@@ -195,14 +200,13 @@ const metricRenderers = [
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="disk"
icon={<HardDrive size={sz.iconSize} />}
label={t("metric.disk")}
value={formatPercent(s.disk)}
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
size={sz}
/>
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
<HardDrive size={sz.iconSize} />
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("metric.disk")}
</Text>
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,54 +1,21 @@
"use client";
import { useCallback, useMemo } from "react";
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useEfsChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
options,
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
},
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const timePeriod = options.timePeriod as BeszelTimePeriod;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
timePeriod,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
timePeriod,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
timePeriod,
);
const efsPaths = useMemo(() => {
if (!systemStats) return [];
const paths = new Set<string>();
for (const record of systemStats) {
if (record.stats.efs) {
for (const path of Object.keys(record.stats.efs)) {
paths.add(path);
}
}
}
return [...paths];
}, [systemStats]);
const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const storageSeries = useMemo(
() => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
[efsPaths],
);
const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError;
if (systemsPending) {
if (systemsPending)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (systems.length === 0) {
return (
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
);
}
if (!systemsPending && !systemExists && systems.length > 0) {
if (!systemExists) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
@@ -260,22 +99,6 @@ export default function BeszelSystemStatsWidget({
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
{systems.map((system) => (
<Menu.Item
key={s.value}
key={system.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{s.label}
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<Center h={CHART_HEIGHT}>
<Loader size="sm" />
</Center>
)}
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
)}
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
<BeszelChartPanel
title={t("chart.storage.title")}
subtitle={t("chart.storage.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: storageData,
type: "stacked",
series: storageSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
</Stack>
</ScrollArea>
</Box>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
showStorage: factory.switch({ defaultValue: true }),
}));
},
errors: {
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
render: (record) => (
<DiskUsage
system={record}
fontSize={size.fontSize}
progressSize={size.progressSize}
valueMiw={size.valueMiw}
valueGap={8}
/>
),
},
options.showGpu && {
accessor: "gpu",
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
const record = (
created: string,
du: number,
efs: BeszelSystemStatsRecord["stats"]["efs"],
): BeszelSystemStatsRecord => ({
id: created,
system: "system-1",
type: "1m",
created,
updated: created,
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 938.8,
du,
dp: 51.15,
efs,
},
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
}),
],
["sda", "sdb", "sdc"],
"Root",
"1h",
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
record("2026-07-11T13:46:00.000Z", 1, undefined),
],
["sda"],
"Root",
"1h",
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
]);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
expect(data).toHaveLength(60);
expect(data[0]).not.toHaveProperty("CPU");
expect(data[58]).not.toHaveProperty("CPU");
expect(data[59]).toMatchObject({ CPU: 18.07 });
});
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
const data = padLiveTimeGrid([
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
]);
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
+62 -14
View File
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod];
if (!days) return data;
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
if (data.length === 0) return data;
const end = dayjs(
data.reduce((latest, point) => {
const timestamp = new Date(point.rawTime as string).getTime();
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0),
).startOf("second");
const pointsBySecond = new Map(
data.map(
(point) =>
[
dayjs(point.rawTime as string)
.startOf("second")
.valueOf(),
point,
] as const,
),
);
return Array.from({ length: pointCount }, (_, index) => {
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
rawTime: timestamp.toISOString(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useEfsChartData = (
export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) =>
useMemo(() => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [systemStats, efsPaths, timePeriod]);
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
);
export const buildDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
@@ -0,0 +1,105 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
interface DiskUsageProps {
system: BeszelSystemRow;
fontSize: string;
progressSize: "xs" | "sm";
valueMiw: number;
valueGap?: number;
}
const mountLabel = (path: string) =>
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
};
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
});
test("maps progress sizes consistently", () => {
expect(getProgressTrackSize("xs")).toBe(6);
expect(getProgressTrackSize("sm")).toBe(9);
});
});
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
@@ -0,0 +1,339 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDiskChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
export interface BeszelStatsVisibility {
cpu: boolean;
memory: boolean;
disk: boolean;
diskIO: boolean;
network: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
}
interface BeszelStatsViewProps {
integrationIds: string[];
systemId: string;
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
integrationIds,
systemId,
timePeriod,
visibility,
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = isLive ? liveData : historicalData;
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
}
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
...efsPaths.map((path, index) => ({
name: path,
color: containerColors[(index + 1) % containerColors.length] as string,
})),
],
[efsPaths, rootSeriesName],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
() =>
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
[containerNames],
);
const dockerCpuData = useDockerChartData(
whenVisible(visibility.dockerCpu, containerStats),
containerNames,
"cpu",
timePeriod,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
{onSwitchToHistorical && (
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
{t("error.switchToHistorical")}
</Button>
)}
</Stack>
</Center>
);
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
);
}
return (
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{visibility.memory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.disk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.diskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{visibility.network && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
);
}
@@ -1,25 +1,15 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
import { useState } from "react";
import { SegmentedControl, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart";
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
import { makeTooltipProps } from "./tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
import { BeszelStatsView } from "./stats-view";
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" },
];
const allCharts = {
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
} as const;
interface BeszelSystemStatsModalProps {
integrationId: string;
systemId: string;
}
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
integrationIds: [innerProps.integrationId],
systemId: innerProps.systemId,
timePeriod,
includeDocker: false,
});
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = data?.systemStats;
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
return (
<Stack gap="md">
<SegmentedControl
size="xs"
value={timePeriod}
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions}
/>
<SimpleGrid cols={2} spacing="md">
{cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</SimpleGrid>
<BeszelStatsView
integrationIds={[innerProps.integrationId]}
systemId={innerProps.systemId}
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
);
}).withOptions({
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
// 120 records at ~1 per second = 2 minutes of rolling data for charts
const MAX_BUFFER = 120;
// Beszel emits roughly one record per second. Keep a sliding one-minute window
// without synthesizing points for missed updates.
const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId },
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{
enabled: enabled && systemId !== "",
onData(event) {
setError(null);
if (event.type === "system_stats") {
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}`;
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
+2 -1
View File
@@ -1,6 +1,6 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -69,6 +69,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
maxIntegrations?: number;
+2
View File
@@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
import * as openWebUi from "./open-webui";
export type {
WidgetDefinition,
@@ -129,6 +130,7 @@ export const widgetImports = {
beszelSystemStats,
traefik,
customApi,
openWebUi,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
@@ -0,0 +1,595 @@
import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Checkbox,
CloseButton,
Combobox,
Group,
Image,
Input,
InputBase,
Loader,
Paper,
Popover,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
useCombobox,
} from "@mantine/core";
import {
IconArrowLeft,
IconCamera,
IconCaretDownFilled,
IconCaretUpFilled,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDatabase,
IconFile,
IconFileText,
IconMessage,
IconPaperclip,
IconPlus,
IconRobot,
IconUser,
IconWorld,
} from "@tabler/icons-react";
import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { getIconUrl } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AttachView, ChatMessage, FileAttachment } from "./chat-types";
import type { useAttachments } from "./use-attachments";
import classes from "./chat.module.css";
// Model picker: a non-editable button target showing the selected model, with a
// search field inside the dropdown to filter the list on the frontend.
export function ModelSelect({
data,
value,
onChange,
loading,
disabled,
}: {
data: { value: string; label: string }[];
value: string | null;
onChange: (value: string) => void;
loading: boolean;
disabled: boolean;
}) {
const t = useI18n();
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
onDropdownOpen: () => combobox.focusSearchInput(),
});
const selectedLabel = data.find((item) => item.value === value)?.label;
const filtered = data.filter((item) => item.label.toLowerCase().includes(search.trim().toLowerCase()));
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(submitted) => {
onChange(submitted);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
variant="unstyled"
size="sm"
disabled={disabled}
w={200}
classNames={{ input: classes.modelSelectControl }}
rightSection={
loading ? (
<Loader size="xs" />
) : combobox.dropdownOpened ? (
<IconCaretUpFilled size={14} />
) : (
<IconCaretDownFilled size={14} />
)
}
rightSectionPointerEvents="none"
onClick={() => combobox.toggleDropdown()}
>
{selectedLabel ? (
<span className={classes.modelSelectLabel}>{selectedLabel}</span>
) : (
<Input.Placeholder>{t("widget.openWebUi.selectModel")}</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={t("widget.openWebUi.searchModel")}
/>
<Combobox.Options mah={220} style={{ overflowY: "auto" }}>
{filtered.length > 0 ? (
filtered.map((item) => (
<Combobox.Option value={item.value} key={item.value} active={item.value === value}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<span className={classes.modelOptionLabel}>{item.label}</span>
{item.value === value ? <IconCheck size={14} /> : null}
</Group>
</Combobox.Option>
))
) : (
<Combobox.Empty>{t("widget.openWebUi.noModels")}</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
export function MenuRow({
icon,
label,
hasSub,
onClick,
}: {
icon: React.ReactNode;
label: string;
hasSub?: boolean;
onClick: () => void;
}) {
return (
<Button
variant="subtle"
color="gray"
size="sm"
fullWidth
justify="space-between"
leftSection={icon}
rightSection={hasSub ? <IconChevronRight size={14} /> : <Box w={14} />}
onClick={onClick}
classNames={{ label: classes.menuRowLabel }}
>
{label}
</Button>
);
}
export function PickerList({
count,
emptyLabel,
children,
}: {
count: number;
emptyLabel: string;
children: React.ReactNode;
}) {
if (count === 0) {
return (
<Text size="xs" c="dimmed" p="xs">
{emptyLabel}
</Text>
);
}
return (
<ScrollArea.Autosize mah={220} type="auto">
<Stack gap={6} p={4}>
{children}
</Stack>
</ScrollArea.Autosize>
);
}
export function KnowledgeRow({
integrationId,
knowledgeId,
name,
selected,
onToggle,
isFileSelected,
onToggleFile,
emptyFilesLabel,
}: {
integrationId: string;
knowledgeId: string;
name: string;
selected: boolean;
onToggle: () => void;
isFileSelected: (id: string) => boolean;
onToggleFile: (file: FileAttachment) => void;
emptyFilesLabel: string;
}) {
// Start collapsed so opening the picker doesn't fire getKnowledgeFiles for
// every knowledge base at once (an N+1 burst on large accounts). Files load
// only when a specific row is expanded.
const [expanded, setExpanded] = useState(false);
const { data: files = [], isLoading } = clientApi.widget.openWebUi.getKnowledgeFiles.useQuery(
{ integrationId, knowledgeId },
{ enabled: expanded && Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
return (
<Stack gap={4}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Checkbox size="xs" label={name} checked={selected} onChange={onToggle} />
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setExpanded((value) => !value)}
aria-label={name}
>
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</ActionIcon>
</Group>
{expanded ? (
<Stack gap={4} pl="md">
{isLoading ? (
<Loader size="xs" />
) : files.length === 0 ? (
<Text size="xs" c="dimmed">
{emptyFilesLabel}
</Text>
) : (
files.map((file) => (
<Checkbox
key={file.id}
size="xs"
label={file.name}
checked={isFileSelected(file.id)}
onChange={() => onToggleFile(file)}
/>
))
)}
</Stack>
) : null}
</Stack>
);
}
export function AttachmentBadge({
color,
icon,
label,
onRemove,
removeLabel,
}: {
color: string;
icon: React.ReactNode;
label: string;
onRemove: () => void;
removeLabel: string;
}) {
return (
<Badge
variant="light"
color={color}
leftSection={icon}
rightSection={<CloseButton size="xs" onClick={onRemove} aria-label={removeLabel} />}
className={classes.attachmentBadge}
>
{label}
</Badge>
);
}
// The "attach" popover: a root menu of sources and the per-source sub-views
// (web URL input, file / note / knowledge / chat pickers) used to ground a reply.
export function AttachPopover({
attachments,
chats,
integrationId,
onUploadClick,
onCapture,
}: {
attachments: ReturnType<typeof useAttachments>;
chats: { id: string; title?: string | null }[];
integrationId: string | undefined;
onUploadClick: () => void;
onCapture: () => void;
}) {
const t = useI18n();
const attachViewTitles: Record<Exclude<AttachView, "root">, string> = {
webpage: t("widget.openWebUi.attachWebpage"),
files: t("widget.openWebUi.attachFiles"),
notes: t("widget.openWebUi.attachNotes"),
knowledge: t("widget.openWebUi.attachKnowledge"),
chats: t("widget.openWebUi.referenceChats"),
};
const attachViewTitle = attachments.attachView === "root" ? "" : attachViewTitles[attachments.attachView];
return (
<Popover
opened={attachments.attachOpened}
onChange={attachments.openAttach}
position="top-start"
withinPortal={false}
width={280}
shadow="md"
>
<Popover.Target>
<Tooltip label={t("widget.openWebUi.attach")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => attachments.openAttach(!attachments.attachOpened)}
aria-label={t("widget.openWebUi.attach")}
>
<IconPaperclip size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown p={4}>
{attachments.attachView === "root" ? (
<Stack gap={2}>
<MenuRow
icon={<IconPaperclip size={16} />}
label={t("widget.openWebUi.uploadFiles")}
onClick={onUploadClick}
/>
<MenuRow icon={<IconCamera size={16} />} label={t("widget.openWebUi.capture")} onClick={onCapture} />
<MenuRow
icon={<IconWorld size={16} />}
label={t("widget.openWebUi.attachWebpage")}
hasSub
onClick={() => attachments.setAttachView("webpage")}
/>
<MenuRow
icon={<IconFile size={16} />}
label={t("widget.openWebUi.attachFiles")}
hasSub
onClick={() => attachments.setAttachView("files")}
/>
<MenuRow
icon={<IconFileText size={16} />}
label={t("widget.openWebUi.attachNotes")}
hasSub
onClick={() => attachments.setAttachView("notes")}
/>
<MenuRow
icon={<IconDatabase size={16} />}
label={t("widget.openWebUi.attachKnowledge")}
hasSub
onClick={() => attachments.setAttachView("knowledge")}
/>
<MenuRow
icon={<IconMessage size={16} />}
label={t("widget.openWebUi.referenceChats")}
hasSub
onClick={() => attachments.setAttachView("chats")}
/>
</Stack>
) : (
<Stack gap={4}>
<Group gap={6} wrap="nowrap" px={4} pt={2}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => attachments.setAttachView("root")}
aria-label={t("widget.openWebUi.back")}
>
<IconArrowLeft size={16} />
</ActionIcon>
<Text fw={600} size="sm">
{attachViewTitle}
</Text>
</Group>
{attachments.attachView === "webpage" ? (
<Group gap={4} wrap="nowrap" p={4}>
<TextInput
size="xs"
flex={1}
placeholder="https://…"
value={attachments.webInput}
onChange={(event) => attachments.setWebInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void attachments.addWebUrl();
}
}}
/>
<ActionIcon
variant="light"
color="gray"
loading={attachments.isAddingWeb}
onClick={() => void attachments.addWebUrl()}
aria-label={t("widget.openWebUi.addUrl")}
>
<IconPlus size={16} />
</ActionIcon>
</Group>
) : null}
{attachments.attachView === "files" ? (
<PickerList emptyLabel={t("widget.openWebUi.noFiles")} count={attachments.files.length}>
{attachments.files.map((file) => (
<Checkbox
key={file.id}
size="xs"
label={file.name}
checked={attachments.fileItems.some((item) => item.id === file.id)}
onChange={() => attachments.toggleFile(file)}
/>
))}
</PickerList>
) : null}
{attachments.attachView === "notes" ? (
<PickerList emptyLabel={t("widget.openWebUi.noNotes")} count={attachments.notes.length}>
{attachments.notes.map((note) => (
<Checkbox
key={note.id}
size="xs"
label={note.title}
checked={attachments.noteItems.some((item) => item.id === note.id)}
onChange={() => void attachments.toggleNote({ id: note.id, title: note.title })}
/>
))}
</PickerList>
) : null}
{attachments.attachView === "knowledge" ? (
<PickerList emptyLabel={t("widget.openWebUi.noKnowledge")} count={attachments.knowledge.length}>
{attachments.knowledge.map((item) => (
<KnowledgeRow
key={item.id}
integrationId={integrationId ?? ""}
knowledgeId={item.id}
name={item.name ?? item.id}
selected={attachments.knowledgeIds.includes(item.id)}
onToggle={() => attachments.toggleKnowledge(item.id)}
isFileSelected={(id) => attachments.fileItems.some((file) => file.id === id)}
onToggleFile={attachments.toggleFile}
emptyFilesLabel={t("widget.openWebUi.noFiles")}
/>
))}
</PickerList>
) : null}
{attachments.attachView === "chats" ? (
<PickerList emptyLabel={t("widget.openWebUi.noChats")} count={chats.length}>
{chats.map((chat) => (
<Checkbox
key={chat.id}
size="xs"
label={chat.title ?? chat.id}
checked={attachments.chatItems.some((item) => item.id === chat.id)}
onChange={() => void attachments.toggleChatReference(chat)}
/>
))}
</PickerList>
) : null}
</Stack>
)}
</Popover.Dropdown>
</Popover>
);
}
// Live microphone waveform driven by a Web Audio analyser node.
export function RecordingWaveform({ analyser }: { analyser: AnalyserNode | null }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!analyser || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const data = new Uint8Array(analyser.frequencyBinCount);
const color = getComputedStyle(canvas).color;
let raf = 0;
const render = () => {
raf = requestAnimationFrame(render);
analyser.getByteFrequencyData(data);
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = color;
const barCount = 56;
const step = Math.max(1, Math.floor(data.length / barCount));
const slot = width / barCount;
for (let index = 0; index < barCount; index++) {
const value = (data[index * step] ?? 0) / 255;
const barHeight = Math.max(2, value * height);
ctx.fillRect(index * slot + slot * 0.3, (height - barHeight) / 2, slot * 0.4, barHeight);
}
};
render();
return () => cancelAnimationFrame(raf);
}, [analyser]);
return <canvas ref={canvasRef} width={640} height={32} aria-hidden className={classes.waveformCanvas} />;
}
function TypingDots() {
return (
<span className={classes.typing} aria-label="…">
<span className={classes.typingDot} />
<span className={classes.typingDot} />
<span className={classes.typingDot} />
</span>
);
}
export function MessageBubble({
from,
content,
images,
loading,
}: {
from: ChatMessage["role"];
content: string;
images?: string[];
loading?: boolean;
}) {
const isUser = from === "user";
// The assistant shows the Open WebUI brand logo (IconRobot is the fallback if
// the image fails to load); the user keeps a plain icon avatar.
const avatar = isUser ? (
<Avatar size={22} radius="xl" variant="filled" color="gray" className={classes.avatar}>
<IconUser size={14} />
</Avatar>
) : (
<Avatar size={22} radius="xl" src={getIconUrl("openWebUi")} alt="Open WebUI" className={classes.avatar}>
<IconRobot size={14} />
</Avatar>
);
return (
<Group gap={6} align="flex-start" wrap="nowrap" justify={isUser ? "flex-end" : "flex-start"}>
{isUser ? null : avatar}
<Paper
className={classes.bubble}
withBorder={false}
bg={isUser ? "var(--mantine-primary-color-light)" : "var(--mantine-color-default-hover)"}
p="xs"
radius="md"
>
{images && images.length > 0 ? (
<Group gap={4} mb={content ? 6 : 0}>
{images.map((src, index) => (
<Image key={index} src={src} alt="" w={120} radius="sm" fit="contain" />
))}
</Group>
) : null}
{loading && !content ? <TypingDots /> : null}
{content ? (
isUser ? (
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{content}
</Text>
) : (
<div className={classes.markdown}>
{/* skipHtml only strips raw HTML; drop markdown images too so
untrusted model output can't trigger arbitrary browser fetches
on render. */}
<ReactMarkdown skipHtml components={{ img: () => null }}>
{content}
</ReactMarkdown>
</div>
)
) : null}
</Paper>
{isUser ? avatar : null}
</Group>
);
}
@@ -0,0 +1,41 @@
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
images?: string[];
}
export type CompletionPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
export type CompletionMessage = { role: ChatMessage["role"]; content: string | CompletionPart[] };
export interface ImageAttachment {
name: string;
dataUrl: string;
}
export interface WebAttachment {
url: string;
collectionName: string;
title: string;
}
export interface FileAttachment {
id: string;
name: string;
}
export interface NoteAttachment {
id: string;
title: string;
content: string;
}
export interface ChatAttachment {
id: string;
title: string;
transcript: string;
}
// Which sub-view the attach popover is showing.
export type AttachView = "root" | "webpage" | "files" | "notes" | "knowledge" | "chats";
// Retrieval collections + verbatim context blocks injected before a completion.
export interface Grounding {
collections: string[];
contextTexts: string[];
}
@@ -0,0 +1,100 @@
import type { ChatMessage, CompletionMessage } from "./chat-types";
// Heuristic fallback for detecting vision-capable models when Open WebUI does
// not declare the capability explicitly.
export const VISION_MODEL_PATTERN =
/vision|llava|bakllava|moondream|minicpm-?v|qwen2?-?vl|pixtral|cogvlm|internvl|gemma3|smolvlm|granite[\w-]*vision/i;
export const readBlobAsDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result as string));
reader.addEventListener("error", () => reject(reader.error ?? new Error("Failed to read file")));
reader.readAsDataURL(blob);
});
export const hostnameOf = (url: string): string => {
try {
return new URL(url).hostname || url;
} catch {
return url;
}
};
export const formatSeconds = (total: number): string => {
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
};
// Convert displayed messages to the OpenAI completion format, inlining any
// attached images on the message that carries them (vision content parts).
export const toCompletionMessages = (messages: ChatMessage[]): CompletionMessage[] =>
messages.map((message) =>
message.images && message.images.length > 0
? {
role: message.role,
content: [
...(message.content ? [{ type: "text" as const, text: message.content }] : []),
...message.images.map((url) => ({ type: "image_url" as const, image_url: { url } })),
],
}
: { role: message.role, content: message.content },
);
type ContentPart = { type?: string | null; text?: string | null };
type MessageContent = string | ContentPart[] | null | undefined;
type RawMessage = { role?: string | null; content?: MessageContent };
type HistoryMessage = RawMessage & { parentId?: string | null };
interface RawChat {
messages?: RawMessage[] | null;
history?: { currentId?: string | null; messages?: Record<string, HistoryMessage> | null } | null;
}
// Open WebUI stores message content as either a plain string or multimodal
// parts. Flatten to display text (keeping text parts, dropping image nodes)
// rather than discarding the whole message when it isn't a bare string.
const flattenContent = (content: MessageContent): string | null => {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((part): part is ContentPart & { text: string } => typeof part?.text === "string")
.map((part) => part.text)
.join("");
}
return null;
};
const toChatMessage = (role: string | null | undefined, content: MessageContent): ChatMessage | null => {
if (role !== "system" && role !== "user" && role !== "assistant") return null;
const text = flattenContent(content);
return text === null ? null : { role, content: text };
};
/**
* Reconstructs the ordered conversation from an Open WebUI chat. Chats created in
* the Open WebUI UI store the active branch as a tree under `chat.history`
* (walk from `currentId` up through `parentId`); `chat.messages` can be stale, so
* the history tree takes precedence and we fall back to the flat list.
*/
export const extractChatMessages = (chat: RawChat | null | undefined): ChatMessage[] => {
const history = chat?.history;
if (history?.messages && history.currentId) {
const map = history.messages;
const ordered: ChatMessage[] = [];
const seen = new Set<string>();
let cursor: string | null | undefined = history.currentId;
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const node: HistoryMessage | undefined = map[cursor];
if (!node) break;
const message = toChatMessage(node.role, node.content);
if (message) ordered.unshift(message);
cursor = node.parentId;
}
if (ordered.length > 0) return ordered;
}
return (chat?.messages ?? [])
.map((message) => toChatMessage(message.role, message.content))
.filter((message): message is ChatMessage => message !== null);
};
@@ -0,0 +1,230 @@
.root {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.messages {
position: absolute;
inset: 0;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 3;
background-color: var(--mantine-color-body);
border-bottom: 1px solid var(--mantine-color-default-border);
}
.composer {
position: absolute;
left: 0;
right: 0;
bottom: var(--mantine-spacing-xs);
z-index: 3;
width: calc(100% - var(--mantine-spacing-md));
margin-inline: auto;
box-shadow: var(--mantine-shadow-md);
}
.modelSelectControl {
font-size: var(--mantine-font-size-xs);
cursor: pointer;
overflow: hidden;
}
/* Truncate the selected model name once it hits the control's max width. */
.modelSelectLabel {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelOptionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Markdown-rendered assistant replies: keep block spacing tight inside the bubble. */
.markdown {
font-size: var(--mantine-font-size-sm);
word-break: break-word;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown pre {
margin-block: var(--mantine-spacing-xs);
}
.markdown pre {
overflow-x: auto;
padding: var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-default);
}
.markdown code {
font-family: var(--mantine-font-family-monospace);
font-size: 0.85em;
}
.markdown :not(pre) > code {
padding: 0.1em 0.3em;
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-default);
}
.avatar {
flex-shrink: 0;
margin-top: 2px;
}
.typing {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 2px;
}
.typingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--mantine-color-dimmed);
animation: owui-typing 1.2s infinite ease-in-out both;
}
.typingDot:nth-child(2) {
animation-delay: 0.15s;
}
.typingDot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes owui-typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.typingDot {
animation: owui-typing-fade 1.2s infinite ease-in-out both;
}
@keyframes owui-typing-fade {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
}
.bubble {
max-width: 85%;
}
.menuRowLabel {
flex: 1;
text-align: left;
font-weight: 400;
}
.attachmentBadge {
max-width: 180px;
text-transform: none;
}
.captureVideo {
width: 100%;
max-height: 50vh;
border-radius: var(--mantine-radius-md);
background-color: black;
}
.recordingBar {
width: 100%;
}
.waveformWrap {
flex: 1;
min-width: 0;
height: 32px;
display: flex;
align-items: center;
}
.waveformCanvas {
width: 100%;
height: 32px;
display: block;
color: var(--mantine-primary-color-filled);
}
.recordingTime {
font-variant-numeric: tabular-nums;
min-width: 34px;
text-align: right;
}
.thumb {
position: relative;
line-height: 0;
}
.thumbRemove {
position: absolute;
top: -6px;
right: -6px;
background-color: var(--mantine-color-body);
border-radius: var(--mantine-radius-xl);
box-shadow: var(--mantine-shadow-xs);
}
.historyOverlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
flex-direction: column;
background-color: var(--mantine-color-body);
}
.historyHeader {
flex-shrink: 0;
border-bottom: 1px solid var(--mantine-color-default-border);
}
.historyList {
flex: 1;
min-height: 0;
}
+571
View File
@@ -0,0 +1,571 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Box,
Button,
CloseButton,
Group,
Image,
Loader,
Modal,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowUp,
IconCamera,
IconCheck,
IconDatabase,
IconFile,
IconFileText,
IconHistory,
IconMessage,
IconMicrophone,
IconPlayerStopFilled,
IconPlus,
IconRefresh,
IconRobot,
IconWorld,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { AttachmentBadge, AttachPopover, MessageBubble, ModelSelect, RecordingWaveform } from "./chat-components";
import classes from "./chat.module.css";
import { VISION_MODEL_PATTERN, formatSeconds, hostnameOf } from "./chat-utils";
import { useAttachments } from "./use-attachments";
import { useCameraCapture } from "./use-camera-capture";
import { useChatStream } from "./use-chat-stream";
import { useVoiceRecording } from "./use-voice-recording";
// `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);
const [model, setModel] = useState<string | null>(null);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [historyOpened, setHistoryOpened] = useState(false);
const [composerHeight, setComposerHeight] = useState(72);
const [headerHeight, setHeaderHeight] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
data: models = [],
isLoading: modelsLoading,
isError: modelsError,
} = clientApi.widget.openWebUi.getModels.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
const { data: chats = [] } = clientApi.widget.openWebUi.getChats.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
const attachments = useAttachments({
integrationId,
onError: setError,
errorMessages: {
upload: t("widget.openWebUi.uploadError"),
web: t("widget.openWebUi.webError"),
note: t("widget.openWebUi.noteError"),
chat: t("widget.openWebUi.chatError"),
},
});
const closeHistory = useCallback(() => setHistoryOpened(false), []);
const stream = useChatStream({
integrationId,
model,
setModel,
systemPrompt: options.systemPrompt,
buildGrounding: attachments.buildGrounding,
setError,
onChatLoaded: closeHistory,
newChatTitle: t("widget.openWebUi.newChat"),
});
const voice = useVoiceRecording({
integrationId,
onError: setError,
onTranscribed: (text) => setInput((previous) => (previous ? `${previous} ${text}` : text)),
micErrorMessage: t("widget.openWebUi.micError"),
micPermissionErrorMessage: t("widget.openWebUi.micPermissionError"),
transcribeErrorMessage: t("widget.openWebUi.transcribeError"),
});
const camera = useCameraCapture({
onError: setError,
onCapture: (attachment) => attachments.setImages((previous) => [...previous, attachment]),
captureErrorMessage: t("widget.openWebUi.captureError"),
});
// Keep the selection reconciled with the available models: default to the
// first one, and reset if the current id disappears (integration change or a
// model removed/renamed upstream) so sends don't use a stale id.
useEffect(() => {
if (models.length === 0) {
if (model !== null) setModel(null);
return;
}
if (model === null || !models.some((item) => item.id === model)) {
setModel(models[0]?.id ?? null);
}
}, [model, models]);
// Track the floating composer's height so messages can pad below it.
useEffect(() => {
const element = composerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => setComposerHeight(element.offsetHeight));
observer.observe(element);
setComposerHeight(element.offsetHeight);
return () => observer.disconnect();
}, []);
// Track the floating header's height so messages can pad above it.
useEffect(() => {
const element = headerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => setHeaderHeight(element.offsetHeight));
observer.observe(element);
setHeaderHeight(element.offsetHeight);
return () => observer.disconnect();
}, []);
// Auto-scroll to the bottom as messages or streamed tokens arrive.
useEffect(() => {
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;
const handleSend = () => {
const trimmed = input.trim();
if (composerEmpty || !model || !integrationId || stream.isStreaming) return;
stream.send({
role: "user",
content: trimmed,
images: attachments.images.length > 0 ? attachments.images.map((item) => item.dataUrl) : undefined,
});
setInput("");
attachments.setImages([]);
};
const handleNewChat = () => {
stream.reset();
attachments.reset();
setInput("");
setError(null);
setHistoryOpened(false);
};
// Drop the noisy ":latest" tag from the label (common for Ollama models); the
// value keeps the full id so the right model is still sent.
const modelData = useMemo(
() => models.map((m) => ({ value: m.id, label: (m.name ?? m.id).replace(/:latest$/i, "") })),
[models],
);
// Whether the selected model can actually see attached images.
const selectedModel = useMemo(() => models.find((item) => item.id === model), [models, model]);
const modelSupportsVision = useMemo(() => {
if (!selectedModel) return true;
const capability = selectedModel.info?.meta?.capabilities?.vision;
if (typeof capability === "boolean") return capability;
return VISION_MODEL_PATTERN.test(selectedModel.id) || VISION_MODEL_PATTERN.test(selectedModel.name ?? "");
}, [selectedModel]);
const showVisionWarning = attachments.images.length > 0 && !modelSupportsVision;
return (
<Box className={classes.root} style={isEditMode ? { pointerEvents: "none" } : undefined}>
<Group ref={headerRef} className={classes.header} justify="space-between" gap="xs" wrap="nowrap" px="xs" py={6}>
<ModelSelect
data={modelData}
value={model}
onChange={setModel}
loading={modelsLoading}
disabled={stream.isStreaming}
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("widget.openWebUi.newChat")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("widget.openWebUi.newChat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
{options.showHistory ? (
<Tooltip label={t("widget.openWebUi.history")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryOpened(true)}
aria-label={t("widget.openWebUi.history")}
>
<IconHistory size={18} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
<Stack gap="xs" p="xs" pt={headerHeight + 8} pb={composerHeight + 24}>
{stream.messages.length === 0 && !stream.isStreaming ? (
<Stack align="center" justify="center" gap="xs" mt="xl" c={modelsError ? "red.6" : "dimmed"}>
{modelsLoading ? (
<Loader size="sm" />
) : modelsError ? (
<>
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.error.internalServerError")}</Text>
</>
) : (
<>
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.emptyState")}</Text>
</>
)}
</Stack>
) : null}
{stream.messages.map((message, index) => (
<MessageBubble key={index} from={message.role} content={message.content} images={message.images} />
))}
{stream.isStreaming ? (
<MessageBubble from="assistant" content={stream.streamingText} loading={!stream.streamingText} />
) : null}
{error ? (
<Group gap="xs" justify="center" wrap="nowrap">
<Text size="xs" c="red.6">
{error}
</Text>
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<IconRefresh size={14} />}
onClick={stream.retry}
>
{t("widget.openWebUi.retry")}
</Button>
</Group>
) : null}
</Stack>
</ScrollArea>
<Paper ref={composerRef} className={classes.composer} withBorder radius="md" p="xs">
{attachments.hasAttachments ? (
<Group gap={6} mb={6}>
{attachments.images.map((attachment, index) => (
<Box key={`${attachment.name}-${index}`} className={classes.thumb}>
<Image src={attachment.dataUrl} alt={attachment.name} w={48} h={48} radius="sm" fit="cover" />
<CloseButton
size="xs"
className={classes.thumbRemove}
onClick={() => attachments.setImages((previous) => previous.filter((_, i) => i !== index))}
aria-label={t("widget.openWebUi.removeAttachment")}
/>
</Box>
))}
{attachments.knowledgeIds.map((id) => (
<AttachmentBadge
key={id}
color="grape"
icon={<IconDatabase size={12} />}
label={attachments.knowledge.find((item) => item.id === id)?.name ?? id}
onRemove={() => attachments.toggleKnowledge(id)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.webItems.map((item) => (
<AttachmentBadge
key={item.collectionName}
color="blue"
icon={<IconWorld size={12} />}
label={hostnameOf(item.url)}
onRemove={() =>
attachments.setWebItems((previous) =>
previous.filter((value) => value.collectionName !== item.collectionName),
)
}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.fileItems.map((item) => (
<AttachmentBadge
key={item.id}
color="teal"
icon={<IconFile size={12} />}
label={item.name}
onRemove={() => attachments.toggleFile(item)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.noteItems.map((item) => (
<AttachmentBadge
key={item.id}
color="yellow"
icon={<IconFileText size={12} />}
label={item.title}
onRemove={() => void attachments.toggleNote(item)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.chatItems.map((item) => (
<AttachmentBadge
key={item.id}
color="indigo"
icon={<IconMessage size={12} />}
label={item.title}
onRemove={() =>
attachments.setChatItems((previous) => previous.filter((value) => value.id !== item.id))
}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
</Group>
) : null}
{showVisionWarning ? (
<Group gap={4} mb={6} wrap="nowrap">
<IconAlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow.7">
{t("widget.openWebUi.visionWarning")}
</Text>
</Group>
) : null}
{voice.isRecording ? (
<Group gap="xs" wrap="nowrap" className={classes.recordingBar} mt={4}>
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
onClick={voice.cancelRecording}
aria-label={t("widget.openWebUi.cancelRecording")}
>
<IconX size={18} />
</ActionIcon>
<Box className={classes.waveformWrap}>
<RecordingWaveform analyser={voice.analyser} />
</Box>
<Text size="xs" c="dimmed" className={classes.recordingTime}>
{formatSeconds(voice.recordingSeconds)}
</Text>
<ActionIcon
variant="filled"
radius="xl"
loading={voice.isTranscribing}
onClick={voice.confirmRecording}
aria-label={t("widget.openWebUi.stopRecording")}
>
<IconCheck size={18} />
</ActionIcon>
</Group>
) : (
<>
<Textarea
variant="unstyled"
size="sm"
autosize
minRows={1}
maxRows={5}
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
placeholder={t("widget.openWebUi.messagePlaceholder")}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}}
/>
<Group justify="space-between" gap="xs" wrap="nowrap" mt={4}>
<Group gap={4} wrap="nowrap">
<AttachPopover
attachments={attachments}
chats={chats}
integrationId={integrationId}
onUploadClick={() => fileInputRef.current?.click()}
onCapture={() => {
attachments.openAttach(false);
void camera.openCapture();
}}
/>
</Group>
<Group gap="xs" wrap="nowrap">
{!stream.isStreaming && composerEmpty ? (
<Tooltip label={t("widget.openWebUi.record")} withinPortal={false}>
<ActionIcon
radius="xl"
variant="subtle"
color="gray"
loading={voice.isTranscribing}
onClick={() => {
setError(null);
void voice.startRecording();
}}
aria-label={t("widget.openWebUi.record")}
>
<IconMicrophone size={18} />
</ActionIcon>
</Tooltip>
) : null}
{stream.isStreaming ? (
<Tooltip label={t("widget.openWebUi.stop")} withinPortal={false}>
<ActionIcon
radius="xl"
color="red"
variant="filled"
onClick={stream.stop}
aria-label={t("widget.openWebUi.stop")}
>
<IconPlayerStopFilled size={16} />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label={t("widget.openWebUi.send")} withinPortal={false}>
<ActionIcon
radius="xl"
variant="filled"
onClick={handleSend}
disabled={composerEmpty || !model}
aria-label={t("widget.openWebUi.send")}
>
<IconArrowUp size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
</>
)}
<input
ref={fileInputRef}
type="file"
multiple
hidden
aria-label={t("widget.openWebUi.uploadFiles")}
onChange={(event) => {
void attachments.handleFilesSelected(event.currentTarget.files);
event.currentTarget.value = "";
}}
/>
</Paper>
<Modal opened={camera.captureOpened} onClose={camera.closeCapture} title={t("widget.openWebUi.capture")} centered>
<Stack gap="sm">
<video
ref={camera.setVideoNode}
autoPlay
playsInline
muted
aria-label={t("widget.openWebUi.capture")}
className={classes.captureVideo}
>
<track kind="captions" />
</video>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={camera.closeCapture}>
{t("widget.openWebUi.back")}
</Button>
<Button size="xs" leftSection={<IconCamera size={16} />} onClick={camera.takePhoto}>
{t("widget.openWebUi.capture")}
</Button>
</Group>
</Stack>
</Modal>
{options.showHistory && historyOpened ? (
<Paper className={classes.historyOverlay} radius={0}>
<Group gap="xs" p="xs" wrap="nowrap" className={classes.historyHeader}>
<Tooltip label={t("widget.openWebUi.back")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryOpened(false)}
aria-label={t("widget.openWebUi.back")}
>
<IconArrowLeft size={18} />
</ActionIcon>
</Tooltip>
<Text fw={600} size="sm">
{t("widget.openWebUi.history")}
</Text>
</Group>
<ScrollArea className={classes.historyList} type="auto">
<Stack gap={2} p={4}>
{chats.length === 0 ? (
<Text size="sm" c="dimmed" p="xs">
{t("widget.openWebUi.emptyState")}
</Text>
) : (
chats.map((chat) => (
<NavLink
key={chat.id}
active={chat.id === stream.activeChatId}
label={chat.title ?? chat.id}
onClick={() => {
// Drop the draft's grounding so a loaded conversation
// isn't answered against the previous draft's attachments.
attachments.reset();
setError(null);
stream.setSelectedChatId(chat.id);
}}
/>
))
)}
</Stack>
</ScrollArea>
</Paper>
) : null}
</Box>
);
}
@@ -0,0 +1,13 @@
"use client";
import dynamic from "next/dynamic";
import type { WidgetComponentProps } from "../definition";
const Chat = dynamic(() => import("./chat").then((module) => module.Chat), {
ssr: false,
});
export default function OpenWebUiWidget(props: WidgetComponentProps<"openWebUi">) {
return <Chat {...props} />;
}
+31
View File
@@ -0,0 +1,31 @@
import { IconRobot, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const widgetKind = "openWebUi";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconRobot,
createOptions() {
return optionsBuilder.from((factory) => ({
systemPrompt: factory.text({
defaultValue: "",
withDescription: true,
}),
showHistory: factory.switch({
defaultValue: true,
withDescription: true,
}),
}));
},
supportedIntegrations: ["openWebUi"],
integrationsRequired: true,
maxIntegrations: 1,
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.openWebUi.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,239 @@
import { useState } from "react";
import { clientApi } from "@homarr/api/client";
import type {
AttachView,
ChatAttachment,
FileAttachment,
Grounding,
ImageAttachment,
NoteAttachment,
WebAttachment,
} from "./chat-types";
import { extractChatMessages, readBlobAsDataUrl } from "./chat-utils";
interface UseAttachmentsOptions {
integrationId: string | undefined;
onError: (message: string) => void;
errorMessages: { upload: string; web: string; note: string; chat: string };
}
/**
* Owns every "ground the conversation" attachment source: inline vision images,
* uploaded files, ingested web pages, knowledge bases, notes and referenced
* chats — including the pickers' data, the toggle handlers, and assembling the
* retrieval collections / verbatim context blocks sent with a completion.
*/
export function useAttachments({ integrationId, onError, errorMessages }: UseAttachmentsOptions) {
// Images are inline per-message (vision); the rest are sticky context kept for
// the conversation until removed.
const [images, setImages] = useState<ImageAttachment[]>([]);
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([]);
const [webItems, setWebItems] = useState<WebAttachment[]>([]);
const [fileItems, setFileItems] = useState<FileAttachment[]>([]);
const [noteItems, setNoteItems] = useState<NoteAttachment[]>([]);
const [chatItems, setChatItems] = useState<ChatAttachment[]>([]);
const [webInput, setWebInput] = useState("");
const [attachOpened, setAttachOpened] = useState(false);
const [attachView, setAttachView] = useState<AttachView>("root");
const utils = clientApi.useUtils();
const processWeb = clientApi.widget.openWebUi.processWeb.useMutation();
const uploadFile = clientApi.widget.openWebUi.uploadFile.useMutation();
const pickerQueryOptions = {
enabled: Boolean(integrationId) && attachOpened,
refetchOnWindowFocus: false,
retry: false,
} as const;
const { data: knowledge = [] } = clientApi.widget.openWebUi.getKnowledge.useQuery(
{ integrationId: integrationId ?? "" },
pickerQueryOptions,
);
const { data: files = [] } = clientApi.widget.openWebUi.getFiles.useQuery(
{ integrationId: integrationId ?? "" },
pickerQueryOptions,
);
const { data: notes = [] } = clientApi.widget.openWebUi.getNotes.useQuery(
{ integrationId: integrationId ?? "" },
pickerQueryOptions,
);
// Reset the popover to the root view whenever it closes.
const openAttach = (open: boolean) => {
setAttachOpened(open);
if (!open) setAttachView("root");
};
// Upload Files: images become inline vision attachments; everything else is
// uploaded to Open WebUI and grounded via retrieval.
const handleFilesSelected = async (fileList: FileList | null) => {
if (!fileList || !integrationId) return;
for (const file of Array.from(fileList)) {
try {
const dataUrl = await readBlobAsDataUrl(file);
if (file.type.startsWith("image/")) {
setImages((previous) => [...previous, { name: file.name, dataUrl }]);
} else {
const contentBase64 = dataUrl.split(",")[1] ?? "";
const uploaded = await uploadFile.mutateAsync({
integrationId,
filename: file.name,
contentBase64,
contentType: file.type || "application/octet-stream",
});
setFileItems((previous) =>
previous.some((item) => item.id === uploaded.id) ? previous : [...previous, uploaded],
);
}
} catch {
onError(errorMessages.upload);
}
}
};
const addWebUrl = async () => {
if (!integrationId) return;
const raw = webInput.trim();
if (!raw) return;
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const document = await processWeb.mutateAsync({ integrationId, url });
// Only clear the field once ingestion succeeds so a transient error
// doesn't force the user to retype the URL.
setWebInput("");
setWebItems((previous) =>
previous.some((item) => item.collectionName === document.collectionName)
? previous
: [...previous, { url, collectionName: document.collectionName, title: document.title }],
);
openAttach(false);
} catch {
onError(errorMessages.web);
}
};
const toggleFile = (file: FileAttachment) =>
setFileItems((previous) =>
previous.some((item) => item.id === file.id)
? previous.filter((item) => item.id !== file.id)
: [...previous, file],
);
// The list endpoint truncates note bodies, so fetch the full note on attach.
const toggleNote = async (note: { id: string; title: string }) => {
if (!integrationId) return;
if (noteItems.some((item) => item.id === note.id)) {
setNoteItems((previous) => previous.filter((item) => item.id !== note.id));
return;
}
try {
const full = await utils.widget.openWebUi.getNote.fetch({ integrationId, noteId: note.id });
// A double-click can start two fetches before state updates; dedupe on the
// resolved id so the note isn't attached (and grounded) twice.
setNoteItems((previous) =>
previous.some((item) => item.id === full.id)
? previous
: [...previous, { id: full.id, title: full.title, content: full.content }],
);
} catch {
onError(errorMessages.note);
}
};
const toggleKnowledge = (id: string) =>
setKnowledgeIds((previous) =>
previous.includes(id) ? previous.filter((value) => value !== id) : [...previous, id],
);
const toggleChatReference = async (chat: { id: string; title?: string | null }) => {
if (!integrationId) return;
if (chatItems.some((item) => item.id === chat.id)) {
setChatItems((previous) => previous.filter((item) => item.id !== chat.id));
return;
}
try {
const full = await utils.widget.openWebUi.getChat.fetch({ integrationId, chatId: chat.id });
const transcript = extractChatMessages(full.chat)
.map((message) => `${message.role}: ${message.content}`)
.join("\n");
// Dedupe on the resolved id so a double-click can't attach the same chat
// (and its transcript) twice.
setChatItems((previous) =>
previous.some((item) => item.id === chat.id)
? previous
: [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }],
);
} catch {
onError(errorMessages.chat);
}
};
const buildGrounding = (): Grounding => ({
// Retrieval collections: knowledge bases (id), web pages and files.
collections: [
...knowledgeIds,
...webItems.map((item) => item.collectionName),
...fileItems.map((item) => `file-${item.id}`),
],
// Verbatim context: notes and referenced chats.
contextTexts: [
...noteItems.map((note) => `Note "${note.title}":\n${note.content}`),
...chatItems.map((chat) => `Previous chat "${chat.title}":\n${chat.transcript}`),
],
});
const reset = () => {
setImages([]);
setKnowledgeIds([]);
setWebItems([]);
setFileItems([]);
setNoteItems([]);
setChatItems([]);
};
const hasAttachments =
images.length > 0 ||
knowledgeIds.length > 0 ||
webItems.length > 0 ||
fileItems.length > 0 ||
noteItems.length > 0 ||
chatItems.length > 0;
return {
// state
images,
setImages,
knowledgeIds,
webItems,
setWebItems,
fileItems,
noteItems,
chatItems,
setChatItems,
webInput,
setWebInput,
attachOpened,
attachView,
setAttachView,
hasAttachments,
// picker data
knowledge,
files,
notes,
// status
isUploading: processWeb.isPending || uploadFile.isPending,
isAddingWeb: processWeb.isPending,
// handlers
openAttach,
handleFilesSelected,
addWebUrl,
toggleFile,
toggleNote,
toggleKnowledge,
toggleChatReference,
buildGrounding,
reset,
};
}
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ImageAttachment } from "./chat-types";
interface UseCameraCaptureOptions {
onError: (message: string) => void;
onCapture: (attachment: ImageAttachment) => void;
captureErrorMessage: string;
}
/**
* Owns the webcam capture lifecycle: acquiring the stream, attaching it to the
* <video> the moment it mounts, taking a still frame, and tearing the stream
* down (including on unmount).
*/
export function useCameraCapture({ onError, onCapture, captureErrorMessage }: UseCameraCaptureOptions) {
const [captureOpened, setCaptureOpened] = useState(false);
const captureStreamRef = useRef<MediaStream | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const stopCaptureStream = () => {
captureStreamRef.current?.getTracks().forEach((track) => track.stop());
captureStreamRef.current = null;
};
const openCapture = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
captureStreamRef.current = stream;
setCaptureOpened(true);
} catch {
onError(captureErrorMessage);
}
};
const closeCapture = () => {
stopCaptureStream();
setCaptureOpened(false);
};
// Attach the live stream the moment the <video> mounts (avoids a ref race
// where an effect runs before the modal's video element exists).
const setVideoNode = useCallback((node: HTMLVideoElement | null) => {
videoRef.current = node;
if (node && captureStreamRef.current) {
node.srcObject = captureStreamRef.current;
void node.play().catch(() => undefined);
}
}, []);
const takePhoto = () => {
const video = videoRef.current;
if (!video) return;
// Bail if the stream hasn't delivered a frame yet, otherwise we'd capture a
// 0x0 (blank) canvas and attach an empty image.
if (video.readyState < 2 || video.videoWidth === 0 || video.videoHeight === 0) return;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
onCapture({ name: `capture-${Date.now()}.png`, dataUrl: canvas.toDataURL("image/png") });
closeCapture();
};
// Stop the camera stream on unmount.
useEffect(() => () => stopCaptureStream(), []);
return { captureOpened, openCapture, closeCapture, setVideoNode, takePhoto };
}
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type { ChatMessage, CompletionMessage, Grounding } from "./chat-types";
import { extractChatMessages, toCompletionMessages } from "./chat-utils";
interface UseChatStreamOptions {
integrationId: string | undefined;
model: string | null;
setModel: (model: string) => void;
systemPrompt: string;
buildGrounding: () => Grounding;
setError: (message: string | null) => void;
onChatLoaded: () => void;
newChatTitle: string;
}
/**
* Owns the conversation itself: the streamed completion subscription, the
* in-memory message list, persistence to Open WebUI's native history, and the
* send / retry / stop / new-chat transitions.
*/
export function useChatStream({
integrationId,
model,
setModel,
systemPrompt,
buildGrounding,
setError,
onChatLoaded,
newChatTitle,
}: UseChatStreamOptions) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [pendingMessages, setPendingMessages] = useState<CompletionMessage[]>([]);
const [pendingCollections, setPendingCollections] = useState<string[]>([]);
const [pendingContextTexts, setPendingContextTexts] = useState<string[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const streamingTextRef = useRef("");
// Read inside the serialized persist loop so a burst of turns reuses the chat
// created by the first write instead of racing on the not-yet-committed state.
const activeChatIdRef = useRef<string | null>(null);
// Bumped whenever the conversation is replaced (history load / new chat) so a
// detached persist that started against an old conversation can be discarded
// instead of rebinding the cleared chat to a stale Open WebUI chat id.
const sessionRef = useRef(0);
const persistInFlightRef = useRef(false);
const pendingPersistRef = useRef<ChatMessage[] | null>(null);
const utils = clientApi.useUtils();
const createChat = clientApi.widget.openWebUi.createChat.useMutation();
const updateChat = clientApi.widget.openWebUi.updateChat.useMutation();
const { data: loadedChat } = clientApi.widget.openWebUi.getChat.useQuery(
{ integrationId: integrationId ?? "", chatId: selectedChatId ?? "" },
{ enabled: Boolean(integrationId) && Boolean(selectedChatId) },
);
// Hydrate the conversation when a history chat finishes loading.
useEffect(() => {
if (!loadedChat) return;
sessionRef.current += 1;
const loadedMessages = extractChatMessages(loadedChat.chat);
setMessages(loadedMessages);
messagesRef.current = loadedMessages;
setActiveChatId(loadedChat.id);
activeChatIdRef.current = loadedChat.id;
if (loadedChat.chat?.models?.[0]) setModel(loadedChat.chat.models[0]);
onChatLoaded();
}, [loadedChat, setModel, onChatLoaded]);
const runPersist = async (finalMessages: ChatMessage[], session: number) => {
if (!integrationId || !model) return;
// Native history stores plain text; image data URLs are dropped on persist.
// Derive the title from the first user message that actually has text so an
// image-only opening turn still gets the fallback title instead of a blank.
const firstUserText = finalMessages
.find((message) => message.role === "user" && message.content.trim() !== "")
?.content.trim();
const title = firstUserText ? firstUserText.slice(0, 80) : newChatTitle;
const payload = {
title,
models: [model],
messages: finalMessages.map(({ role, content }) => ({ role, content })),
};
try {
const chatId = activeChatIdRef.current;
if (chatId) {
await updateChat.mutateAsync({ integrationId, chatId, chat: payload });
} else {
const chat = await createChat.mutateAsync({ integrationId, ...payload });
// The conversation was cleared/replaced while this create was in flight;
// don't rebind the now-stale chat id onto the current conversation.
if (session !== sessionRef.current) return;
activeChatIdRef.current = chat.id;
setActiveChatId(chat.id);
}
if (session !== sessionRef.current) return;
await utils.widget.openWebUi.getChats.invalidate({ integrationId });
} catch {
// Persistence is best-effort; the conversation remains usable in-memory.
}
};
// Open WebUI's chat endpoint replaces the whole chat object, so each turn
// re-sends the full transcript. Serialize the writes and collapse a burst into
// a single trailing save of the latest messages so turns don't stack
// concurrent POSTs (or create duplicate chats on the first save).
const persistChat = async (finalMessages: ChatMessage[]) => {
const session = sessionRef.current;
if (persistInFlightRef.current) {
pendingPersistRef.current = finalMessages;
return;
}
persistInFlightRef.current = true;
try {
let next: ChatMessage[] | null = finalMessages;
while (next) {
pendingPersistRef.current = null;
await runPersist(next, session);
next = pendingPersistRef.current;
}
} finally {
persistInFlightRef.current = false;
}
};
const clearPending = () => {
setPendingMessages([]);
setPendingCollections([]);
setPendingContextTexts([]);
};
const finalizeAssistantMessage = (skipPersist = false) => {
const finalText = streamingTextRef.current;
if (finalText.length > 0) {
const finalMessages = [...messagesRef.current, { role: "assistant" as const, content: finalText }];
setMessages(finalMessages);
messagesRef.current = finalMessages;
// A stopped or failed reply is kept on screen but not written to history as
// a finished turn; a later real turn will persist the whole conversation.
if (!skipPersist) void persistChat(finalMessages);
}
streamingTextRef.current = "";
setStreamingText("");
setIsStreaming(false);
clearPending();
};
clientApi.widget.openWebUi.sendMessage.useSubscription(
{
integrationId: integrationId ?? "",
model: model ?? "",
messages: pendingMessages,
collections: pendingCollections.length > 0 ? pendingCollections : undefined,
contextTexts: pendingContextTexts.length > 0 ? pendingContextTexts : undefined,
},
{
enabled: isStreaming && pendingMessages.length > 0,
onData: (event) => {
if (event.type === "delta") {
streamingTextRef.current += event.content;
setStreamingText(streamingTextRef.current);
} else if (event.type === "done") {
finalizeAssistantMessage();
} else if (event.type === "error") {
setError(event.message);
// Don't persist a partial reply from a failed turn as finished history.
finalizeAssistantMessage(true);
}
},
onError: (err) => {
setError(err.message);
// Don't persist a partial reply from a failed turn as finished history.
finalizeAssistantMessage(true);
},
},
);
// Kick off a completion for the given conversation (ending with a user message).
// Shared by sending a new message and retrying after a failure.
const startCompletion = (baseMessages: ChatMessage[]) => {
if (!model || !integrationId) return;
setError(null);
const trimmedSystemPrompt = systemPrompt.trim();
const payloadMessages: ChatMessage[] = trimmedSystemPrompt
? [{ role: "system", content: trimmedSystemPrompt }, ...baseMessages]
: baseMessages;
const grounding = buildGrounding();
streamingTextRef.current = "";
setStreamingText("");
setPendingMessages(toCompletionMessages(payloadMessages));
setPendingCollections(grounding.collections);
setPendingContextTexts(grounding.contextTexts);
setIsStreaming(true);
};
const send = (userMessage: ChatMessage) => {
const nextMessages = [...messagesRef.current, userMessage];
setMessages(nextMessages);
messagesRef.current = nextMessages;
startCompletion(nextMessages);
};
// Retry the last turn after a failure: drop any partial assistant reply and
// re-run the completion from the most recent user message.
const retry = () => {
if (isStreaming) return;
const lastUserIndex = messagesRef.current.findLastIndex((message) => message.role === "user");
if (lastUserIndex === -1) return;
const truncated = messagesRef.current.slice(0, lastUserIndex + 1);
setMessages(truncated);
messagesRef.current = truncated;
startCompletion(truncated);
};
const reset = () => {
sessionRef.current += 1;
setMessages([]);
messagesRef.current = [];
setActiveChatId(null);
activeChatIdRef.current = null;
setSelectedChatId(null);
streamingTextRef.current = "";
setStreamingText("");
setIsStreaming(false);
clearPending();
};
return {
messages,
streamingText,
isStreaming,
activeChatId,
selectedChatId,
setSelectedChatId,
send,
retry,
stop: () => finalizeAssistantMessage(true),
reset,
};
}
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { readBlobAsDataUrl } from "./chat-utils";
interface UseVoiceRecordingOptions {
integrationId: string | undefined;
onError: (message: string) => void;
onTranscribed: (text: string) => void;
micErrorMessage: string;
micPermissionErrorMessage: string;
transcribeErrorMessage: string;
}
/**
* Owns the microphone recording lifecycle: capturing audio, driving the live
* waveform analyser, and transcribing the result to text. Cancel discards the
* recording; confirm transcribes it. Streams/timers are torn down on unmount.
*/
export function useVoiceRecording({
integrationId,
onError,
onTranscribed,
micErrorMessage,
micPermissionErrorMessage,
transcribeErrorMessage,
}: UseVoiceRecordingOptions) {
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const audioStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const recordingTimerRef = useRef<number | null>(null);
const recordingCancelledRef = useRef(false);
const transcribe = clientApi.widget.openWebUi.transcribe.useMutation();
// Stop the mic stream, audio context and timer. No state updates, so it is
// safe to call during unmount as well as from teardownRecording.
const releaseAudioResources = () => {
if (recordingTimerRef.current !== null) {
window.clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
audioStreamRef.current = null;
void audioContextRef.current?.close().catch(() => undefined);
audioContextRef.current = null;
};
const teardownRecording = () => {
releaseAudioResources();
setAnalyser(null);
setIsRecording(false);
};
const finishRecording = async () => {
const cancelled = recordingCancelledRef.current;
const chunks = audioChunksRef.current;
audioChunksRef.current = [];
teardownRecording();
if (cancelled || chunks.length === 0 || !integrationId) return;
const blob = new Blob(chunks, { type: chunks[0]?.type ?? "audio/webm" });
try {
const dataUrl = await readBlobAsDataUrl(blob);
const result = await transcribe.mutateAsync({
integrationId,
filename: "recording.webm",
contentBase64: dataUrl.split(",")[1] ?? "",
contentType: blob.type || "audio/webm",
});
const text = result.text.trim();
if (text) onTranscribed(text);
} catch {
onError(transcribeErrorMessage);
}
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioStreamRef.current = stream;
// Live audio analyser drives the waveform animation.
const audioContext = new AudioContext();
const sourceNode = audioContext.createMediaStreamSource(stream);
const analyserNode = audioContext.createAnalyser();
analyserNode.fftSize = 256;
sourceNode.connect(analyserNode);
audioContextRef.current = audioContext;
setAnalyser(analyserNode);
const recorder = new MediaRecorder(stream);
audioChunksRef.current = [];
recordingCancelledRef.current = false;
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) audioChunksRef.current.push(event.data);
});
recorder.addEventListener("stop", () => void finishRecording());
mediaRecorderRef.current = recorder;
recorder.start();
setRecordingSeconds(0);
recordingTimerRef.current = window.setInterval(() => setRecordingSeconds((value) => value + 1), 1000);
setIsRecording(true);
} catch (error) {
// getUserMedia may have succeeded before AudioContext/MediaRecorder setup
// threw; release the mic stream and context so it isn't left running.
releaseAudioResources();
mediaRecorderRef.current = null;
setAnalyser(null);
setIsRecording(false);
// Distinguish a denied/dismissed permission prompt from other mic failures
// so the user gets an actionable message.
const denied =
error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError");
onError(denied ? micPermissionErrorMessage : micErrorMessage);
}
};
const cancelRecording = () => {
recordingCancelledRef.current = true;
mediaRecorderRef.current?.stop();
};
const confirmRecording = () => {
recordingCancelledRef.current = false;
mediaRecorderRef.current?.stop();
};
// Tear down the microphone stream and timer on unmount. Mark the session
// cancelled first so a recorder "stop" fired while stopping the tracks doesn't
// upload/transcribe partial audio after the component is gone.
useEffect(
() => () => {
recordingCancelledRef.current = true;
releaseAudioResources();
},
[],
);
return {
isRecording,
recordingSeconds,
analyser,
isTranscribing: transcribe.isPending,
startRecording,
cancelRecording,
confirmRecording,
};
}