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} + + + + +