diff --git a/apps/docs/docs/integrations/open-webui/index.mdx b/apps/docs/docs/integrations/open-webui/index.mdx
new file mode 100644
index 000000000..1594a3a6d
--- /dev/null
+++ b/apps/docs/docs/integrations/open-webui/index.mdx
@@ -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";
+
+
+
+[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
+
+
+
+### Adding the integration
+
+
+
+:::info URL configuration
+Use the root URL of your Open WebUI instance, for example `http://localhost:3000`. Homarr calls the Open WebUI API under `/api` automatically.
+:::
+
+### Secrets
+
+
diff --git a/apps/docs/docs/integrations/open-webui/index.ts b/apps/docs/docs/integrations/open-webui/index.ts
new file mode 100644
index 000000000..47162875c
--- /dev/null
+++ b/apps/docs/docs/integrations/open-webui/index.ts
@@ -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",
+};
diff --git a/apps/docs/docs/widgets/open-webui/index.mdx b/apps/docs/docs/widgets/open-webui/index.mdx
new file mode 100644
index 000000000..f6cede841
--- /dev/null
+++ b/apps/docs/docs/widgets/open-webui/index.mdx
@@ -0,0 +1,47 @@
+---
+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";
+
+
+
+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.
+
+### Supported Integrations
+
+
+
+### Adding the widget
+
+
+
+### 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. |
+
+### 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.
diff --git a/apps/docs/docs/widgets/open-webui/index.ts b/apps/docs/docs/widgets/open-webui/index.ts
new file mode 100644
index 000000000..3ea2dfc1b
--- /dev/null
+++ b/apps/docs/docs/widgets/open-webui/index.ts
@@ -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",
+ },
+ ],
+ },
+};
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index 80776db45..6fea031fa 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
+ openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
});
diff --git a/packages/api/src/router/widgets/open-webui.ts b/packages/api/src/router/widgets/open-webui.ts
new file mode 100644
index 000000000..36a79b376
--- /dev/null
+++ b/packages/api/src/router/widgets/open-webui.ts
@@ -0,0 +1,117 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { createIntegrationAsync } from "@homarr/integrations";
+import {
+ openWebUiChatRequestHandler,
+ openWebUiChatsRequestHandler,
+ openWebUiModelsRequestHandler,
+} from "@homarr/request-handler/open-webui";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
+
+const logger = createLogger({ module: "openWebUiRouter" });
+
+const messageSchema = z.object({
+ role: z.enum(["system", "user", "assistant"]),
+ content: z.string(),
+});
+
+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 };
+
+export const openWebUiRouter = createTRPCRouter({
+ getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
+ const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
+ const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return data;
+ }),
+
+ getChats: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
+ const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
+ const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return data;
+ }),
+
+ getChat: publicProcedure
+ .input(z.object({ chatId: z.string() }))
+ .concat(createOneIntegrationMiddleware("query", "openWebUi"))
+ .query(async ({ ctx, input }) => {
+ const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
+ const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return data;
+ }),
+
+ createChat: protectedProcedure
+ .input(chatPayloadSchema)
+ .concat(createOneIntegrationMiddleware("interact", "openWebUi"))
+ .mutation(async ({ ctx, input }) => {
+ const client = await createIntegrationAsync(ctx.integration);
+ const chat = await client.createChatAsync({
+ title: input.title,
+ models: input.models,
+ messages: input.messages,
+ });
+ await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync();
+ return chat;
+ }),
+
+ updateChat: protectedProcedure
+ .input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
+ .concat(createOneIntegrationMiddleware("interact", "openWebUi"))
+ .mutation(async ({ ctx, input }) => {
+ const client = await createIntegrationAsync(ctx.integration);
+ const chat = await client.updateChatAsync(input.chatId, input.chat);
+ await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync();
+ await openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId }).invalidateAsync();
+ return chat;
+ }),
+
+ // Streaming chat completion. Delivered over the WebSocket subscription link so
+ // tokens can be emitted as they arrive. Uses "query" access (same level as
+ // viewing the widget) to match the other integration subscriptions in the app.
+ sendMessage: publicProcedure
+ .input(z.object({ model: z.string(), messages: z.array(messageSchema) }))
+ .concat(createOneIntegrationMiddleware("query", "openWebUi"))
+ .subscription(({ ctx, input }) => {
+ return observable((emit) => {
+ const controller = new AbortController();
+
+ void (async () => {
+ try {
+ const client = await createIntegrationAsync(ctx.integration);
+ await client.streamChatCompletionAsync(
+ { model: input.model, messages: input.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();
+ };
+ });
+ }),
+});
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 4dbb8757a..ae39b90c1 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -195,6 +195,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/ntfy"
| "/docs/integrations/nzbget"
| "/docs/integrations/open-media-vault"
+ | "/docs/integrations/open-webui"
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/paperless-ngx"
diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts
index 90c3cae8b..d081effe1 100644
--- a/packages/definitions/src/docs/integration-doc-slugs.ts
+++ b/packages/definitions/src/docs/integration-doc-slugs.ts
@@ -59,5 +59,6 @@ export const integrationDocSlugs: Record = {
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
+ openWebUi: "open-webui",
mock: null,
};
diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts
index 2e34a4843..347e1b90a 100644
--- a/packages/definitions/src/docs/widget-doc-slugs.ts
+++ b/packages/definitions/src/docs/widget-doc-slugs.ts
@@ -51,4 +51,5 @@ export const widgetDocSlugs: Record = {
beszelAlerts: "beszel-alerts",
beszelSystemStats: "beszel-system-stats",
customApi: "custom-api",
+ openWebUi: "open-webui",
};
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index abb4ffdfd..55646c919 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -504,6 +504,15 @@ export const integrationDefs = {
defaultUrl: "http://localhost:8001",
documentationUrl: null,
},
+ 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",
@@ -617,6 +626,7 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
+ "ai",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts
index a560f2979..9d85c250b 100644
--- a/packages/definitions/src/widget-integration-map.ts
+++ b/packages/definitions/src/widget-integration-map.ts
@@ -40,6 +40,7 @@ export const widgetIntegrationSupport: Partial {
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index db2028082..c926fe042 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -49,6 +49,7 @@ export const widgetKinds = [
"beszelAlerts",
"beszelSystemStats",
"customApi",
+ "openWebUi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts
index 13ed36537..c3aaf191b 100644
--- a/packages/integrations/src/base/creator.ts
+++ b/packages/integrations/src/base/creator.ts
@@ -38,6 +38,7 @@ import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-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";
@@ -143,6 +144,7 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
+ openWebUi: OpenWebUiIntegration,
} satisfies Record Promise]>;
type IntegrationInstanceOfKind = {
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index 66cf4be51..d78c24bb5 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -41,6 +41,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";
// Types
export type { IntegrationInput } from "./base/integration";
diff --git a/packages/integrations/src/open-webui/open-webui-integration.ts b/packages/integrations/src/open-webui/open-webui-integration.ts
new file mode 100644
index 000000000..6a6fafd43
--- /dev/null
+++ b/packages/integrations/src/open-webui/open-webui-integration.ts
@@ -0,0 +1,185 @@
+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,
+ OpenWebUiChatMessage,
+ OpenWebUiChatPayload,
+ OpenWebUiModel,
+} from "./open-webui-types";
+import {
+ openWebUiChatListItemSchema,
+ openWebUiChatSchema,
+ openWebUiCompletionChunkSchema,
+ openWebUiModelsResponseSchema,
+} from "./open-webui-types";
+
+const logger = createLogger({ module: "open-webui-integration" });
+
+export class OpenWebUiIntegration extends Integration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ 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 {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/models"), {
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return openWebUiModelsResponseSchema.parse(await response.json()).data;
+ }
+
+ /**
+ * List the user's chats (native Open WebUI history).
+ */
+ public async listChatsAsync(): Promise {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/chats/"), {
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return openWebUiChatListItemSchema.array().parse(await response.json());
+ }
+
+ /**
+ * Get a single chat including its messages.
+ */
+ public async getChatAsync(chatId: string): Promise {
+ const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/chats/${chatId}`), {
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return openWebUiChatSchema.parse(await response.json());
+ }
+
+ /**
+ * Create a new chat in the native history.
+ */
+ public async createChatAsync(payload: OpenWebUiChatPayload): Promise {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/chats/new"), {
+ method: "POST",
+ headers: { ...this.getAuthHeaders(), "Content-Type": "application/json" },
+ body: JSON.stringify({ chat: payload }),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return openWebUiChatSchema.parse(await response.json());
+ }
+
+ /**
+ * Update an existing chat in the native history.
+ */
+ public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise {
+ const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/chats/${chatId}`), {
+ method: "POST",
+ headers: { ...this.getAuthHeaders(), "Content-Type": "application/json" },
+ body: JSON.stringify({ chat: payload }),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return openWebUiChatSchema.parse(await response.json());
+ }
+
+ /**
+ * 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: OpenWebUiChatMessage[] },
+ onDelta: (delta: string) => void,
+ signal: AbortSignal,
+ ): Promise {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/chat/completions"), {
+ method: "POST",
+ headers: { ...this.getAuthHeaders(), "Content-Type": "application/json" },
+ body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
+ signal,
+ });
+
+ if (!response.ok || !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 delta = parsed.choices?.[0]?.delta?.content;
+ if (delta) {
+ onDelta(delta);
+ }
+ } catch {
+ logger.debug("Failed to parse Open WebUI completion chunk", { line: trimmed });
+ }
+ return false;
+ };
+
+ try {
+ while (!signal.aborted) {
+ 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) {
+ if (processLine(line)) return;
+ }
+ }
+ } finally {
+ reader.cancel().catch(() => {
+ // ignore cancellation errors during teardown
+ });
+ }
+ }
+
+ private getAuthHeaders(): Record {
+ return {
+ Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
+ };
+ }
+}
diff --git a/packages/integrations/src/open-webui/open-webui-types.ts b/packages/integrations/src/open-webui/open-webui-types.ts
new file mode 100644
index 000000000..f880aae04
--- /dev/null
+++ b/packages/integrations/src/open-webui/open-webui-types.ts
@@ -0,0 +1,103 @@
+// 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(),
+});
+
+export const openWebUiModelsResponseSchema = z.object({
+ data: z.array(openWebUiModelSchema),
+});
+
+export type OpenWebUiModel = z.infer;
+
+// 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(),
+});
+
+// A single chat message exchanged with the model / persisted in a chat.
+export const openWebUiChatMessageSchema = z.object({
+ role: z.enum(["system", "user", "assistant"]),
+ content: z.string(),
+});
+
+export type OpenWebUiChatMessage = z.infer;
+
+// 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;
+
+// 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: z.string().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: z.string().nullish(),
+ }),
+ )
+ .nullish(),
+ history: z
+ .object({
+ currentId: z.string().nullish(),
+ messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
+ })
+ .nullish(),
+ })
+ .nullish(),
+});
+
+export type OpenWebUiChat = z.infer;
+
+// Body used when creating/updating a chat through the native API.
+export interface OpenWebUiChatPayload {
+ title: string;
+ models: string[];
+ messages: OpenWebUiChatMessage[];
+}
diff --git a/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts b/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts
new file mode 100644
index 000000000..b6d94d604
--- /dev/null
+++ b/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts
@@ -0,0 +1,151 @@
+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>;
+
+const setupMockFetch = (responses: Record | 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({
+ 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 | 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();
+ });
+ });
+});
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
index afeaf1db2..5a78382c8 100644
--- a/packages/integrations/src/types.ts
+++ b/packages/integrations/src/types.ts
@@ -25,3 +25,4 @@ 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";
diff --git a/packages/request-handler/src/open-webui.ts b/packages/request-handler/src/open-webui.ts
new file mode 100644
index 000000000..79fbb34bd
--- /dev/null
+++ b/packages/request-handler/src/open-webui.ts
@@ -0,0 +1,45 @@
+import dayjs from "dayjs";
+
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { OpenWebUiChat, OpenWebUiChatListItem, OpenWebUiModel } from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export const openWebUiModelsRequestHandler = createCachedIntegrationRequestHandler<
+ OpenWebUiModel[],
+ "openWebUi",
+ Record
+>({
+ async requestAsync(integration) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getModelsAsync();
+ },
+ cacheDuration: dayjs.duration(5, "minutes"),
+ queryKey: "openWebUiModels",
+});
+
+export const openWebUiChatsRequestHandler = createCachedIntegrationRequestHandler<
+ OpenWebUiChatListItem[],
+ "openWebUi",
+ Record
+>({
+ async requestAsync(integration) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.listChatsAsync();
+ },
+ cacheDuration: dayjs.duration(15, "seconds"),
+ queryKey: "openWebUiChats",
+});
+
+export const openWebUiChatRequestHandler = createCachedIntegrationRequestHandler<
+ OpenWebUiChat,
+ "openWebUi",
+ { chatId: string }
+>({
+ async requestAsync(integration, input) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getChatAsync(input.chatId);
+ },
+ cacheDuration: dayjs.duration(15, "seconds"),
+ queryKey: "openWebUiChat",
+});
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 190a13733..440f03999 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -3540,6 +3540,32 @@
"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",
+ "noModels": "No models available",
+ "emptyState": "Start a conversation",
+ "messagePlaceholder": "Type a message…",
+ "newChat": "New chat",
+ "history": "History",
+ "back": "Back",
+ "send": "Send",
+ "stop": "Stop",
+ "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",
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 17e9b8eed..70089d48f 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -60,6 +60,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, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
@@ -116,6 +117,7 @@ export const widgetImports = {
beszelAlerts,
beszelSystemStats,
customApi,
+ openWebUi,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
diff --git a/packages/widgets/src/open-webui/chat.module.css b/packages/widgets/src/open-webui/chat.module.css
new file mode 100644
index 000000000..fd08d54e6
--- /dev/null
+++ b/packages/widgets/src/open-webui/chat.module.css
@@ -0,0 +1,58 @@
+.root {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.messages {
+ position: absolute;
+ inset: 0;
+}
+
+.composer {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: var(--mantine-spacing-xs);
+ z-index: 3;
+ width: min(50vw, calc(100% - var(--mantine-spacing-md)));
+ margin-inline: auto;
+ box-shadow: var(--mantine-shadow-md);
+}
+
+.modelSelect input {
+ text-align: right;
+ font-size: var(--mantine-font-size-xs);
+ cursor: pointer;
+}
+
+.avatar {
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.bubble {
+ max-width: 85%;
+}
+
+.historyOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ 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;
+}
diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx
new file mode 100644
index 000000000..8fa10844e
--- /dev/null
+++ b/packages/widgets/src/open-webui/chat.tsx
@@ -0,0 +1,420 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ ActionIcon,
+ Avatar,
+ Box,
+ Group,
+ Loader,
+ NavLink,
+ Paper,
+ ScrollArea,
+ Select,
+ Stack,
+ Text,
+ Textarea,
+ Tooltip,
+} from "@mantine/core";
+import {
+ IconArrowLeft,
+ IconArrowUp,
+ IconHistory,
+ IconPlayerStopFilled,
+ IconPlus,
+ IconRobot,
+ IconUser,
+} from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
+import { useI18n } from "@homarr/translation/client";
+
+import type { WidgetComponentProps } from "../definition";
+import classes from "./chat.module.css";
+
+interface ChatMessage {
+ role: "system" | "user" | "assistant";
+ content: string;
+}
+
+export function Chat({ options, integrationIds, isEditMode }: WidgetComponentProps<"openWebUi">) {
+ const t = useI18n();
+ const integrationId = integrationIds.at(0);
+
+ const [model, setModel] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+ const [streamingText, setStreamingText] = useState("");
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [pendingMessages, setPendingMessages] = useState([]);
+ const [activeChatId, setActiveChatId] = useState(null);
+ const [selectedChatId, setSelectedChatId] = useState(null);
+ const [error, setError] = useState(null);
+ const [historyOpened, setHistoryOpened] = useState(false);
+
+ const messagesRef = useRef([]);
+ const streamingTextRef = useRef("");
+ const viewportRef = useRef(null);
+ const composerRef = useRef(null);
+ const [composerHeight, setComposerHeight] = useState(72);
+
+ const utils = clientApi.useUtils();
+
+ const { data: models = [], isLoading: modelsLoading } = 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) && options.showHistory, refetchOnWindowFocus: false, retry: false },
+ );
+
+ const { data: loadedChat } = clientApi.widget.openWebUi.getChat.useQuery(
+ { integrationId: integrationId ?? "", chatId: selectedChatId ?? "" },
+ { enabled: Boolean(integrationId) && Boolean(selectedChatId) },
+ );
+
+ const createChat = clientApi.widget.openWebUi.createChat.useMutation();
+ const updateChat = clientApi.widget.openWebUi.updateChat.useMutation();
+
+ // Default to the first available model once the list loads.
+ useEffect(() => {
+ if (model !== null || models.length === 0) return;
+ setModel(models[0]?.id ?? null);
+ }, [model, models]);
+
+ // Hydrate the conversation when a history chat finishes loading.
+ useEffect(() => {
+ if (!loadedChat) return;
+ const loadedMessages = extractChatMessages(loadedChat.chat);
+ setMessages(loadedMessages);
+ messagesRef.current = loadedMessages;
+ setActiveChatId(loadedChat.id);
+ if (loadedChat.chat?.models?.[0]) {
+ setModel(loadedChat.chat.models[0]);
+ }
+ setHistoryOpened(false);
+ }, [loadedChat]);
+
+ // 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();
+ }, []);
+
+ // Auto-scroll to the bottom as messages or streamed tokens arrive.
+ useEffect(() => {
+ viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
+ }, [messages, streamingText, composerHeight]);
+
+ clientApi.widget.openWebUi.sendMessage.useSubscription(
+ { integrationId: integrationId ?? "", model: model ?? "", messages: pendingMessages },
+ {
+ 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);
+ finalizeAssistantMessage();
+ }
+ },
+ onError: (err) => {
+ setError(err.message);
+ finalizeAssistantMessage();
+ },
+ },
+ );
+
+ const finalizeAssistantMessage = () => {
+ const finalText = streamingTextRef.current;
+ if (finalText.length > 0) {
+ const finalMessages = [...messagesRef.current, { role: "assistant" as const, content: finalText }];
+ setMessages(finalMessages);
+ messagesRef.current = finalMessages;
+ void persistChat(finalMessages);
+ }
+ streamingTextRef.current = "";
+ setStreamingText("");
+ setIsStreaming(false);
+ setPendingMessages([]);
+ };
+
+ const persistChat = async (finalMessages: ChatMessage[]) => {
+ if (!integrationId || !model) return;
+ const title = finalMessages.find((message) => message.role === "user")?.content.slice(0, 80) ?? "New chat";
+ const payload = { title, models: [model], messages: finalMessages };
+ try {
+ if (activeChatId) {
+ await updateChat.mutateAsync({ integrationId, chatId: activeChatId, chat: payload });
+ } else {
+ const chat = await createChat.mutateAsync({ integrationId, ...payload });
+ setActiveChatId(chat.id);
+ }
+ if (options.showHistory) {
+ await utils.widget.openWebUi.getChats.invalidate({ integrationId });
+ }
+ } catch {
+ // Persistence is best-effort; the conversation remains usable in-memory.
+ }
+ };
+
+ const handleSend = () => {
+ const trimmed = input.trim();
+ if (!trimmed || !model || !integrationId || isStreaming) return;
+ setError(null);
+
+ const userMessage: ChatMessage = { role: "user", content: trimmed };
+ const nextMessages = [...messagesRef.current, userMessage];
+ setMessages(nextMessages);
+ messagesRef.current = nextMessages;
+ setInput("");
+
+ const systemPrompt = options.systemPrompt.trim();
+ const payloadMessages: ChatMessage[] = systemPrompt
+ ? [{ role: "system", content: systemPrompt }, ...nextMessages]
+ : nextMessages;
+
+ streamingTextRef.current = "";
+ setStreamingText("");
+ setPendingMessages(payloadMessages);
+ setIsStreaming(true);
+ };
+
+ const handleNewChat = () => {
+ setMessages([]);
+ messagesRef.current = [];
+ setActiveChatId(null);
+ setSelectedChatId(null);
+ setError(null);
+ streamingTextRef.current = "";
+ setStreamingText("");
+ setIsStreaming(false);
+ setPendingMessages([]);
+ setHistoryOpened(false);
+ };
+
+ const modelData = useMemo(() => models.map((m) => ({ value: m.id, label: m.name ?? m.id })), [models]);
+
+ return (
+
+
+
+ {messages.length === 0 && !isStreaming ? (
+
+
+ {t("widget.openWebUi.emptyState")}
+
+ ) : null}
+ {messages.map((message, index) => (
+
+ ))}
+ {isStreaming ? : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+ {options.showHistory && historyOpened ? (
+
+
+
+ setHistoryOpened(false)}
+ aria-label={t("widget.openWebUi.back")}
+ >
+
+
+
+
+ {t("widget.openWebUi.history")}
+
+
+
+
+ {chats.length === 0 ? (
+
+ {t("widget.openWebUi.emptyState")}
+
+ ) : (
+ chats.map((chat) => (
+ setSelectedChatId(chat.id)}
+ />
+ ))
+ )}
+
+
+
+ ) : null}
+
+ );
+}
+
+type RawMessage = { role?: string | null; content?: string | null };
+type HistoryMessage = RawMessage & { parentId?: string | null };
+interface RawChat {
+ messages?: RawMessage[] | null;
+ history?: { currentId?: string | null; messages?: Record | null } | null;
+}
+
+const toChatMessage = (role: string | null | undefined, content: string | null | undefined): ChatMessage | null =>
+ (role === "system" || role === "user" || role === "assistant") && typeof content === "string"
+ ? { role, content }
+ : null;
+
+/**
+ * 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.
+ */
+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();
+ 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);
+};
+
+function MessageBubble({ from, content }: { from: ChatMessage["role"]; content: string }) {
+ const isUser = from === "user";
+ const avatar = (
+
+ {isUser ? : }
+
+ );
+ return (
+
+ {isUser ? null : avatar}
+
+
+ {content}
+
+
+ {isUser ? avatar : null}
+
+ );
+}
diff --git a/packages/widgets/src/open-webui/component.tsx b/packages/widgets/src/open-webui/component.tsx
new file mode 100644
index 000000000..c1534d348
--- /dev/null
+++ b/packages/widgets/src/open-webui/component.tsx
@@ -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 ;
+}
diff --git a/packages/widgets/src/open-webui/index.ts b/packages/widgets/src/open-webui/index.ts
new file mode 100644
index 000000000..a8e96f84e
--- /dev/null
+++ b/packages/widgets/src/open-webui/index.ts
@@ -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"));