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.
This commit is contained in:
1 parent
f3608b26a0
commit
005c2e4cd1
25 files changed
+1290
No files matched your search
@@ -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<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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 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<OpenWebUiChatListItem[]> {
|
||||
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<OpenWebUiChat> {
|
||||
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<OpenWebUiChat> {
|
||||
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<OpenWebUiChat> {
|
||||
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<void> {
|
||||
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<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<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(),
|
||||
});
|
||||
|
||||
// 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<typeof openWebUiChatMessageSchema>;
|
||||
|
||||
// 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: 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<typeof openWebUiChatSchema>;
|
||||
|
||||
// Body used when creating/updating a chat through the native API.
|
||||
export interface OpenWebUiChatPayload {
|
||||
title: string;
|
||||
models: string[];
|
||||
messages: OpenWebUiChatMessage[];
|
||||
}
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user