Compare commits

..
Author SHA1 Message Date
Nerdy-Technician fe07d2379a fix(integrations): update PatchMon API key settings link
Point the PatchMon “Get API key” link to /settings/integrations and add coverage for the generated URL.

Closes #6284
2026-07-11 21:45:46 +01:00
61 changed files with 28 additions and 11911 deletions

No files matched your search

@@ -1,39 +0,0 @@
---
title: "Open WebUI"
description: "Self-hosted AI chat interface for local and remote LLM providers"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { openWebUiIntegration } from ".";
import { openWebUiWidget } from "../../widgets/open-webui";
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["apiKey"],
steps: [],
},
]}
/>
@@ -1,8 +0,0 @@
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",
};
@@ -1,56 +0,0 @@
---
title: "AI Chat"
description: "Chat with AI models through Open WebUI, with model picking and history."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { openWebUiWidget } from ".";
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: openWebUiIntegration,
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={openWebUiWidget.configuration} />
### Features
| Feature | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
| **Streaming responses** | Assistant replies stream in live as they are generated. |
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
| **Notes** | Attach the contents of your Open WebUI notes. |
| **Reference chats** | Bring a previous conversation in as context for the current one. |
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
### Notes
- The widget uses a single Open WebUI integration.
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
@@ -1,25 +0,0 @@
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",
},
],
},
};
@@ -1,11 +0,0 @@
"use client";
import dynamic from "next/dynamic";
const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), {
ssr: false,
});
export const OpenWebUiChatProvider = () => {
return <OpenWebUiChat />;
};
-2
View File
@@ -32,7 +32,6 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader"; import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine"; import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session"; import { AuthProvider } from "./_client-providers/session";
@@ -148,7 +147,6 @@ export default async function Layout(props: {
<StackedProvider> <StackedProvider>
<Notifications pauseResetOnHover="notification" /> <Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
<OpenWebUiChatProvider />
{props.children} {props.children}
</StackedProvider> </StackedProvider>
</body> </body>
@@ -1,108 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Drawer } from "@mantine/core";
import { useHotkeys, useMediaQuery } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { openWebUiChatEvent } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Chat } from "@homarr/widgets/_open-webui/chat";
/**
* App-wide launcher for the Open WebUI chat. Renders a right-side panel that can
* be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind,
* or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window
* event). Renders nothing unless the signed-in user owns an Open WebUI
* integration.
*/
export const OpenWebUiChat = () => {
const t = useI18n();
const { data: session } = useSession();
const isMobile = useMediaQuery("(max-width: 767px)");
const [opened, setOpened] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
// Bumped on every open so the chat remounts and a fresh initial query is sent.
const [openNonce, setOpenNonce] = useState(0);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const myOpenWebUiIntegration = useMemo(() => {
if (!session?.user?.id) return null;
return integrations.find(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id,
);
}, [integrations, session?.user?.id]);
const open = useCallback((query?: string) => {
setInitialQuery(query?.trim() ? query.trim() : undefined);
setOpenNonce((nonce) => nonce + 1);
setOpened(true);
}, []);
const toggle = useCallback(() => {
setOpened((previous) => {
if (previous) return false;
setInitialQuery(undefined);
setOpenNonce((nonce) => nonce + 1);
return true;
});
}, []);
// Open (with an optional query) when the spotlight "ask AI" mode dispatches.
useEffect(() => {
const handler = (event: Event) => {
if (!myOpenWebUiIntegration) return;
open((event as CustomEvent<{ query?: string }>).detail?.query);
};
window.addEventListener(openWebUiChatEvent, handler);
return () => window.removeEventListener(openWebUiChatEvent, handler);
}, [open, myOpenWebUiIntegration]);
useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]);
if (!myOpenWebUiIntegration) return null;
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size={isMobile ? "100%" : 480}
title={t("widget.openWebUi.name")}
keepMounted={false}
styles={{
body: {
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
padding: 0,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
key={openNonce}
options={{ systemPrompt: "", showHistory: true }}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the launcher chat.
}}
width={480}
height={600}
initialQuery={initialQuery}
/>
</Box>
</Drawer>
);
};
@@ -11,17 +11,14 @@ import {
IconHome, IconHome,
IconLogin, IconLogin,
IconLogout, IconLogout,
IconRobot,
IconSettings, IconSettings,
IconTool, IconTool,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { signOut, useSession } from "@homarr/auth/client"; import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals"; import { createModal, useModalAction } from "@homarr/modals";
import { openOpenWebUiChat } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
@@ -49,15 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal } = useModalAction(LogoutModal); const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session.data?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
);
const handleSignout = useCallback(async () => { const handleSignout = useCallback(async () => {
await signOut({ await signOut({
redirect: false, redirect: false,
@@ -108,11 +96,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
{t("docker")} {t("docker")}
</Menu.Item> </Menu.Item>
)} )}
{hasOpenWebUiIntegration && (
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
{t("aiChat")}
</Menu.Item>
)}
</> </>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -99,7 +99,6 @@ export const integrationRouter = createTRPCRouter({
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
url: integration.url, url: integration.url,
creatorId: integration.creatorId,
permissions: { permissions: {
hasUseAccess: hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"), permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -345,67 +344,24 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app); const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId(); const integrationId = createId();
const userId = ctx.session.user.id; await ctx.db.insert(integrations).values({
const secretValues = input.secrets.map((secret) => ({ id: integrationId,
kind: secret.kind, name: input.name,
value: encryptSecret(secret.value), url: input.url,
integrationId, kind: input.kind,
})); appId,
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
}); });
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
logger.info("Created integration", { logger.info("Created integration", {
id: integrationId, id: integrationId,
name: input.name, name: input.name,
@@ -4,7 +4,6 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; import type { GroupPermissionKey } from "@homarr/definitions";
@@ -302,10 +301,6 @@ describe("create should create a new integration", () => {
deviceType: undefined, deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]), session: defaultSessionWithPermissions(["integration-create"]),
}); });
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = { const input = {
name: "Jellyfin", name: "Jellyfin",
kind: "jellyfin" as const, kind: "jellyfin" as const,
@@ -322,23 +317,10 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst(); const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst(); const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined(); expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name); expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind); expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url); expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id); expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined(); expect(dbSecret).toBeDefined();
@@ -354,10 +336,6 @@ describe("create should create a new integration", () => {
deviceType: undefined, deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]), session: defaultSessionWithPermissions(["integration-create"]),
}); });
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = { const input = {
name: "Jellyseerr", name: "Jellyseerr",
kind: "jellyseerr" as const, kind: "jellyseerr" as const,
@@ -379,7 +357,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name); expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind); expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url); expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id); expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined(); expect(dbSecret).toBeDefined();
@@ -397,10 +374,6 @@ describe("create should create a new integration", () => {
deviceType: undefined, deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]), session: defaultSessionWithPermissions(["integration-create", "app-create"]),
}); });
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = { const input = {
name: "Jellyfin", name: "Jellyfin",
kind: "jellyfin" as const, kind: "jellyfin" as const,
@@ -432,7 +405,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name); expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind); expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url); expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name); expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl); expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href); expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -459,10 +431,6 @@ describe("create should create a new integration", () => {
deviceType: undefined, deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]), session: defaultSessionWithPermissions(["integration-create"]),
}); });
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = { const input = {
name: "Jellyfin", name: "Jellyfin",
kind: "jellyfin" as const, kind: "jellyfin" as const,
@@ -487,7 +455,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind); expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url); expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId); expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id); expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined(); expect(dbSecret).toBeDefined();
-1
View File
@@ -43,6 +43,5 @@ export const widgetRouter = createTRPCRouter({
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)), traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
}); });
@@ -1,379 +0,0 @@
import { observable } from "@trpc/server/observable";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import {
openWebUiChatRequestHandler,
openWebUiChatsRequestHandler,
openWebUiFilesRequestHandler,
openWebUiKnowledgeFilesRequestHandler,
openWebUiKnowledgeRequestHandler,
openWebUiModelsRequestHandler,
openWebUiNoteRequestHandler,
openWebUiNotesRequestHandler,
} from "@homarr/request-handler/open-webui";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "openWebUiRouter" });
/**
* Open WebUI integrations hold a personal API key that grants access to a
* single user's data (chat history, files, notes, knowledge bases). To prevent
* other Homarr users from reading that data, every procedure that touches the
* integration verifies that the caller is the integration's creator.
*
* The check is intentionally placed **after** the integration middleware (which
* already validates the caller's permission level) so the error message does
* not leak whether the integration exists.
*/
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
// Existing integrations that predate the creatorId column have a null
// creatorId; we skip the check for backward compatibility.
if (integration.creatorId && integration.creatorId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
}
};
// Hosts that should never be fetched server-side. processWeb is already
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
const isBlockedWebHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::" ||
host === "::1" ||
host.startsWith("127.") ||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
host === "metadata.google.internal"
);
};
// A user-provided URL for web-page ingestion: must be http(s) and not target a
// loopback/metadata host.
const webUrlSchema = z
.string()
.url()
.refine((value) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
// Bounds the upload/transcription payloads so a huge request can't pressure
// memory here or upstream before we even start decoding.
const MAX_BASE64_LENGTH = 25_000_000;
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
const uploadFilenameSchema = z.string().min(1).max(255);
const contentTypeSchema = z.string().min(1).max(255);
// Tag persisted conversations with the owning Homarr user so they can be found
// back in Open WebUI even when an integration/account is shared between users.
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
});
// Outbound completion messages may carry multimodal parts (text + images),
// so users can attach images to a message.
const contentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
const completionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(contentPartSchema)]),
});
const chatPayloadSchema = z.object({
title: z.string(),
models: z.array(z.string()),
messages: z.array(messageSchema),
});
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
type CompletionMessage = z.infer<typeof completionMessageSchema>;
// Extract the plain text of a message, flattening multimodal content parts.
const messageText = (message: CompletionMessage): string =>
typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
// Number of chunks to retrieve per collection when grounding a reply.
const RETRIEVAL_K = 4;
// Instruction prepended to the retrieved context so the model knows how to use
// it. Model-facing, so intentionally not localized.
const RETRIEVAL_INSTRUCTION =
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
"files, notes and chats) to answer. If the answer is not in the context, say so.";
// Retrieve relevant chunks from the attached collections and prepend them as a
// system context message so the model can ground its reply on them.
const groundMessagesAsync = async (
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
messages: CompletionMessage[],
collections: string[],
contextTexts: string[],
): Promise<CompletionMessage[]> => {
if (collections.length === 0 && contextTexts.length === 0) return messages;
// Use the most recent user message that has text. The latest turn can be
// image-only (no text), in which case we fall back to the prior question so
// attached collections still get a meaningful retrieval query.
const query =
messages
.filter((message) => message.role === "user")
.map(messageText)
.findLast((text) => text.trim() !== "") ?? "";
let documents: string[] = [];
if (collections.length > 0) {
try {
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
}
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
if (blocks.length === 0) return messages;
const contextMessage: CompletionMessage = {
role: "system",
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
};
return [contextMessage, ...messages];
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Knowledge bases, files and notes expose private source content/metadata, so
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getFiles: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNotes: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNote: protectedProcedure
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getDataAsync();
return data;
}),
getKnowledgeFiles: protectedProcedure
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
const { data } = await handler.getDataAsync();
return data;
}),
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
// the file id; its vector collection is `file-{id}`.
uploadFile: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
}),
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
return { text };
}),
// Full chat messages contain private user data; restrict to the creator.
getChat: protectedProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
const { data } = await handler.getDataAsync();
return data;
}),
createChat: protectedProcedure
.input(chatPayloadSchema)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.createChatAsync({
title: input.title,
models: input.models,
messages: input.messages,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
return chat;
}),
updateChat: protectedProcedure
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.updateChatAsync(input.chatId, {
...input.chat,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
openWebUiChatRequestHandler.invalidateCache();
return chat;
}),
// Streaming chat completion. Delivered over the WebSocket subscription link so
// tokens can be emitted as they arrive. A completion spends the owner's upstream
// LLM credits, so it must not be reachable by anonymous viewers of a public
// board: protectedProcedure requires a session (the WS context resolves it from
// the session-token cookie) and the "interact" middleware checks integration
// access, matching processWeb/uploadFile/transcribe above.
sendMessage: protectedProcedure
.input(
z.object({
model: z.string(),
messages: z.array(completionMessageSchema),
// Collections to ground the reply on (web pages, knowledge bases, files).
// We retrieve relevant chunks and inject them before streaming.
collections: z.array(z.string()).optional(),
// Verbatim context blocks (notes, referenced chats) injected as-is.
contextTexts: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
return observable<StreamEvent>((emit) => {
const controller = new AbortController();
void (async () => {
try {
const client = await createIntegrationAsync(ctx.integration);
const messages = await groundMessagesAsync(
client,
input.messages,
input.collections ?? [],
input.contextTexts ?? [],
);
await client.streamChatCompletionAsync(
{ model: input.model, messages },
(delta) => emit.next({ type: "delta", content: delta }),
controller.signal,
);
emit.next({ type: "done" });
emit.complete();
} catch (error) {
if (!controller.signal.aborted) {
logger.error("Open WebUI chat completion failed", {
integrationId: ctx.integration.id,
error,
});
emit.next({
type: "error",
message: error instanceof Error ? error.message : "Failed to stream chat completion",
});
}
emit.complete();
}
})();
return () => {
controller.abort();
};
});
}),
});
@@ -11,7 +11,6 @@ interface IntegrationContextProps {
name: string; name: string;
url: string; url: string;
kind: IntegrationKind; kind: IntegrationKind;
creatorId: string | null;
permissions: { permissions: {
hasFullAccess: boolean; hasFullAccess: boolean;
hasInteractAccess: boolean; hasInteractAccess: boolean;
@@ -15,7 +15,6 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true, name: true,
url: true, url: true,
kind: true, kind: true,
creatorId: true,
}, },
with: { with: {
userPermissions: { userPermissions: {
@@ -1,2 +0,0 @@
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -302,13 +302,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0042_add_widget_secret", "tag": "0042_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783786572320,
"tag": "0043_add_integration_creator_id",
"breakpoints": true
} }
] ]
} }
@@ -1,2 +0,0 @@
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -64,13 +64,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0008_add_widget_secret", "tag": "0008_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786378808,
"tag": "0009_add_integration_creator_id",
"breakpoints": true
} }
] ]
} }
@@ -1 +0,0 @@
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
File diff suppressed because it is too large. Load diff
@@ -288,13 +288,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0040_add_widget_secret", "tag": "0040_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786326756,
"tag": "0041_add_integration_creator_id",
"breakpoints": true
} }
] ]
} }
-5
View File
@@ -209,7 +209,6 @@ export const integrations = mysqlTable(
url: text().notNull(), url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(), kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }), appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
}, },
(integrations) => ({ (integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind), kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -689,10 +688,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId], fields: [integrations.appId],
references: [apps.id], references: [apps.id],
}), }),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
})); }));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({ export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
-5
View File
@@ -208,7 +208,6 @@ export const integrations = pgTable(
url: text().notNull(), url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(), kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }), appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
}, },
(integrations) => ({ (integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind), kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -688,10 +687,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId], fields: [integrations.appId],
references: [apps.id], references: [apps.id],
}), }),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
})); }));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({ export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
-5
View File
@@ -195,7 +195,6 @@ export const integrations = sqliteTable(
url: text().notNull(), url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(), kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }), appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
}, },
(integrations) => ({ (integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind), kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -678,10 +677,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId], fields: [integrations.appId],
references: [apps.id], references: [apps.id],
}), }),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
})); }));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({ export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
@@ -329,7 +329,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut" | "/docs/integrations/peanut"
| "/docs/integrations/beszel" | "/docs/integrations/beszel"
| "/docs/integrations/traefik" | "/docs/integrations/traefik"
| "/docs/integrations/open-webui"
| "/docs/widgets/clock" | "/docs/widgets/clock"
| "/docs/widgets/weather" | "/docs/widgets/weather"
| "/docs/widgets/app" | "/docs/widgets/app"
@@ -382,5 +381,4 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats" | "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik" | "/docs/widgets/traefik"
| "/docs/widgets/custom-api" | "/docs/widgets/custom-api";
| "/docs/widgets/open-webui";
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel", beszel: "beszel",
traefik: "traefik", traefik: "traefik",
archiveTeamWarrior: null, archiveTeamWarrior: null,
openWebUi: "open-webui",
mock: null, mock: null,
}; };
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats", beszelSystemStats: "beszel-system-stats",
traefik: "traefik", traefik: "traefik",
customApi: "custom-api", customApi: "custom-api",
openWebUi: "open-webui",
}; };
-1
View File
@@ -3,5 +3,4 @@ export const hotkeys = {
toggleColorScheme: "mod+j", toggleColorScheme: "mod+j",
saveNotebook: "mod+s", saveNotebook: "mod+s",
openSpotlight: "mod+k", openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
}; };
+1 -11
View File
@@ -379,7 +379,7 @@ export const integrationDefs = {
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"), documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413, defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys", apiKeySettingsPath: "/settings/integrations",
}, },
tracearr: { tracearr: {
name: "Tracearr", name: "Tracearr",
@@ -469,15 +469,6 @@ export const integrationDefs = {
documentationUrl: null, documentationUrl: null,
defaultUrl: "http://localhost:8001", defaultUrl: "http://localhost:8001",
}, },
openWebUi: {
name: "Open WebUI",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
category: ["ai"],
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
defaultPort: 8080,
apiKeySettingsPath: "/?settings=open#account",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) // 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: { mock: {
name: "Mock", name: "Mock",
@@ -592,7 +583,6 @@ export const integrationCategories = [
"documents", "documents",
"mediaLibrary", "mediaLibrary",
"uptimeMonitoring", "uptimeMonitoring",
"ai",
"subtitleManager", "subtitleManager",
"reverseProxy", "reverseProxy",
] as const; ] as const;
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
); );
}); });
it("builds URL for patchmon", () => {
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
"http://patchmon.local:3413/settings/integrations",
);
});
it("returns null for integrations without apiKeySettingsPath", () => { it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull(); expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
}); });
@@ -43,7 +43,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"), vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"], archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"], anchorNote: ["anchor"],
openWebUi: ["openWebUi"],
traefik: ["traefik"], traefik: ["traefik"],
}; };
-1
View File
@@ -53,7 +53,6 @@ export const widgetKinds = [
"beszelSystemStats", "beszelSystemStats",
"traefik", "traefik",
"customApi", "customApi",
"openWebUi",
] as const; ] as const;
export type WidgetKind = (typeof widgetKinds)[number]; export type WidgetKind = (typeof widgetKinds)[number];
@@ -32,7 +32,6 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration"; import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration"; import { GotifyIntegration } from "../gotify/gotify-integration";
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration"; import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
@@ -132,7 +131,6 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration, uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration, peaNut: PeaNutIntegration,
beszel: BeszelIntegration, beszel: BeszelIntegration,
openWebUi: OpenWebUiIntegration,
bazarr: BazarrIntegration, bazarr: BazarrIntegration,
traefik: TraefikIntegration, traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>; } satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
-1
View File
@@ -42,7 +42,6 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration"; export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration"; export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration"; export { PeaNutIntegration } from "./peanut/peanut-integration";
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration"; export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration"; export { TraefikIntegration } from "./traefik/traefik-integration";
@@ -1,372 +0,0 @@
import { FormData } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiChatPayload,
OpenWebUiCompletionMessage,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
OpenWebUiWebDocument,
} from "./open-webui-types";
import {
openWebUiChatListItemSchema,
openWebUiChatSchema,
openWebUiCollectionQueryResponseSchema,
openWebUiCompletionChunkSchema,
openWebUiFileListSchema,
openWebUiKnowledgeListSchema,
openWebUiModelsResponseSchema,
openWebUiNoteListSchema,
openWebUiNoteSchema,
openWebUiProcessWebResponseSchema,
openWebUiTranscriptionSchema,
openWebUiUploadedFileSchema,
} from "./open-webui-types";
const logger = createLogger({ module: "open-webui-integration" });
// Default per-request timeout so a stalled upstream can't hang model/file/note/
// chat calls indefinitely. Streaming completions opt out (they are long-lived).
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
// The (undici) request options accepted by the shared fetch helper. Derived so
// the request helpers stay in sync with the underlying fetch types.
type RequestOptions = NonNullable<Parameters<typeof fetchWithTrustedCertificatesAsync>[1]>;
export class OpenWebUiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/models"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return { success: true };
}
/**
* List the models served by Open WebUI (aggregates all configured providers).
*/
public async getModelsAsync(): Promise<OpenWebUiModel[]> {
const parsed = await this.getJsonAsync("/api/models", openWebUiModelsResponseSchema);
return parsed.data;
}
/**
* List the knowledge bases (RAG collections) the user can ground chats on.
*/
public async getKnowledgeAsync(): Promise<OpenWebUiKnowledge[]> {
const parsed = await this.getJsonAsync("/api/v1/knowledge/", openWebUiKnowledgeListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's uploaded files (for the "Attach Files" picker).
*/
public async listFilesAsync(): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/**
* List the files contained in a knowledge base (for the expandable picker).
* The knowledge detail endpoint returns `files: null` on recent versions, so
* we read the files list and filter by the base's collection id instead.
*/
public async getKnowledgeFilesAsync(knowledgeId: string): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files
.filter((file) => file.meta?.collection_name === knowledgeId)
.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/** Fetch the raw file list, normalizing the paginated and bare-array shapes. */
private async fetchFilesAsync() {
const parsed = await this.getJsonAsync("/api/v1/files/", openWebUiFileListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's notes (for the "Attach Notes" picker).
*/
public async listNotesAsync(): Promise<OpenWebUiNote[]> {
const parsed = await this.getJsonAsync("/api/v1/notes/", openWebUiNoteListSchema);
const notes = Array.isArray(parsed) ? parsed : parsed.items;
return notes.map((note) => ({
id: note.id,
title: note.title ?? "Untitled note",
content: note.data?.content?.md ?? "",
}));
}
/**
* Get a single note including its full markdown content. The list endpoint
* truncates the body, so we fetch the note directly when attaching it.
*/
public async getNoteAsync(noteId: string): Promise<OpenWebUiNote> {
const parsed = await this.getJsonAsync(`/api/v1/notes/${encodeURIComponent(noteId)}`, openWebUiNoteSchema);
return { id: parsed.id, title: parsed.title ?? "Untitled note", content: parsed.data?.content?.md ?? "" };
}
/**
* Transcribe recorded audio to text (speech-to-text) for voice messages.
*/
public async transcribeAudioAsync(filename: string, base64Content: string, contentType: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/audio/transcriptions", form, openWebUiTranscriptionSchema);
return parsed.text;
}
/**
* Upload a file to Open WebUI so it can be retrieved from later. Returns the
* new file id (its vector collection is `file-{id}`).
*/
public async uploadFileAsync(
filename: string,
base64Content: string,
contentType: string,
): Promise<OpenWebUiFileSummary> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/files/", form, openWebUiUploadedFileSchema);
return { id: parsed.id, name: parsed.meta?.name ?? parsed.filename ?? filename };
}
/**
* Ingest a web page into a temporary vector collection and return a handle to
* retrieve from it. Open WebUI does not crawl URLs at completion time, so we
* embed the page here and inject retrieved chunks ourselves at send time.
*/
public async processWebAsync(url: string): Promise<OpenWebUiWebDocument> {
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/process/web",
{ url, collection_name: "" },
openWebUiProcessWebResponseSchema,
);
return { collectionName: parsed.collection_name, title: parsed.file?.meta?.name ?? url };
}
/**
* Retrieve the most relevant chunks for a query across the given collections
* (web pages and/or knowledge bases). Returns the chunk texts, best-effort.
*/
public async queryCollectionsAsync(collectionNames: string[], query: string, k = 4): Promise<string[]> {
if (collectionNames.length === 0 || query.trim() === "") {
return [];
}
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/query/collection",
{ collection_names: collectionNames, query, k },
openWebUiCollectionQueryResponseSchema,
);
return (parsed.documents ?? []).flat();
}
/**
* List the user's chats (native Open WebUI history).
*/
public async listChatsAsync(): Promise<OpenWebUiChatListItem[]> {
return await this.getJsonAsync("/api/v1/chats/", openWebUiChatListItemSchema.array());
}
/**
* Get a single chat including its messages.
*/
public async getChatAsync(chatId: string): Promise<OpenWebUiChat> {
return await this.getJsonAsync(`/api/v1/chats/${encodeURIComponent(chatId)}`, openWebUiChatSchema);
}
/**
* Create a new chat in the native history.
*/
public async createChatAsync(payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync("/api/v1/chats/new", { chat: payload }, openWebUiChatSchema);
}
/**
* Update an existing chat in the native history.
*/
public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync(
`/api/v1/chats/${encodeURIComponent(chatId)}`,
{ chat: payload },
openWebUiChatSchema,
);
}
/**
* Stream an assistant completion token-by-token.
* Reads the OpenAI-compatible SSE response and invokes `onDelta` for each
* content delta. Resolves when the stream completes ([DONE]) or is aborted.
*/
public async streamChatCompletionAsync(
params: { model: string; messages: OpenWebUiCompletionMessage[] },
onDelta: (delta: string) => void,
signal: AbortSignal,
): Promise<void> {
const response = await this.requestAsync("/api/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
// Long-lived stream: opt out of the default request timeout and rely on the
// caller's abort signal instead (the timeout path would also replace this
// signal, breaking cancellation).
timeout: undefined,
signal,
});
if (!response.body) {
throw new ResponseError(response);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
const payload = trimmed.slice(5).trim();
if (payload === "") return false;
if (payload === "[DONE]") return true;
try {
const parsed = openWebUiCompletionChunkSchema.parse(JSON.parse(payload));
const choice = parsed.choices?.[0];
if (choice?.delta?.content) {
onDelta(choice.delta.content);
}
// Some providers end the stream with a finish_reason chunk and no
// explicit [DONE] sentinel; treat that as a clean completion too.
if (choice?.finish_reason) return true;
} catch (error) {
// The chunk can contain model output or grounded user data, so log only
// safe metadata rather than the raw payload.
logger.debug("Failed to parse Open WebUI completion chunk", {
error: error instanceof Error ? error.message : "unknown",
payloadLength: payload.length,
});
}
return false;
};
let completed = false;
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
// The server may close after the final data: event without a trailing
// newline; parse whatever is left so a valid completion isn't treated
// as a truncated stream.
if (buffer.trim() !== "" && processLine(buffer)) {
completed = true;
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processLine(line)) {
completed = true;
break;
}
}
if (completed) break;
}
} finally {
reader.cancel().catch(() => {
// ignore cancellation errors during teardown
});
}
// The reader ended without a [DONE]/finish_reason marker: the upstream
// connection was cut mid-stream. Surface it instead of presenting the
// partial reply as a complete answer.
if (!signal.aborted && !completed) {
throw new Error("Open WebUI stream ended before completion");
}
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
/**
* Issue an authenticated request against the Open WebUI API and throw a
* {@link ResponseError} on a non-2xx status. Centralizes the auth header,
* trusted-certificate fetch and error handling shared by every endpoint.
*/
private async requestAsync(
path: `/${string}`,
init?: Omit<RequestOptions, "headers"> & { headers?: Record<string, string> },
) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
// Apply a default timeout unless the caller opts out (e.g. streaming
// completions pass `timeout: undefined` to stay open indefinitely). A
// spread after this key lets an explicit caller value win.
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...init,
headers: { ...this.getAuthHeaders(), ...init?.headers },
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
/** GET an endpoint and validate the JSON body against `schema`. */
private async getJsonAsync<TOutput>(
path: `/${string}`,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path);
return schema.parse(await response.json());
}
/** POST a JSON body and validate the JSON response against `schema`. */
private async postJsonAsync<TOutput>(
path: `/${string}`,
body: unknown,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return schema.parse(await response.json());
}
/** POST a multipart form (file upload) and validate the JSON response. */
private async postFormAsync<TOutput>(
path: `/${string}`,
form: FormData,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, { method: "POST", body: form });
return schema.parse(await response.json());
}
}
@@ -1,238 +0,0 @@
// Open WebUI API types and schemas.
// Open WebUI exposes an OpenAI-compatible API plus its own native chat-history API.
// Schemas are intentionally defensive (`.nullish()`) because the native chat
// endpoints are undocumented and vary between Open WebUI versions.
import { z } from "zod/v4";
// GET /api/models — OpenAI-compatible. Open WebUI returns `{ data: [...] }`.
export const openWebUiModelSchema = z.object({
id: z.string(),
name: z.string().nullish(),
object: z.string().nullish(),
owned_by: z.string().nullish(),
// Open WebUI exposes per-model capabilities here when configured; `vision`
// tells us whether the model can accept image attachments.
info: z
.object({
meta: z
.object({
capabilities: z.object({ vision: z.boolean().nullish() }).nullish(),
})
.nullish(),
})
.nullish(),
});
export const openWebUiModelsResponseSchema = z.object({
data: z.array(openWebUiModelSchema),
});
export type OpenWebUiModel = z.infer<typeof openWebUiModelSchema>;
// POST /api/chat/completions (stream: true) — OpenAI-compatible SSE delta chunk.
export const openWebUiCompletionChunkSchema = z.object({
id: z.string().nullish(),
choices: z
.array(
z.object({
delta: z
.object({
role: z.string().nullish(),
content: z.string().nullish(),
})
.nullish(),
finish_reason: z.string().nullish(),
}),
)
.nullish(),
});
// Multimodal content part (OpenAI vision format). Lets a message carry text
// and/or images.
export const openWebUiContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
// Message content is either plain text or multimodal parts. Both the completion
// and the native chat/history contracts use this so an attachment-backed message
// round-trips through create/update/get without parse failures or lossy drops.
export const openWebUiMessageContentSchema = z.union([z.string(), z.array(openWebUiContentPartSchema)]);
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
// GET /api/v1/knowledge/ — knowledge bases (RAG collections owned by the user).
// Newer Open WebUI versions paginate the list as `{ items, total }`; older ones
// return a bare array. Accept both and normalise in the integration.
export const openWebUiKnowledgeSchema = z.object({
id: z.string(),
name: z.string().nullish(),
description: z.string().nullish(),
});
export type OpenWebUiKnowledge = z.infer<typeof openWebUiKnowledgeSchema>;
export const openWebUiKnowledgeListSchema = z.union([
z.array(openWebUiKnowledgeSchema),
z.object({ items: z.array(openWebUiKnowledgeSchema) }),
]);
// A file stored in Open WebUI (uploaded directly or part of a knowledge base).
// `meta.collection_name` is the knowledge base id when the file belongs to one.
export const openWebUiFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z
.object({
name: z.string().nullish(),
content_type: z.string().nullish(),
collection_name: z.string().nullish(),
})
.nullish(),
});
export type OpenWebUiFile = z.infer<typeof openWebUiFileSchema>;
export const openWebUiFileListSchema = z.union([
z.array(openWebUiFileSchema),
z.object({ items: z.array(openWebUiFileSchema) }),
]);
// POST /api/v1/files/ — upload response (we only need the new file id + name).
export const openWebUiUploadedFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z.object({ name: z.string().nullish() }).nullish(),
});
// GET /api/v1/knowledge/{id} — knowledge base with its files.
export const openWebUiKnowledgeDetailSchema = z.object({
id: z.string(),
name: z.string().nullish(),
files: z.array(openWebUiFileSchema).nullish(),
});
export type OpenWebUiKnowledgeDetail = z.infer<typeof openWebUiKnowledgeDetailSchema>;
// GET /api/v1/notes/ — user notes (markdown content under data.content.md).
export const openWebUiNoteSchema = z.object({
id: z.string(),
title: z.string().nullish(),
data: z.object({ content: z.object({ md: z.string().nullish() }).nullish() }).nullish(),
});
export const openWebUiNoteListSchema = z.union([
z.array(openWebUiNoteSchema),
z.object({ items: z.array(openWebUiNoteSchema) }),
]);
// A note flattened for the widget: id, title and its markdown body.
export interface OpenWebUiNote {
id: string;
title: string;
content: string;
}
// POST /api/v1/audio/transcriptions — speech-to-text result.
export const openWebUiTranscriptionSchema = z.object({ text: z.string() });
// A file flattened for the widget: id and a display name.
export interface OpenWebUiFileSummary {
id: string;
name: string;
}
// POST /api/v1/retrieval/process/web — ingests a URL into a temporary vector
// collection. We retrieve from that collection at send time and inject the
// results as context (Open WebUI does not fetch URLs at completion time).
export const openWebUiProcessWebResponseSchema = z.object({
collection_name: z.string(),
filename: z.string().nullish(),
file: z.object({ meta: z.object({ name: z.string().nullish() }).nullish() }).nullish(),
});
// POST /api/v1/retrieval/query/collection — top-k chunks per collection.
export const openWebUiCollectionQueryResponseSchema = z.object({
documents: z.array(z.array(z.string())).nullish(),
});
// A web page ingested for grounding: the collection to retrieve from + a label.
export interface OpenWebUiWebDocument {
collectionName: string;
title: string;
}
// GET /api/v1/chats/ — list of the user's chats (native Open WebUI shape).
export const openWebUiChatListItemSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
});
export type OpenWebUiChatListItem = z.infer<typeof openWebUiChatListItemSchema>;
// GET /api/v1/chats/{id} — full chat.
// Two message representations exist: a flat `chat.messages` list (used by chats
// we create) and a `chat.history` tree keyed by message id with a `currentId`
// leaf (used by chats created in the Open WebUI UI). The history tree is
// authoritative when present, since `chat.messages` can be stale/partial.
export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
chat: z
.object({
title: z.string().nullish(),
models: z.array(z.string()).nullish(),
messages: z
.array(
z.object({
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
history: z
.object({
currentId: z.string().nullish(),
messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
})
.nullish(),
})
.nullish(),
});
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
// Body used when creating/updating a chat through the native API. `meta.tags`
// lets Homarr tag a conversation (e.g. with the owning Homarr user id) so it can
// be found back in Open WebUI even when an integration/account is shared.
export interface OpenWebUiChatPayload {
title: string;
models: string[];
messages: OpenWebUiChatMessage[];
meta?: { tags?: string[] };
}
@@ -1,230 +0,0 @@
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OpenWebUiIntegration } from "../open-webui-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_API_KEY = "test-api-key-12345";
const TEST_URL = "https://openwebui.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const asFetchResult = (response: Response) =>
response as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const setupMockFetch = (responses: Record<string, Record<string, unknown> | unknown[]>) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const path = new URL(urlString).pathname;
if (path in responses) {
return Promise.resolve(
asFetchResult(
new Response(JSON.stringify(responses[path]), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
}
return Promise.resolve(asFetchResult(new Response(JSON.stringify({ error: "Not Found" }), { status: 404 })));
});
};
const streamFrom = (chunks: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const createIntegration = () =>
new OpenWebUiIntegration({
id: "test-open-webui",
name: "Test Open WebUI",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
describe("OpenWebUiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getModelsAsync", () => {
test("parses the OpenAI-compatible model list", async () => {
setupMockFetch({
"/api/models": { data: [{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }] },
});
const result = await createIntegration().getModelsAsync();
expect(result).toEqual([{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }]);
expect(mockFetch).toHaveBeenCalled();
});
test("sends the bearer token", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const headers = mockFetch.mock.calls.at(0)?.[1]?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBe(`Bearer ${TEST_API_KEY}`);
});
test("throws when the API returns an error", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Unauthorized", { status: 401 })));
await expect(createIntegration().getModelsAsync()).rejects.toThrow();
});
});
describe("streamChatCompletionAsync", () => {
test("emits each content delta and resolves on [DONE]", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hel" } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { content: "lo" } }] })}\n`,
"data: [DONE]\n",
]),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas).toEqual(["Hel", "lo"]);
});
test("handles a delta split across stream chunks", async () => {
const payload = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
const half = Math.floor(payload.length / 2);
mockFetch.mockResolvedValue(
asFetchResult(
new Response(streamFrom([`data: ${payload.slice(0, half)}`, `${payload.slice(half)}\n`, "data: [DONE]\n"]), {
status: 200,
}),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas.join("")).toBe("world");
});
test("throws when the completion endpoint errors", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Bad Gateway", { status: 502 })));
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
),
).rejects.toThrow();
});
test("flushes the final SSE frame when it has no trailing newline", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "done" } }] })}\n`,
// Final event: no trailing newline and ends via finish_reason (no [DONE]).
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`,
]),
{ status: 200 },
),
),
);
const deltas: string[] = [];
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
),
).resolves.toBeUndefined();
expect(deltas.join("")).toBe("done");
});
});
describe("requestAsync timeouts", () => {
test("applies a default timeout to non-streaming requests", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeGreaterThan(0);
});
test("does not time out the long-lived streaming completion", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response(streamFrom(["data: [DONE]\n"]), { status: 200 })));
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
);
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeUndefined();
});
});
describe("getChatAsync", () => {
test("parses a chat whose message content is multimodal (text + image parts)", async () => {
setupMockFetch({
"/api/v1/chats/abc": {
id: "abc",
chat: {
title: "Vision",
models: ["llava"],
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is this?" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
{ role: "assistant", content: "A cat." },
],
},
},
});
const chat = await createIntegration().getChatAsync("abc");
expect(chat.id).toBe("abc");
expect(chat.chat?.messages?.[0]?.content).toBeDefined();
});
});
});
-1
View File
@@ -26,5 +26,4 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types"; export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types"; export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types"; export * from "./beszel/beszel-types";
export * from "./open-webui/open-webui-types";
export * from "./traefik/traefik-types"; export * from "./traefik/traefik-types";
-109
View File
@@ -1,109 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
} from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const openWebUiModelsRequestHandler = createIntegrationRequestHandler<
OpenWebUiModel[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getModelsAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeRequestHandler = createIntegrationRequestHandler<
OpenWebUiKnowledge[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
{ knowledgeId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeFilesAsync(input.knowledgeId);
},
cacheTtlMs: dayjs.duration(1, "minute").asMilliseconds(),
});
export const openWebUiFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listFilesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNotesRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listNotesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNoteRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote,
"openWebUi",
{ noteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNoteAsync(input.noteId);
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiChatsRequestHandler = createIntegrationRequestHandler<
OpenWebUiChatListItem[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listChatsAsync();
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
export const openWebUiChatRequestHandler = createIntegrationRequestHandler<
OpenWebUiChat,
"openWebUi",
{ chatId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getChatAsync(input.chatId);
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
+3 -13
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { mediaRequestSearchEvent, openWebUiChatEvent, spotlightActions } from "./spotlight-store"; import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight"; export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch, openOpenWebUiChat }; export { openSpotlight, openMediaRequestSearch };
export { export {
SpotlightProvider, SpotlightProvider,
useRegisterSpotlightContextResults, useRegisterSpotlightContextResults,
@@ -25,14 +25,4 @@ const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) =>
spotlightActions.open(); spotlightActions.open();
}; };
export { mediaRequestSearchEvent, openWebUiChatEvent }; export { mediaRequestSearchEvent };
// Opens the Open WebUI chat side panel, optionally seeding it with a query to
// start a conversation from. Closes the spotlight so the panel takes over.
const openOpenWebUiChat = (query?: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
spotlightActions.close();
};
@@ -1,61 +0,0 @@
import { Group, Text } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { openWebUiChatEvent } from "../../spotlight-store";
interface AskAiOption {
key: string;
query: string;
}
// Opening the panel is decoupled from the app via a window event so the
// spotlight package doesn't depend on the Next.js app.
const dispatchOpenChat = (query: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
};
// "Ask AI" — starts an Open WebUI conversation from the spotlight query.
export const askAiGroup = createGroup<AskAiOption>({
keyPath: "key",
title: (t) => t("search.mode.askAi.title"),
Component: (option) => {
const t = useScopedI18n("search.mode.askAi.group.ask");
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<IconRobot stroke={1.5} />
<Text>{option.query ? t("startWithQuery", { query: option.query }) : t("hint")}</Text>
</Group>
);
},
useInteraction: interaction.javaScript((_option, query) => ({
onSelect() {
dispatchOpenChat(query);
},
closeSpotlightOnTrigger: true,
})),
useQueryOptions(query) {
const { data: session } = useSession();
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id,
);
return {
data: hasOpenWebUiIntegration ? [{ key: "ask-ai", query }] : [],
isLoading: false,
isError: false,
};
},
});
@@ -1,8 +0,0 @@
import type { SearchMode } from "../../lib/mode";
import { askAiGroup } from "./ask-ai-group";
export const askAiMode = {
modeKey: "askAi",
character: "?",
groups: [askAiGroup],
} satisfies SearchMode;
@@ -8,7 +8,6 @@ import {
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -18,7 +17,6 @@ import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board"; import { appIntegrationBoardMode } from "../app-integration-board";
import { askAiMode } from "../ask-ai";
import { commandMode } from "../command"; import { commandMode } from "../command";
import { externalMode } from "../external"; import { externalMode } from "../external";
import { mediaMode } from "../media"; import { mediaMode } from "../media";
@@ -42,18 +40,6 @@ export const useHomeEmptyGroups = () => {
visibleSearchModes.unshift(userGroupMode); visibleSearchModes.unshift(userGroupMode);
} }
// Only surface the "Ask AI" mode when the user owns an Open WebUI integration.
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
if (
integrations.some((integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id)
) {
visibleSearchModes.push(askAiMode);
}
return [ return [
createGroup({ createGroup({
keyPath: "key", keyPath: "key",
-2
View File
@@ -1,5 +1,4 @@
import { appIntegrationBoardMode } from "./app-integration-board"; import { appIntegrationBoardMode } from "./app-integration-board";
import { askAiMode } from "./ask-ai";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home"; import { homeMode } from "./home";
@@ -14,6 +13,5 @@ export const searchModes = [
commandMode, commandMode,
pageMode, pageMode,
mediaMode, mediaMode,
askAiMode,
homeMode, homeMode,
] as const; ] as const;
@@ -14,10 +14,6 @@ export interface MediaRequestSearchScope {
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({}); export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search"; export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
// Dispatched to open the Open WebUI chat side panel (optionally with a query to
// start a conversation from). The app-level provider listens for this.
export const openWebUiChatEvent = "homarr:spotlight:open-web-ui-chat";
export const setSelectedAction = (index: number, store: SpotlightStore) => { export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index })); store.updateState((state) => ({ ...state, selected: index }));
}; };
-66
View File
@@ -1489,7 +1489,6 @@
"switchToLightMode": "Switch to light mode", "switchToLightMode": "Switch to light mode",
"management": "Management", "management": "Management",
"docker": "Docker", "docker": "Docker",
"aiChat": "AI Chat",
"preferences": "Your preferences", "preferences": "Your preferences",
"logout": "Logout", "logout": "Logout",
"login": "Login", "login": "Login",
@@ -3849,61 +3848,6 @@
"executeSuccess": "Action executed successfully", "executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration." "executeFailed": "Action failed. Check the widget configuration."
}, },
"openWebUi": {
"name": "AI Chat",
"description": "Chat with AI models through Open WebUI, with model picking and history.",
"option": {
"systemPrompt": {
"label": "System prompt",
"description": "Optional instruction prepended to every conversation."
},
"showHistory": {
"label": "Show chat history",
"description": "Show a button to browse and reopen previous Open WebUI chats."
}
},
"selectModel": "Select a model",
"searchModel": "Search models…",
"noModels": "No models available",
"emptyState": "Start a conversation",
"messagePlaceholder": "Type a message…",
"newChat": "New chat",
"history": "History",
"back": "Back",
"send": "Send",
"stop": "Stop",
"retry": "Retry",
"visionWarning": "This model may not support images",
"attach": "Attach",
"uploadFiles": "Upload Files",
"capture": "Capture",
"attachImage": "Attach image",
"attachKnowledge": "Knowledge",
"noKnowledge": "No knowledge bases in Open WebUI",
"attachWebpage": "Webpage",
"attachFiles": "Files",
"attachNotes": "Notes",
"referenceChats": "Reference Chats",
"noFiles": "No files in Open WebUI",
"noNotes": "No notes in Open WebUI",
"noChats": "No previous chats",
"addUrl": "Add URL",
"removeAttachment": "Remove attachment",
"webError": "Could not load that web page",
"uploadError": "Could not upload the file",
"captureError": "Could not access the camera",
"chatError": "Could not load that chat",
"noteError": "Could not load that note",
"record": "Record voice message",
"stopRecording": "Stop recording",
"cancelRecording": "Cancel recording",
"micError": "Could not access the microphone",
"micPermissionError": "Microphone access was denied. Allow it in your browser to record voice messages.",
"transcribeError": "Could not transcribe the recording",
"error": {
"internalServerError": "Failed to reach Open WebUI"
}
},
"beszelSystemTable": { "beszelSystemTable": {
"name": "Beszel Systems (Table)", "name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators", "description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -5789,16 +5733,6 @@
"fetch": "An error occurred while fetching data" "fetch": "An error occurred while fetching data"
}, },
"mode": { "mode": {
"askAi": {
"help": "Start an AI chat",
"title": "AI Chat",
"group": {
"ask": {
"startWithQuery": "Ask AI: {query}",
"hint": "Start an AI chat"
}
}
},
"appIntegrationBoard": { "appIntegrationBoard": {
"help": "Search for apps, integrations or boards", "help": "Search for apps, integrations or boards",
"group": { "group": {
+1 -2
View File
@@ -20,8 +20,7 @@
"./errors/classes": "./src/errors/index.ts", "./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx", "./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts", "./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts", "./prefetch": "./src/prefetch.ts"
"./_open-webui/chat": "./src/open-webui/chat.tsx"
}, },
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
-2
View File
@@ -64,7 +64,6 @@ import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video"; import * as video from "./video";
import * as weather from "./weather"; import * as weather from "./weather";
import * as customApi from "./custom-api"; import * as customApi from "./custom-api";
import * as openWebUi from "./open-webui";
export type { export type {
WidgetDefinition, WidgetDefinition,
@@ -130,7 +129,6 @@ export const widgetImports = {
beszelSystemStats, beszelSystemStats,
traefik, traefik,
customApi, customApi,
openWebUi,
} satisfies WidgetImportRecord; } satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports; export type WidgetImports = typeof widgetImports;
@@ -1,595 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Checkbox,
CloseButton,
Combobox,
Group,
Image,
Input,
InputBase,
Loader,
Paper,
Popover,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
useCombobox,
} from "@mantine/core";
import {
IconArrowLeft,
IconCamera,
IconCaretDownFilled,
IconCaretUpFilled,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDatabase,
IconFile,
IconFileText,
IconMessage,
IconPaperclip,
IconPlus,
IconRobot,
IconUser,
IconWorld,
} from "@tabler/icons-react";
import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { getIconUrl } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AttachView, ChatMessage, FileAttachment } from "./chat-types";
import type { useAttachments } from "./use-attachments";
import classes from "./chat.module.css";
// Model picker: a non-editable button target showing the selected model, with a
// search field inside the dropdown to filter the list on the frontend.
export function ModelSelect({
data,
value,
onChange,
loading,
disabled,
}: {
data: { value: string; label: string }[];
value: string | null;
onChange: (value: string) => void;
loading: boolean;
disabled: boolean;
}) {
const t = useI18n();
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
onDropdownOpen: () => combobox.focusSearchInput(),
});
const selectedLabel = data.find((item) => item.value === value)?.label;
const filtered = data.filter((item) => item.label.toLowerCase().includes(search.trim().toLowerCase()));
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(submitted) => {
onChange(submitted);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
variant="unstyled"
size="sm"
disabled={disabled}
w={200}
classNames={{ input: classes.modelSelectControl }}
rightSection={
loading ? (
<Loader size="xs" />
) : combobox.dropdownOpened ? (
<IconCaretUpFilled size={14} />
) : (
<IconCaretDownFilled size={14} />
)
}
rightSectionPointerEvents="none"
onClick={() => combobox.toggleDropdown()}
>
{selectedLabel ? (
<span className={classes.modelSelectLabel}>{selectedLabel}</span>
) : (
<Input.Placeholder>{t("widget.openWebUi.selectModel")}</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={t("widget.openWebUi.searchModel")}
/>
<Combobox.Options mah={220} style={{ overflowY: "auto" }}>
{filtered.length > 0 ? (
filtered.map((item) => (
<Combobox.Option value={item.value} key={item.value} active={item.value === value}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<span className={classes.modelOptionLabel}>{item.label}</span>
{item.value === value ? <IconCheck size={14} /> : null}
</Group>
</Combobox.Option>
))
) : (
<Combobox.Empty>{t("widget.openWebUi.noModels")}</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
export function MenuRow({
icon,
label,
hasSub,
onClick,
}: {
icon: React.ReactNode;
label: string;
hasSub?: boolean;
onClick: () => void;
}) {
return (
<Button
variant="subtle"
color="gray"
size="sm"
fullWidth
justify="space-between"
leftSection={icon}
rightSection={hasSub ? <IconChevronRight size={14} /> : <Box w={14} />}
onClick={onClick}
classNames={{ label: classes.menuRowLabel }}
>
{label}
</Button>
);
}
export function PickerList({
count,
emptyLabel,
children,
}: {
count: number;
emptyLabel: string;
children: React.ReactNode;
}) {
if (count === 0) {
return (
<Text size="xs" c="dimmed" p="xs">
{emptyLabel}
</Text>
);
}
return (
<ScrollArea.Autosize mah={220} type="auto">
<Stack gap={6} p={4}>
{children}
</Stack>
</ScrollArea.Autosize>
);
}
export function KnowledgeRow({
integrationId,
knowledgeId,
name,
selected,
onToggle,
isFileSelected,
onToggleFile,
emptyFilesLabel,
}: {
integrationId: string;
knowledgeId: string;
name: string;
selected: boolean;
onToggle: () => void;
isFileSelected: (id: string) => boolean;
onToggleFile: (file: FileAttachment) => void;
emptyFilesLabel: string;
}) {
// Start collapsed so opening the picker doesn't fire getKnowledgeFiles for
// every knowledge base at once (an N+1 burst on large accounts). Files load
// only when a specific row is expanded.
const [expanded, setExpanded] = useState(false);
const { data: files = [], isLoading } = clientApi.widget.openWebUi.getKnowledgeFiles.useQuery(
{ integrationId, knowledgeId },
{ enabled: expanded && Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
return (
<Stack gap={4}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Checkbox size="xs" label={name} checked={selected} onChange={onToggle} />
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setExpanded((value) => !value)}
aria-label={name}
>
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</ActionIcon>
</Group>
{expanded ? (
<Stack gap={4} pl="md">
{isLoading ? (
<Loader size="xs" />
) : files.length === 0 ? (
<Text size="xs" c="dimmed">
{emptyFilesLabel}
</Text>
) : (
files.map((file) => (
<Checkbox
key={file.id}
size="xs"
label={file.name}
checked={isFileSelected(file.id)}
onChange={() => onToggleFile(file)}
/>
))
)}
</Stack>
) : null}
</Stack>
);
}
export function AttachmentBadge({
color,
icon,
label,
onRemove,
removeLabel,
}: {
color: string;
icon: React.ReactNode;
label: string;
onRemove: () => void;
removeLabel: string;
}) {
return (
<Badge
variant="light"
color={color}
leftSection={icon}
rightSection={<CloseButton size="xs" onClick={onRemove} aria-label={removeLabel} />}
className={classes.attachmentBadge}
>
{label}
</Badge>
);
}
// The "attach" popover: a root menu of sources and the per-source sub-views
// (web URL input, file / note / knowledge / chat pickers) used to ground a reply.
export function AttachPopover({
attachments,
chats,
integrationId,
onUploadClick,
onCapture,
}: {
attachments: ReturnType<typeof useAttachments>;
chats: { id: string; title?: string | null }[];
integrationId: string | undefined;
onUploadClick: () => void;
onCapture: () => void;
}) {
const t = useI18n();
const attachViewTitles: Record<Exclude<AttachView, "root">, string> = {
webpage: t("widget.openWebUi.attachWebpage"),
files: t("widget.openWebUi.attachFiles"),
notes: t("widget.openWebUi.attachNotes"),
knowledge: t("widget.openWebUi.attachKnowledge"),
chats: t("widget.openWebUi.referenceChats"),
};
const attachViewTitle = attachments.attachView === "root" ? "" : attachViewTitles[attachments.attachView];
return (
<Popover
opened={attachments.attachOpened}
onChange={attachments.openAttach}
position="top-start"
withinPortal={false}
width={280}
shadow="md"
>
<Popover.Target>
<Tooltip label={t("widget.openWebUi.attach")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => attachments.openAttach(!attachments.attachOpened)}
aria-label={t("widget.openWebUi.attach")}
>
<IconPaperclip size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown p={4}>
{attachments.attachView === "root" ? (
<Stack gap={2}>
<MenuRow
icon={<IconPaperclip size={16} />}
label={t("widget.openWebUi.uploadFiles")}
onClick={onUploadClick}
/>
<MenuRow icon={<IconCamera size={16} />} label={t("widget.openWebUi.capture")} onClick={onCapture} />
<MenuRow
icon={<IconWorld size={16} />}
label={t("widget.openWebUi.attachWebpage")}
hasSub
onClick={() => attachments.setAttachView("webpage")}
/>
<MenuRow
icon={<IconFile size={16} />}
label={t("widget.openWebUi.attachFiles")}
hasSub
onClick={() => attachments.setAttachView("files")}
/>
<MenuRow
icon={<IconFileText size={16} />}
label={t("widget.openWebUi.attachNotes")}
hasSub
onClick={() => attachments.setAttachView("notes")}
/>
<MenuRow
icon={<IconDatabase size={16} />}
label={t("widget.openWebUi.attachKnowledge")}
hasSub
onClick={() => attachments.setAttachView("knowledge")}
/>
<MenuRow
icon={<IconMessage size={16} />}
label={t("widget.openWebUi.referenceChats")}
hasSub
onClick={() => attachments.setAttachView("chats")}
/>
</Stack>
) : (
<Stack gap={4}>
<Group gap={6} wrap="nowrap" px={4} pt={2}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => attachments.setAttachView("root")}
aria-label={t("widget.openWebUi.back")}
>
<IconArrowLeft size={16} />
</ActionIcon>
<Text fw={600} size="sm">
{attachViewTitle}
</Text>
</Group>
{attachments.attachView === "webpage" ? (
<Group gap={4} wrap="nowrap" p={4}>
<TextInput
size="xs"
flex={1}
placeholder="https://…"
value={attachments.webInput}
onChange={(event) => attachments.setWebInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void attachments.addWebUrl();
}
}}
/>
<ActionIcon
variant="light"
color="gray"
loading={attachments.isAddingWeb}
onClick={() => void attachments.addWebUrl()}
aria-label={t("widget.openWebUi.addUrl")}
>
<IconPlus size={16} />
</ActionIcon>
</Group>
) : null}
{attachments.attachView === "files" ? (
<PickerList emptyLabel={t("widget.openWebUi.noFiles")} count={attachments.files.length}>
{attachments.files.map((file) => (
<Checkbox
key={file.id}
size="xs"
label={file.name}
checked={attachments.fileItems.some((item) => item.id === file.id)}
onChange={() => attachments.toggleFile(file)}
/>
))}
</PickerList>
) : null}
{attachments.attachView === "notes" ? (
<PickerList emptyLabel={t("widget.openWebUi.noNotes")} count={attachments.notes.length}>
{attachments.notes.map((note) => (
<Checkbox
key={note.id}
size="xs"
label={note.title}
checked={attachments.noteItems.some((item) => item.id === note.id)}
onChange={() => void attachments.toggleNote({ id: note.id, title: note.title })}
/>
))}
</PickerList>
) : null}
{attachments.attachView === "knowledge" ? (
<PickerList emptyLabel={t("widget.openWebUi.noKnowledge")} count={attachments.knowledge.length}>
{attachments.knowledge.map((item) => (
<KnowledgeRow
key={item.id}
integrationId={integrationId ?? ""}
knowledgeId={item.id}
name={item.name ?? item.id}
selected={attachments.knowledgeIds.includes(item.id)}
onToggle={() => attachments.toggleKnowledge(item.id)}
isFileSelected={(id) => attachments.fileItems.some((file) => file.id === id)}
onToggleFile={attachments.toggleFile}
emptyFilesLabel={t("widget.openWebUi.noFiles")}
/>
))}
</PickerList>
) : null}
{attachments.attachView === "chats" ? (
<PickerList emptyLabel={t("widget.openWebUi.noChats")} count={chats.length}>
{chats.map((chat) => (
<Checkbox
key={chat.id}
size="xs"
label={chat.title ?? chat.id}
checked={attachments.chatItems.some((item) => item.id === chat.id)}
onChange={() => void attachments.toggleChatReference(chat)}
/>
))}
</PickerList>
) : null}
</Stack>
)}
</Popover.Dropdown>
</Popover>
);
}
// Live microphone waveform driven by a Web Audio analyser node.
export function RecordingWaveform({ analyser }: { analyser: AnalyserNode | null }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!analyser || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const data = new Uint8Array(analyser.frequencyBinCount);
const color = getComputedStyle(canvas).color;
let raf = 0;
const render = () => {
raf = requestAnimationFrame(render);
analyser.getByteFrequencyData(data);
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = color;
const barCount = 56;
const step = Math.max(1, Math.floor(data.length / barCount));
const slot = width / barCount;
for (let index = 0; index < barCount; index++) {
const value = (data[index * step] ?? 0) / 255;
const barHeight = Math.max(2, value * height);
ctx.fillRect(index * slot + slot * 0.3, (height - barHeight) / 2, slot * 0.4, barHeight);
}
};
render();
return () => cancelAnimationFrame(raf);
}, [analyser]);
return <canvas ref={canvasRef} width={640} height={32} aria-hidden className={classes.waveformCanvas} />;
}
function TypingDots() {
return (
<span className={classes.typing} aria-label="…">
<span className={classes.typingDot} />
<span className={classes.typingDot} />
<span className={classes.typingDot} />
</span>
);
}
export function MessageBubble({
from,
content,
images,
loading,
}: {
from: ChatMessage["role"];
content: string;
images?: string[];
loading?: boolean;
}) {
const isUser = from === "user";
// The assistant shows the Open WebUI brand logo (IconRobot is the fallback if
// the image fails to load); the user keeps a plain icon avatar.
const avatar = isUser ? (
<Avatar size={22} radius="xl" variant="filled" color="gray" className={classes.avatar}>
<IconUser size={14} />
</Avatar>
) : (
<Avatar size={22} radius="xl" src={getIconUrl("openWebUi")} alt="Open WebUI" className={classes.avatar}>
<IconRobot size={14} />
</Avatar>
);
return (
<Group gap={6} align="flex-start" wrap="nowrap" justify={isUser ? "flex-end" : "flex-start"}>
{isUser ? null : avatar}
<Paper
className={classes.bubble}
withBorder={false}
bg={isUser ? "var(--mantine-primary-color-light)" : "var(--mantine-color-default-hover)"}
p="xs"
radius="md"
>
{images && images.length > 0 ? (
<Group gap={4} mb={content ? 6 : 0}>
{images.map((src, index) => (
<Image key={index} src={src} alt="" w={120} radius="sm" fit="contain" />
))}
</Group>
) : null}
{loading && !content ? <TypingDots /> : null}
{content ? (
isUser ? (
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{content}
</Text>
) : (
<div className={classes.markdown}>
{/* skipHtml only strips raw HTML; drop markdown images too so
untrusted model output can't trigger arbitrary browser fetches
on render. */}
<ReactMarkdown skipHtml components={{ img: () => null }}>
{content}
</ReactMarkdown>
</div>
)
) : null}
</Paper>
{isUser ? avatar : null}
</Group>
);
}
@@ -1,41 +0,0 @@
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
images?: string[];
}
export type CompletionPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
export type CompletionMessage = { role: ChatMessage["role"]; content: string | CompletionPart[] };
export interface ImageAttachment {
name: string;
dataUrl: string;
}
export interface WebAttachment {
url: string;
collectionName: string;
title: string;
}
export interface FileAttachment {
id: string;
name: string;
}
export interface NoteAttachment {
id: string;
title: string;
content: string;
}
export interface ChatAttachment {
id: string;
title: string;
transcript: string;
}
// Which sub-view the attach popover is showing.
export type AttachView = "root" | "webpage" | "files" | "notes" | "knowledge" | "chats";
// Retrieval collections + verbatim context blocks injected before a completion.
export interface Grounding {
collections: string[];
contextTexts: string[];
}
@@ -1,100 +0,0 @@
import type { ChatMessage, CompletionMessage } from "./chat-types";
// Heuristic fallback for detecting vision-capable models when Open WebUI does
// not declare the capability explicitly.
export const VISION_MODEL_PATTERN =
/vision|llava|bakllava|moondream|minicpm-?v|qwen2?-?vl|pixtral|cogvlm|internvl|gemma3|smolvlm|granite[\w-]*vision/i;
export const readBlobAsDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result as string));
reader.addEventListener("error", () => reject(reader.error ?? new Error("Failed to read file")));
reader.readAsDataURL(blob);
});
export const hostnameOf = (url: string): string => {
try {
return new URL(url).hostname || url;
} catch {
return url;
}
};
export const formatSeconds = (total: number): string => {
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
};
// Convert displayed messages to the OpenAI completion format, inlining any
// attached images on the message that carries them (vision content parts).
export const toCompletionMessages = (messages: ChatMessage[]): CompletionMessage[] =>
messages.map((message) =>
message.images && message.images.length > 0
? {
role: message.role,
content: [
...(message.content ? [{ type: "text" as const, text: message.content }] : []),
...message.images.map((url) => ({ type: "image_url" as const, image_url: { url } })),
],
}
: { role: message.role, content: message.content },
);
type ContentPart = { type?: string | null; text?: string | null };
type MessageContent = string | ContentPart[] | null | undefined;
type RawMessage = { role?: string | null; content?: MessageContent };
type HistoryMessage = RawMessage & { parentId?: string | null };
interface RawChat {
messages?: RawMessage[] | null;
history?: { currentId?: string | null; messages?: Record<string, HistoryMessage> | null } | null;
}
// Open WebUI stores message content as either a plain string or multimodal
// parts. Flatten to display text (keeping text parts, dropping image nodes)
// rather than discarding the whole message when it isn't a bare string.
const flattenContent = (content: MessageContent): string | null => {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((part): part is ContentPart & { text: string } => typeof part?.text === "string")
.map((part) => part.text)
.join("");
}
return null;
};
const toChatMessage = (role: string | null | undefined, content: MessageContent): ChatMessage | null => {
if (role !== "system" && role !== "user" && role !== "assistant") return null;
const text = flattenContent(content);
return text === null ? null : { role, content: text };
};
/**
* Reconstructs the ordered conversation from an Open WebUI chat. Chats created in
* the Open WebUI UI store the active branch as a tree under `chat.history`
* (walk from `currentId` up through `parentId`); `chat.messages` can be stale, so
* the history tree takes precedence and we fall back to the flat list.
*/
export const extractChatMessages = (chat: RawChat | null | undefined): ChatMessage[] => {
const history = chat?.history;
if (history?.messages && history.currentId) {
const map = history.messages;
const ordered: ChatMessage[] = [];
const seen = new Set<string>();
let cursor: string | null | undefined = history.currentId;
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const node: HistoryMessage | undefined = map[cursor];
if (!node) break;
const message = toChatMessage(node.role, node.content);
if (message) ordered.unshift(message);
cursor = node.parentId;
}
if (ordered.length > 0) return ordered;
}
return (chat?.messages ?? [])
.map((message) => toChatMessage(message.role, message.content))
.filter((message): message is ChatMessage => message !== null);
};
@@ -1,230 +0,0 @@
.root {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.messages {
position: absolute;
inset: 0;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 3;
background-color: var(--mantine-color-body);
border-bottom: 1px solid var(--mantine-color-default-border);
}
.composer {
position: absolute;
left: 0;
right: 0;
bottom: var(--mantine-spacing-xs);
z-index: 3;
width: calc(100% - var(--mantine-spacing-md));
margin-inline: auto;
box-shadow: var(--mantine-shadow-md);
}
.modelSelectControl {
font-size: var(--mantine-font-size-xs);
cursor: pointer;
overflow: hidden;
}
/* Truncate the selected model name once it hits the control's max width. */
.modelSelectLabel {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelOptionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Markdown-rendered assistant replies: keep block spacing tight inside the bubble. */
.markdown {
font-size: var(--mantine-font-size-sm);
word-break: break-word;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown pre {
margin-block: var(--mantine-spacing-xs);
}
.markdown pre {
overflow-x: auto;
padding: var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-default);
}
.markdown code {
font-family: var(--mantine-font-family-monospace);
font-size: 0.85em;
}
.markdown :not(pre) > code {
padding: 0.1em 0.3em;
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-default);
}
.avatar {
flex-shrink: 0;
margin-top: 2px;
}
.typing {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 2px;
}
.typingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--mantine-color-dimmed);
animation: owui-typing 1.2s infinite ease-in-out both;
}
.typingDot:nth-child(2) {
animation-delay: 0.15s;
}
.typingDot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes owui-typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.typingDot {
animation: owui-typing-fade 1.2s infinite ease-in-out both;
}
@keyframes owui-typing-fade {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
}
.bubble {
max-width: 85%;
}
.menuRowLabel {
flex: 1;
text-align: left;
font-weight: 400;
}
.attachmentBadge {
max-width: 180px;
text-transform: none;
}
.captureVideo {
width: 100%;
max-height: 50vh;
border-radius: var(--mantine-radius-md);
background-color: black;
}
.recordingBar {
width: 100%;
}
.waveformWrap {
flex: 1;
min-width: 0;
height: 32px;
display: flex;
align-items: center;
}
.waveformCanvas {
width: 100%;
height: 32px;
display: block;
color: var(--mantine-primary-color-filled);
}
.recordingTime {
font-variant-numeric: tabular-nums;
min-width: 34px;
text-align: right;
}
.thumb {
position: relative;
line-height: 0;
}
.thumbRemove {
position: absolute;
top: -6px;
right: -6px;
background-color: var(--mantine-color-body);
border-radius: var(--mantine-radius-xl);
box-shadow: var(--mantine-shadow-xs);
}
.historyOverlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
flex-direction: column;
background-color: var(--mantine-color-body);
}
.historyHeader {
flex-shrink: 0;
border-bottom: 1px solid var(--mantine-color-default-border);
}
.historyList {
flex: 1;
min-height: 0;
}
-571
View File
@@ -1,571 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Box,
Button,
CloseButton,
Group,
Image,
Loader,
Modal,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowUp,
IconCamera,
IconCheck,
IconDatabase,
IconFile,
IconFileText,
IconHistory,
IconMessage,
IconMicrophone,
IconPlayerStopFilled,
IconPlus,
IconRefresh,
IconRobot,
IconWorld,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { AttachmentBadge, AttachPopover, MessageBubble, ModelSelect, RecordingWaveform } from "./chat-components";
import classes from "./chat.module.css";
import { VISION_MODEL_PATTERN, formatSeconds, hostnameOf } from "./chat-utils";
import { useAttachments } from "./use-attachments";
import { useCameraCapture } from "./use-camera-capture";
import { useChatStream } from "./use-chat-stream";
import { useVoiceRecording } from "./use-voice-recording";
// `initialQuery` is only supplied when the chat is opened from outside the board
// (spotlight / side panel "start chat with query"); the board widget renderer
// never passes it.
export function Chat({
options,
integrationIds,
isEditMode,
initialQuery,
}: WidgetComponentProps<"openWebUi"> & { initialQuery?: string }) {
const t = useI18n();
const integrationId = integrationIds.at(0);
const [model, setModel] = useState<string | null>(null);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [historyOpened, setHistoryOpened] = useState(false);
const [composerHeight, setComposerHeight] = useState(72);
const [headerHeight, setHeaderHeight] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
data: models = [],
isLoading: modelsLoading,
isError: modelsError,
} = clientApi.widget.openWebUi.getModels.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
const { data: chats = [] } = clientApi.widget.openWebUi.getChats.useQuery(
{ integrationId: integrationId ?? "" },
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
);
const attachments = useAttachments({
integrationId,
onError: setError,
errorMessages: {
upload: t("widget.openWebUi.uploadError"),
web: t("widget.openWebUi.webError"),
note: t("widget.openWebUi.noteError"),
chat: t("widget.openWebUi.chatError"),
},
});
const closeHistory = useCallback(() => setHistoryOpened(false), []);
const stream = useChatStream({
integrationId,
model,
setModel,
systemPrompt: options.systemPrompt,
buildGrounding: attachments.buildGrounding,
setError,
onChatLoaded: closeHistory,
newChatTitle: t("widget.openWebUi.newChat"),
});
const voice = useVoiceRecording({
integrationId,
onError: setError,
onTranscribed: (text) => setInput((previous) => (previous ? `${previous} ${text}` : text)),
micErrorMessage: t("widget.openWebUi.micError"),
micPermissionErrorMessage: t("widget.openWebUi.micPermissionError"),
transcribeErrorMessage: t("widget.openWebUi.transcribeError"),
});
const camera = useCameraCapture({
onError: setError,
onCapture: (attachment) => attachments.setImages((previous) => [...previous, attachment]),
captureErrorMessage: t("widget.openWebUi.captureError"),
});
// Keep the selection reconciled with the available models: default to the
// first one, and reset if the current id disappears (integration change or a
// model removed/renamed upstream) so sends don't use a stale id.
useEffect(() => {
if (models.length === 0) {
if (model !== null) setModel(null);
return;
}
if (model === null || !models.some((item) => item.id === model)) {
setModel(models[0]?.id ?? null);
}
}, [model, models]);
// Track the floating composer's height so messages can pad below it.
useEffect(() => {
const element = composerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => setComposerHeight(element.offsetHeight));
observer.observe(element);
setComposerHeight(element.offsetHeight);
return () => observer.disconnect();
}, []);
// Track the floating header's height so messages can pad above it.
useEffect(() => {
const element = headerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => setHeaderHeight(element.offsetHeight));
observer.observe(element);
setHeaderHeight(element.offsetHeight);
return () => observer.disconnect();
}, []);
// Auto-scroll to the bottom as messages or streamed tokens arrive.
useEffect(() => {
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}, [stream.messages, stream.streamingText, composerHeight]);
// When opened with an initial query (spotlight / side panel "start chat with
// query"), send it once the model has loaded. The caller remounts the chat per
// open (via key) so a fresh query is always delivered.
const autoSentRef = useRef(false);
useEffect(() => {
if (autoSentRef.current) return;
const trimmed = initialQuery?.trim();
if (!trimmed || !model || !integrationId || stream.isStreaming) return;
autoSentRef.current = true;
stream.send({ role: "user", content: trimmed });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery, model, integrationId]);
// Nothing to send yet: no typed text and no inline image attachments.
const composerEmpty = !input.trim() && attachments.images.length === 0;
const handleSend = () => {
const trimmed = input.trim();
if (composerEmpty || !model || !integrationId || stream.isStreaming) return;
stream.send({
role: "user",
content: trimmed,
images: attachments.images.length > 0 ? attachments.images.map((item) => item.dataUrl) : undefined,
});
setInput("");
attachments.setImages([]);
};
const handleNewChat = () => {
stream.reset();
attachments.reset();
setInput("");
setError(null);
setHistoryOpened(false);
};
// Drop the noisy ":latest" tag from the label (common for Ollama models); the
// value keeps the full id so the right model is still sent.
const modelData = useMemo(
() => models.map((m) => ({ value: m.id, label: (m.name ?? m.id).replace(/:latest$/i, "") })),
[models],
);
// Whether the selected model can actually see attached images.
const selectedModel = useMemo(() => models.find((item) => item.id === model), [models, model]);
const modelSupportsVision = useMemo(() => {
if (!selectedModel) return true;
const capability = selectedModel.info?.meta?.capabilities?.vision;
if (typeof capability === "boolean") return capability;
return VISION_MODEL_PATTERN.test(selectedModel.id) || VISION_MODEL_PATTERN.test(selectedModel.name ?? "");
}, [selectedModel]);
const showVisionWarning = attachments.images.length > 0 && !modelSupportsVision;
return (
<Box className={classes.root} style={isEditMode ? { pointerEvents: "none" } : undefined}>
<Group ref={headerRef} className={classes.header} justify="space-between" gap="xs" wrap="nowrap" px="xs" py={6}>
<ModelSelect
data={modelData}
value={model}
onChange={setModel}
loading={modelsLoading}
disabled={stream.isStreaming}
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("widget.openWebUi.newChat")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("widget.openWebUi.newChat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
{options.showHistory ? (
<Tooltip label={t("widget.openWebUi.history")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryOpened(true)}
aria-label={t("widget.openWebUi.history")}
>
<IconHistory size={18} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
<Stack gap="xs" p="xs" pt={headerHeight + 8} pb={composerHeight + 24}>
{stream.messages.length === 0 && !stream.isStreaming ? (
<Stack align="center" justify="center" gap="xs" mt="xl" c={modelsError ? "red.6" : "dimmed"}>
{modelsLoading ? (
<Loader size="sm" />
) : modelsError ? (
<>
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.error.internalServerError")}</Text>
</>
) : (
<>
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.emptyState")}</Text>
</>
)}
</Stack>
) : null}
{stream.messages.map((message, index) => (
<MessageBubble key={index} from={message.role} content={message.content} images={message.images} />
))}
{stream.isStreaming ? (
<MessageBubble from="assistant" content={stream.streamingText} loading={!stream.streamingText} />
) : null}
{error ? (
<Group gap="xs" justify="center" wrap="nowrap">
<Text size="xs" c="red.6">
{error}
</Text>
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<IconRefresh size={14} />}
onClick={stream.retry}
>
{t("widget.openWebUi.retry")}
</Button>
</Group>
) : null}
</Stack>
</ScrollArea>
<Paper ref={composerRef} className={classes.composer} withBorder radius="md" p="xs">
{attachments.hasAttachments ? (
<Group gap={6} mb={6}>
{attachments.images.map((attachment, index) => (
<Box key={`${attachment.name}-${index}`} className={classes.thumb}>
<Image src={attachment.dataUrl} alt={attachment.name} w={48} h={48} radius="sm" fit="cover" />
<CloseButton
size="xs"
className={classes.thumbRemove}
onClick={() => attachments.setImages((previous) => previous.filter((_, i) => i !== index))}
aria-label={t("widget.openWebUi.removeAttachment")}
/>
</Box>
))}
{attachments.knowledgeIds.map((id) => (
<AttachmentBadge
key={id}
color="grape"
icon={<IconDatabase size={12} />}
label={attachments.knowledge.find((item) => item.id === id)?.name ?? id}
onRemove={() => attachments.toggleKnowledge(id)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.webItems.map((item) => (
<AttachmentBadge
key={item.collectionName}
color="blue"
icon={<IconWorld size={12} />}
label={hostnameOf(item.url)}
onRemove={() =>
attachments.setWebItems((previous) =>
previous.filter((value) => value.collectionName !== item.collectionName),
)
}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.fileItems.map((item) => (
<AttachmentBadge
key={item.id}
color="teal"
icon={<IconFile size={12} />}
label={item.name}
onRemove={() => attachments.toggleFile(item)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.noteItems.map((item) => (
<AttachmentBadge
key={item.id}
color="yellow"
icon={<IconFileText size={12} />}
label={item.title}
onRemove={() => void attachments.toggleNote(item)}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
{attachments.chatItems.map((item) => (
<AttachmentBadge
key={item.id}
color="indigo"
icon={<IconMessage size={12} />}
label={item.title}
onRemove={() =>
attachments.setChatItems((previous) => previous.filter((value) => value.id !== item.id))
}
removeLabel={t("widget.openWebUi.removeAttachment")}
/>
))}
</Group>
) : null}
{showVisionWarning ? (
<Group gap={4} mb={6} wrap="nowrap">
<IconAlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow.7">
{t("widget.openWebUi.visionWarning")}
</Text>
</Group>
) : null}
{voice.isRecording ? (
<Group gap="xs" wrap="nowrap" className={classes.recordingBar} mt={4}>
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
onClick={voice.cancelRecording}
aria-label={t("widget.openWebUi.cancelRecording")}
>
<IconX size={18} />
</ActionIcon>
<Box className={classes.waveformWrap}>
<RecordingWaveform analyser={voice.analyser} />
</Box>
<Text size="xs" c="dimmed" className={classes.recordingTime}>
{formatSeconds(voice.recordingSeconds)}
</Text>
<ActionIcon
variant="filled"
radius="xl"
loading={voice.isTranscribing}
onClick={voice.confirmRecording}
aria-label={t("widget.openWebUi.stopRecording")}
>
<IconCheck size={18} />
</ActionIcon>
</Group>
) : (
<>
<Textarea
variant="unstyled"
size="sm"
autosize
minRows={1}
maxRows={5}
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
placeholder={t("widget.openWebUi.messagePlaceholder")}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}}
/>
<Group justify="space-between" gap="xs" wrap="nowrap" mt={4}>
<Group gap={4} wrap="nowrap">
<AttachPopover
attachments={attachments}
chats={chats}
integrationId={integrationId}
onUploadClick={() => fileInputRef.current?.click()}
onCapture={() => {
attachments.openAttach(false);
void camera.openCapture();
}}
/>
</Group>
<Group gap="xs" wrap="nowrap">
{!stream.isStreaming && composerEmpty ? (
<Tooltip label={t("widget.openWebUi.record")} withinPortal={false}>
<ActionIcon
radius="xl"
variant="subtle"
color="gray"
loading={voice.isTranscribing}
onClick={() => {
setError(null);
void voice.startRecording();
}}
aria-label={t("widget.openWebUi.record")}
>
<IconMicrophone size={18} />
</ActionIcon>
</Tooltip>
) : null}
{stream.isStreaming ? (
<Tooltip label={t("widget.openWebUi.stop")} withinPortal={false}>
<ActionIcon
radius="xl"
color="red"
variant="filled"
onClick={stream.stop}
aria-label={t("widget.openWebUi.stop")}
>
<IconPlayerStopFilled size={16} />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label={t("widget.openWebUi.send")} withinPortal={false}>
<ActionIcon
radius="xl"
variant="filled"
onClick={handleSend}
disabled={composerEmpty || !model}
aria-label={t("widget.openWebUi.send")}
>
<IconArrowUp size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
</>
)}
<input
ref={fileInputRef}
type="file"
multiple
hidden
aria-label={t("widget.openWebUi.uploadFiles")}
onChange={(event) => {
void attachments.handleFilesSelected(event.currentTarget.files);
event.currentTarget.value = "";
}}
/>
</Paper>
<Modal opened={camera.captureOpened} onClose={camera.closeCapture} title={t("widget.openWebUi.capture")} centered>
<Stack gap="sm">
<video
ref={camera.setVideoNode}
autoPlay
playsInline
muted
aria-label={t("widget.openWebUi.capture")}
className={classes.captureVideo}
>
<track kind="captions" />
</video>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={camera.closeCapture}>
{t("widget.openWebUi.back")}
</Button>
<Button size="xs" leftSection={<IconCamera size={16} />} onClick={camera.takePhoto}>
{t("widget.openWebUi.capture")}
</Button>
</Group>
</Stack>
</Modal>
{options.showHistory && historyOpened ? (
<Paper className={classes.historyOverlay} radius={0}>
<Group gap="xs" p="xs" wrap="nowrap" className={classes.historyHeader}>
<Tooltip label={t("widget.openWebUi.back")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryOpened(false)}
aria-label={t("widget.openWebUi.back")}
>
<IconArrowLeft size={18} />
</ActionIcon>
</Tooltip>
<Text fw={600} size="sm">
{t("widget.openWebUi.history")}
</Text>
</Group>
<ScrollArea className={classes.historyList} type="auto">
<Stack gap={2} p={4}>
{chats.length === 0 ? (
<Text size="sm" c="dimmed" p="xs">
{t("widget.openWebUi.emptyState")}
</Text>
) : (
chats.map((chat) => (
<NavLink
key={chat.id}
active={chat.id === stream.activeChatId}
label={chat.title ?? chat.id}
onClick={() => {
// Drop the draft's grounding so a loaded conversation
// isn't answered against the previous draft's attachments.
attachments.reset();
setError(null);
stream.setSelectedChatId(chat.id);
}}
/>
))
)}
</Stack>
</ScrollArea>
</Paper>
) : null}
</Box>
);
}
@@ -1,13 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import type { WidgetComponentProps } from "../definition";
const Chat = dynamic(() => import("./chat").then((module) => module.Chat), {
ssr: false,
});
export default function OpenWebUiWidget(props: WidgetComponentProps<"openWebUi">) {
return <Chat {...props} />;
}
-31
View File
@@ -1,31 +0,0 @@
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"));
@@ -1,239 +0,0 @@
import { useState } from "react";
import { clientApi } from "@homarr/api/client";
import type {
AttachView,
ChatAttachment,
FileAttachment,
Grounding,
ImageAttachment,
NoteAttachment,
WebAttachment,
} from "./chat-types";
import { extractChatMessages, readBlobAsDataUrl } from "./chat-utils";
interface UseAttachmentsOptions {
integrationId: string | undefined;
onError: (message: string) => void;
errorMessages: { upload: string; web: string; note: string; chat: string };
}
/**
* Owns every "ground the conversation" attachment source: inline vision images,
* uploaded files, ingested web pages, knowledge bases, notes and referenced
* chats — including the pickers' data, the toggle handlers, and assembling the
* retrieval collections / verbatim context blocks sent with a completion.
*/
export function useAttachments({ integrationId, onError, errorMessages }: UseAttachmentsOptions) {
// Images are inline per-message (vision); the rest are sticky context kept for
// the conversation until removed.
const [images, setImages] = useState<ImageAttachment[]>([]);
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([]);
const [webItems, setWebItems] = useState<WebAttachment[]>([]);
const [fileItems, setFileItems] = useState<FileAttachment[]>([]);
const [noteItems, setNoteItems] = useState<NoteAttachment[]>([]);
const [chatItems, setChatItems] = useState<ChatAttachment[]>([]);
const [webInput, setWebInput] = useState("");
const [attachOpened, setAttachOpened] = useState(false);
const [attachView, setAttachView] = useState<AttachView>("root");
const utils = clientApi.useUtils();
const processWeb = clientApi.widget.openWebUi.processWeb.useMutation();
const uploadFile = clientApi.widget.openWebUi.uploadFile.useMutation();
const pickerQueryOptions = {
enabled: Boolean(integrationId) && attachOpened,
refetchOnWindowFocus: false,
retry: false,
} as const;
const { data: knowledge = [] } = clientApi.widget.openWebUi.getKnowledge.useQuery(
{ integrationId: integrationId ?? "" },
pickerQueryOptions,
);
const { data: files = [] } = clientApi.widget.openWebUi.getFiles.useQuery(
{ integrationId: integrationId ?? "" },
pickerQueryOptions,
);
const { data: notes = [] } = clientApi.widget.openWebUi.getNotes.useQuery(
{ integrationId: integrationId ?? "" },
pickerQueryOptions,
);
// Reset the popover to the root view whenever it closes.
const openAttach = (open: boolean) => {
setAttachOpened(open);
if (!open) setAttachView("root");
};
// Upload Files: images become inline vision attachments; everything else is
// uploaded to Open WebUI and grounded via retrieval.
const handleFilesSelected = async (fileList: FileList | null) => {
if (!fileList || !integrationId) return;
for (const file of Array.from(fileList)) {
try {
const dataUrl = await readBlobAsDataUrl(file);
if (file.type.startsWith("image/")) {
setImages((previous) => [...previous, { name: file.name, dataUrl }]);
} else {
const contentBase64 = dataUrl.split(",")[1] ?? "";
const uploaded = await uploadFile.mutateAsync({
integrationId,
filename: file.name,
contentBase64,
contentType: file.type || "application/octet-stream",
});
setFileItems((previous) =>
previous.some((item) => item.id === uploaded.id) ? previous : [...previous, uploaded],
);
}
} catch {
onError(errorMessages.upload);
}
}
};
const addWebUrl = async () => {
if (!integrationId) return;
const raw = webInput.trim();
if (!raw) return;
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const document = await processWeb.mutateAsync({ integrationId, url });
// Only clear the field once ingestion succeeds so a transient error
// doesn't force the user to retype the URL.
setWebInput("");
setWebItems((previous) =>
previous.some((item) => item.collectionName === document.collectionName)
? previous
: [...previous, { url, collectionName: document.collectionName, title: document.title }],
);
openAttach(false);
} catch {
onError(errorMessages.web);
}
};
const toggleFile = (file: FileAttachment) =>
setFileItems((previous) =>
previous.some((item) => item.id === file.id)
? previous.filter((item) => item.id !== file.id)
: [...previous, file],
);
// The list endpoint truncates note bodies, so fetch the full note on attach.
const toggleNote = async (note: { id: string; title: string }) => {
if (!integrationId) return;
if (noteItems.some((item) => item.id === note.id)) {
setNoteItems((previous) => previous.filter((item) => item.id !== note.id));
return;
}
try {
const full = await utils.widget.openWebUi.getNote.fetch({ integrationId, noteId: note.id });
// A double-click can start two fetches before state updates; dedupe on the
// resolved id so the note isn't attached (and grounded) twice.
setNoteItems((previous) =>
previous.some((item) => item.id === full.id)
? previous
: [...previous, { id: full.id, title: full.title, content: full.content }],
);
} catch {
onError(errorMessages.note);
}
};
const toggleKnowledge = (id: string) =>
setKnowledgeIds((previous) =>
previous.includes(id) ? previous.filter((value) => value !== id) : [...previous, id],
);
const toggleChatReference = async (chat: { id: string; title?: string | null }) => {
if (!integrationId) return;
if (chatItems.some((item) => item.id === chat.id)) {
setChatItems((previous) => previous.filter((item) => item.id !== chat.id));
return;
}
try {
const full = await utils.widget.openWebUi.getChat.fetch({ integrationId, chatId: chat.id });
const transcript = extractChatMessages(full.chat)
.map((message) => `${message.role}: ${message.content}`)
.join("\n");
// Dedupe on the resolved id so a double-click can't attach the same chat
// (and its transcript) twice.
setChatItems((previous) =>
previous.some((item) => item.id === chat.id)
? previous
: [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }],
);
} catch {
onError(errorMessages.chat);
}
};
const buildGrounding = (): Grounding => ({
// Retrieval collections: knowledge bases (id), web pages and files.
collections: [
...knowledgeIds,
...webItems.map((item) => item.collectionName),
...fileItems.map((item) => `file-${item.id}`),
],
// Verbatim context: notes and referenced chats.
contextTexts: [
...noteItems.map((note) => `Note "${note.title}":\n${note.content}`),
...chatItems.map((chat) => `Previous chat "${chat.title}":\n${chat.transcript}`),
],
});
const reset = () => {
setImages([]);
setKnowledgeIds([]);
setWebItems([]);
setFileItems([]);
setNoteItems([]);
setChatItems([]);
};
const hasAttachments =
images.length > 0 ||
knowledgeIds.length > 0 ||
webItems.length > 0 ||
fileItems.length > 0 ||
noteItems.length > 0 ||
chatItems.length > 0;
return {
// state
images,
setImages,
knowledgeIds,
webItems,
setWebItems,
fileItems,
noteItems,
chatItems,
setChatItems,
webInput,
setWebInput,
attachOpened,
attachView,
setAttachView,
hasAttachments,
// picker data
knowledge,
files,
notes,
// status
isUploading: processWeb.isPending || uploadFile.isPending,
isAddingWeb: processWeb.isPending,
// handlers
openAttach,
handleFilesSelected,
addWebUrl,
toggleFile,
toggleNote,
toggleKnowledge,
toggleChatReference,
buildGrounding,
reset,
};
}
@@ -1,69 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ImageAttachment } from "./chat-types";
interface UseCameraCaptureOptions {
onError: (message: string) => void;
onCapture: (attachment: ImageAttachment) => void;
captureErrorMessage: string;
}
/**
* Owns the webcam capture lifecycle: acquiring the stream, attaching it to the
* <video> the moment it mounts, taking a still frame, and tearing the stream
* down (including on unmount).
*/
export function useCameraCapture({ onError, onCapture, captureErrorMessage }: UseCameraCaptureOptions) {
const [captureOpened, setCaptureOpened] = useState(false);
const captureStreamRef = useRef<MediaStream | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const stopCaptureStream = () => {
captureStreamRef.current?.getTracks().forEach((track) => track.stop());
captureStreamRef.current = null;
};
const openCapture = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
captureStreamRef.current = stream;
setCaptureOpened(true);
} catch {
onError(captureErrorMessage);
}
};
const closeCapture = () => {
stopCaptureStream();
setCaptureOpened(false);
};
// Attach the live stream the moment the <video> mounts (avoids a ref race
// where an effect runs before the modal's video element exists).
const setVideoNode = useCallback((node: HTMLVideoElement | null) => {
videoRef.current = node;
if (node && captureStreamRef.current) {
node.srcObject = captureStreamRef.current;
void node.play().catch(() => undefined);
}
}, []);
const takePhoto = () => {
const video = videoRef.current;
if (!video) return;
// Bail if the stream hasn't delivered a frame yet, otherwise we'd capture a
// 0x0 (blank) canvas and attach an empty image.
if (video.readyState < 2 || video.videoWidth === 0 || video.videoHeight === 0) return;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
onCapture({ name: `capture-${Date.now()}.png`, dataUrl: canvas.toDataURL("image/png") });
closeCapture();
};
// Stop the camera stream on unmount.
useEffect(() => () => stopCaptureStream(), []);
return { captureOpened, openCapture, closeCapture, setVideoNode, takePhoto };
}
@@ -1,251 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type { ChatMessage, CompletionMessage, Grounding } from "./chat-types";
import { extractChatMessages, toCompletionMessages } from "./chat-utils";
interface UseChatStreamOptions {
integrationId: string | undefined;
model: string | null;
setModel: (model: string) => void;
systemPrompt: string;
buildGrounding: () => Grounding;
setError: (message: string | null) => void;
onChatLoaded: () => void;
newChatTitle: string;
}
/**
* Owns the conversation itself: the streamed completion subscription, the
* in-memory message list, persistence to Open WebUI's native history, and the
* send / retry / stop / new-chat transitions.
*/
export function useChatStream({
integrationId,
model,
setModel,
systemPrompt,
buildGrounding,
setError,
onChatLoaded,
newChatTitle,
}: UseChatStreamOptions) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [pendingMessages, setPendingMessages] = useState<CompletionMessage[]>([]);
const [pendingCollections, setPendingCollections] = useState<string[]>([]);
const [pendingContextTexts, setPendingContextTexts] = useState<string[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const streamingTextRef = useRef("");
// Read inside the serialized persist loop so a burst of turns reuses the chat
// created by the first write instead of racing on the not-yet-committed state.
const activeChatIdRef = useRef<string | null>(null);
// Bumped whenever the conversation is replaced (history load / new chat) so a
// detached persist that started against an old conversation can be discarded
// instead of rebinding the cleared chat to a stale Open WebUI chat id.
const sessionRef = useRef(0);
const persistInFlightRef = useRef(false);
const pendingPersistRef = useRef<ChatMessage[] | null>(null);
const utils = clientApi.useUtils();
const createChat = clientApi.widget.openWebUi.createChat.useMutation();
const updateChat = clientApi.widget.openWebUi.updateChat.useMutation();
const { data: loadedChat } = clientApi.widget.openWebUi.getChat.useQuery(
{ integrationId: integrationId ?? "", chatId: selectedChatId ?? "" },
{ enabled: Boolean(integrationId) && Boolean(selectedChatId) },
);
// Hydrate the conversation when a history chat finishes loading.
useEffect(() => {
if (!loadedChat) return;
sessionRef.current += 1;
const loadedMessages = extractChatMessages(loadedChat.chat);
setMessages(loadedMessages);
messagesRef.current = loadedMessages;
setActiveChatId(loadedChat.id);
activeChatIdRef.current = loadedChat.id;
if (loadedChat.chat?.models?.[0]) setModel(loadedChat.chat.models[0]);
onChatLoaded();
}, [loadedChat, setModel, onChatLoaded]);
const runPersist = async (finalMessages: ChatMessage[], session: number) => {
if (!integrationId || !model) return;
// Native history stores plain text; image data URLs are dropped on persist.
// Derive the title from the first user message that actually has text so an
// image-only opening turn still gets the fallback title instead of a blank.
const firstUserText = finalMessages
.find((message) => message.role === "user" && message.content.trim() !== "")
?.content.trim();
const title = firstUserText ? firstUserText.slice(0, 80) : newChatTitle;
const payload = {
title,
models: [model],
messages: finalMessages.map(({ role, content }) => ({ role, content })),
};
try {
const chatId = activeChatIdRef.current;
if (chatId) {
await updateChat.mutateAsync({ integrationId, chatId, chat: payload });
} else {
const chat = await createChat.mutateAsync({ integrationId, ...payload });
// The conversation was cleared/replaced while this create was in flight;
// don't rebind the now-stale chat id onto the current conversation.
if (session !== sessionRef.current) return;
activeChatIdRef.current = chat.id;
setActiveChatId(chat.id);
}
if (session !== sessionRef.current) return;
await utils.widget.openWebUi.getChats.invalidate({ integrationId });
} catch {
// Persistence is best-effort; the conversation remains usable in-memory.
}
};
// Open WebUI's chat endpoint replaces the whole chat object, so each turn
// re-sends the full transcript. Serialize the writes and collapse a burst into
// a single trailing save of the latest messages so turns don't stack
// concurrent POSTs (or create duplicate chats on the first save).
const persistChat = async (finalMessages: ChatMessage[]) => {
const session = sessionRef.current;
if (persistInFlightRef.current) {
pendingPersistRef.current = finalMessages;
return;
}
persistInFlightRef.current = true;
try {
let next: ChatMessage[] | null = finalMessages;
while (next) {
pendingPersistRef.current = null;
await runPersist(next, session);
next = pendingPersistRef.current;
}
} finally {
persistInFlightRef.current = false;
}
};
const clearPending = () => {
setPendingMessages([]);
setPendingCollections([]);
setPendingContextTexts([]);
};
const finalizeAssistantMessage = (skipPersist = false) => {
const finalText = streamingTextRef.current;
if (finalText.length > 0) {
const finalMessages = [...messagesRef.current, { role: "assistant" as const, content: finalText }];
setMessages(finalMessages);
messagesRef.current = finalMessages;
// A stopped or failed reply is kept on screen but not written to history as
// a finished turn; a later real turn will persist the whole conversation.
if (!skipPersist) void persistChat(finalMessages);
}
streamingTextRef.current = "";
setStreamingText("");
setIsStreaming(false);
clearPending();
};
clientApi.widget.openWebUi.sendMessage.useSubscription(
{
integrationId: integrationId ?? "",
model: model ?? "",
messages: pendingMessages,
collections: pendingCollections.length > 0 ? pendingCollections : undefined,
contextTexts: pendingContextTexts.length > 0 ? pendingContextTexts : undefined,
},
{
enabled: isStreaming && pendingMessages.length > 0,
onData: (event) => {
if (event.type === "delta") {
streamingTextRef.current += event.content;
setStreamingText(streamingTextRef.current);
} else if (event.type === "done") {
finalizeAssistantMessage();
} else if (event.type === "error") {
setError(event.message);
// Don't persist a partial reply from a failed turn as finished history.
finalizeAssistantMessage(true);
}
},
onError: (err) => {
setError(err.message);
// Don't persist a partial reply from a failed turn as finished history.
finalizeAssistantMessage(true);
},
},
);
// Kick off a completion for the given conversation (ending with a user message).
// Shared by sending a new message and retrying after a failure.
const startCompletion = (baseMessages: ChatMessage[]) => {
if (!model || !integrationId) return;
setError(null);
const trimmedSystemPrompt = systemPrompt.trim();
const payloadMessages: ChatMessage[] = trimmedSystemPrompt
? [{ role: "system", content: trimmedSystemPrompt }, ...baseMessages]
: baseMessages;
const grounding = buildGrounding();
streamingTextRef.current = "";
setStreamingText("");
setPendingMessages(toCompletionMessages(payloadMessages));
setPendingCollections(grounding.collections);
setPendingContextTexts(grounding.contextTexts);
setIsStreaming(true);
};
const send = (userMessage: ChatMessage) => {
const nextMessages = [...messagesRef.current, userMessage];
setMessages(nextMessages);
messagesRef.current = nextMessages;
startCompletion(nextMessages);
};
// Retry the last turn after a failure: drop any partial assistant reply and
// re-run the completion from the most recent user message.
const retry = () => {
if (isStreaming) return;
const lastUserIndex = messagesRef.current.findLastIndex((message) => message.role === "user");
if (lastUserIndex === -1) return;
const truncated = messagesRef.current.slice(0, lastUserIndex + 1);
setMessages(truncated);
messagesRef.current = truncated;
startCompletion(truncated);
};
const reset = () => {
sessionRef.current += 1;
setMessages([]);
messagesRef.current = [];
setActiveChatId(null);
activeChatIdRef.current = null;
setSelectedChatId(null);
streamingTextRef.current = "";
setStreamingText("");
setIsStreaming(false);
clearPending();
};
return {
messages,
streamingText,
isStreaming,
activeChatId,
selectedChatId,
setSelectedChatId,
send,
retry,
stop: () => finalizeAssistantMessage(true),
reset,
};
}
@@ -1,156 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { readBlobAsDataUrl } from "./chat-utils";
interface UseVoiceRecordingOptions {
integrationId: string | undefined;
onError: (message: string) => void;
onTranscribed: (text: string) => void;
micErrorMessage: string;
micPermissionErrorMessage: string;
transcribeErrorMessage: string;
}
/**
* Owns the microphone recording lifecycle: capturing audio, driving the live
* waveform analyser, and transcribing the result to text. Cancel discards the
* recording; confirm transcribes it. Streams/timers are torn down on unmount.
*/
export function useVoiceRecording({
integrationId,
onError,
onTranscribed,
micErrorMessage,
micPermissionErrorMessage,
transcribeErrorMessage,
}: UseVoiceRecordingOptions) {
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const audioStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const recordingTimerRef = useRef<number | null>(null);
const recordingCancelledRef = useRef(false);
const transcribe = clientApi.widget.openWebUi.transcribe.useMutation();
// Stop the mic stream, audio context and timer. No state updates, so it is
// safe to call during unmount as well as from teardownRecording.
const releaseAudioResources = () => {
if (recordingTimerRef.current !== null) {
window.clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
audioStreamRef.current = null;
void audioContextRef.current?.close().catch(() => undefined);
audioContextRef.current = null;
};
const teardownRecording = () => {
releaseAudioResources();
setAnalyser(null);
setIsRecording(false);
};
const finishRecording = async () => {
const cancelled = recordingCancelledRef.current;
const chunks = audioChunksRef.current;
audioChunksRef.current = [];
teardownRecording();
if (cancelled || chunks.length === 0 || !integrationId) return;
const blob = new Blob(chunks, { type: chunks[0]?.type ?? "audio/webm" });
try {
const dataUrl = await readBlobAsDataUrl(blob);
const result = await transcribe.mutateAsync({
integrationId,
filename: "recording.webm",
contentBase64: dataUrl.split(",")[1] ?? "",
contentType: blob.type || "audio/webm",
});
const text = result.text.trim();
if (text) onTranscribed(text);
} catch {
onError(transcribeErrorMessage);
}
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioStreamRef.current = stream;
// Live audio analyser drives the waveform animation.
const audioContext = new AudioContext();
const sourceNode = audioContext.createMediaStreamSource(stream);
const analyserNode = audioContext.createAnalyser();
analyserNode.fftSize = 256;
sourceNode.connect(analyserNode);
audioContextRef.current = audioContext;
setAnalyser(analyserNode);
const recorder = new MediaRecorder(stream);
audioChunksRef.current = [];
recordingCancelledRef.current = false;
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) audioChunksRef.current.push(event.data);
});
recorder.addEventListener("stop", () => void finishRecording());
mediaRecorderRef.current = recorder;
recorder.start();
setRecordingSeconds(0);
recordingTimerRef.current = window.setInterval(() => setRecordingSeconds((value) => value + 1), 1000);
setIsRecording(true);
} catch (error) {
// getUserMedia may have succeeded before AudioContext/MediaRecorder setup
// threw; release the mic stream and context so it isn't left running.
releaseAudioResources();
mediaRecorderRef.current = null;
setAnalyser(null);
setIsRecording(false);
// Distinguish a denied/dismissed permission prompt from other mic failures
// so the user gets an actionable message.
const denied =
error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError");
onError(denied ? micPermissionErrorMessage : micErrorMessage);
}
};
const cancelRecording = () => {
recordingCancelledRef.current = true;
mediaRecorderRef.current?.stop();
};
const confirmRecording = () => {
recordingCancelledRef.current = false;
mediaRecorderRef.current?.stop();
};
// Tear down the microphone stream and timer on unmount. Mark the session
// cancelled first so a recorder "stop" fired while stopping the tracks doesn't
// upload/transcribe partial audio after the component is gone.
useEffect(
() => () => {
recordingCancelledRef.current = true;
releaseAudioResources();
},
[],
);
return {
isRecording,
recordingSeconds,
analyser,
isTranscribing: transcribe.isPending,
startRecording,
cancelRecording,
confirmRecording,
};
}