Compare commits

..
Author SHA1 Message Date
Thomas 9f8cc19a9e Merge branch 'dev' into feat/typescript-7 2026-07-12 15:32:58 +02:00
Thomas 03f68a0421 fix(qbittorrent): sort torrents by progress so active items appear before limit cutoff (#6297) 2026-07-12 14:56:24 +02:00
Bergasha 807e5eb2a9 feat(docker-widget): replace footer timestamp text with compact refresh button (#6307) 2026-07-12 14:51:56 +02:00
Thomas 8e8c29979f fix(system-disks): format absolute storage values with shared byte helper (#6299) 2026-07-12 14:50:58 +02:00
Manuel 7d859c1af0 docs: update pr template (#6147) 2026-07-12 14:49:56 +02:00
homarr-renovate[bot] e9543dc30d chore(deps): update tiptap monorepo to v3.27.3 (#6308)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 09:12:55 +00:00
Nerdy Technician 9f313440ac fix(integrations): update PatchMon API key settings link (#6302) 2026-07-12 08:59:52 +02:00
homarr-renovate[bot] c2508dffea chore(deps): update dependency posthog-js to ^1.396.7 (#6285)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 06:18:17 +00:00
Artur d44bc06a93 feat(notebook): support text direction controls (#6293) 2026-07-12 07:58:02 +02:00
homarr-renovate[bot] c56b91570b chore(deps): update tiptap monorepo to v3.27.2 (#6292)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 03:01:23 +00:00
homarr-renovate[bot] d53c4e6513 chore(deps): update dependency hono@<4.6.5 to >=4.12.28 (#6291)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 02:58:51 +00:00
homarr-renovate[bot] 0426d600cc chore(deps): update dependency oxfmt to ^0.58.0 (#6305)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 00:06:59 +00:00
Thomas Camlong 2faa0f018a chore: upgrade typescript from v6 to v7 (tsgo native port)
TypeScript 7 is a native Go port (tsgo) that delivers 8-12x faster
builds and a rebuilt LSP. No tsconfig changes needed — the codebase
was already compatible with all TS7 defaults and deprecations.
2026-07-11 21:41:00 +02:00
89 changed files with 1463 additions and 12808 deletions

No files matched your search

+8
View File
@@ -11,3 +11,11 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
@@ -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",
};
+40 -20
View File
@@ -1,29 +1,25 @@
---
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
title: "Notebook"
description: "A simple notebook widget that supports markdown."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from '.';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
<WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features.
### Screenshots
import editOff from './img/normal.png';
import editOn from './img/edit.png';
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variaty of functions to edit the text and more.
The notebook offers a variety of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold |
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp)
@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls
![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table.
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
- Delete Column
- Add row on top
- Add row below
- Delete Row
- Delete Row
@@ -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,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
{formatBytes(file.size)}
</Text>
</Group>
<Button
-2
View File
@@ -32,7 +32,6 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session";
@@ -148,7 +147,6 @@ export default async function Layout(props: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<OpenWebUiChatProvider />
{props.children}
</StackedProvider>
</body>
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -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,
IconLogin,
IconLogout,
IconRobot,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { openOpenWebUiChat } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -49,15 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session.data?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
);
const handleSignout = useCallback(async () => {
await signOut({
redirect: false,
@@ -108,11 +96,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
{t("docker")}
</Menu.Item>
)}
{hasOpenWebUiIntegration && (
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
{t("aiChat")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
@@ -99,7 +99,6 @@ export const integrationRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
url: integration.url,
creatorId: integration.creatorId,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -345,67 +344,24 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
const userId = ctx.session.user.id;
const secretValues = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
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", {
id: integrationId,
name: input.name,
@@ -4,7 +4,6 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -302,10 +301,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -322,23 +317,10 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -354,10 +336,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
@@ -379,7 +357,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -397,10 +374,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -432,7 +405,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -459,10 +431,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -487,7 +455,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
-1
View File
@@ -43,6 +43,5 @@ export const widgetRouter = createTRPCRouter({
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -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;
url: string;
kind: IntegrationKind;
creatorId: string | null;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -15,7 +15,6 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true,
url: true,
kind: true,
creatorId: true,
},
with: {
userPermissions: {
+104 -5
View File
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
};
/**
* Number of bytes to si format. (Division by 1024)
* Does not accept floats, size in bytes should be an integer.
* Will return "NaI" and logs a warning if a float is passed.
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
* Returns "∞" if the size is too large to be represented in the current format.
* Number of bytes to a human-readable string using binary (KiB) suffixes.
* Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/
export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
return "∞";
};
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
*/
unit?: ByteUnitSystem;
}
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
let index = 0;
let value = bytes;
while (value >= base && index < lastIndex) {
value /= base;
index++;
}
return index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
};
};
/**
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
*/
export const formatBytesPair = (
used: number,
available: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
};
};
/**
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
* string with a `/s` suffix. The value is formatted with the same rules as
* `formatBytes` and a trailing `/s` is appended.
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
});
it("returns the zero value for negative or non-finite input", () => {
expect(formatBytes(-1)).toBe("0.0 B");
expect(formatBytes(Number.NaN)).toBe("0.0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
});
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
});
it("returns zero for invalid input", () => {
expect(formatByteRate(-1)).toBe("0.0 B/s");
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
@@ -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,
"tag": "0042_add_widget_secret",
"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,
"tag": "0008_add_widget_secret",
"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,
"tag": "0040_add_widget_secret",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786326756,
"tag": "0041_add_integration_creator_id",
"breakpoints": true
}
]
}
-5
View File
@@ -209,7 +209,6 @@ export const integrations = mysqlTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -689,10 +688,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
-5
View File
@@ -208,7 +208,6 @@ export const integrations = pgTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -688,10 +687,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
-5
View File
@@ -195,7 +195,6 @@ export const integrations = sqliteTable(
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -678,10 +677,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
@@ -329,7 +329,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/open-webui"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -382,5 +381,4 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api"
| "/docs/widgets/open-webui";
| "/docs/widgets/custom-api";
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
openWebUi: "open-webui",
mock: null,
};
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
openWebUi: "open-webui",
};
-1
View File
@@ -3,5 +3,4 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
};
+1 -11
View File
@@ -379,7 +379,7 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys",
apiKeySettingsPath: "/settings/integrations",
},
tracearr: {
name: "Tracearr",
@@ -469,15 +469,6 @@ export const integrationDefs = {
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
openWebUi: {
name: "Open WebUI",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
category: ["ai"],
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
defaultPort: 8080,
apiKeySettingsPath: "/?settings=open#account",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -592,7 +583,6 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"ai",
"subtitleManager",
"reverseProxy",
] 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", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
});
@@ -43,7 +43,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
openWebUi: ["openWebUi"],
traefik: ["traefik"],
};
-1
View File
@@ -53,7 +53,6 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"openWebUi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -32,7 +32,6 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
@@ -132,7 +131,6 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
openWebUi: OpenWebUiIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
@@ -1,5 +1,3 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!),
used: `${storageLoad[index]!}`,
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit });
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
-1
View File
@@ -42,7 +42,6 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
@@ -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 "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./open-webui/open-webui-types";
export * from "./traefik/traefik-types";
@@ -1,7 +1,6 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
-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";
import { mediaRequestSearchEvent, openWebUiChatEvent, spotlightActions } from "./spotlight-store";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch, openOpenWebUiChat };
export { openSpotlight, openMediaRequestSearch };
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
@@ -25,14 +25,4 @@ const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) =>
spotlightActions.open();
};
export { mediaRequestSearchEvent, openWebUiChatEvent };
// Opens the Open WebUI chat side panel, optionally seeding it with a query to
// start a conversation from. Closes the spotlight so the panel takes over.
const openOpenWebUiChat = (query?: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
spotlightActions.close();
};
export { mediaRequestSearchEvent };
@@ -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,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
@@ -18,7 +17,6 @@ import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
import { askAiMode } from "../ask-ai";
import { commandMode } from "../command";
import { externalMode } from "../external";
import { mediaMode } from "../media";
@@ -42,18 +40,6 @@ export const useHomeEmptyGroups = () => {
visibleSearchModes.unshift(userGroupMode);
}
// Only surface the "Ask AI" mode when the user owns an Open WebUI integration.
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
if (
integrations.some((integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id)
) {
visibleSearchModes.push(askAiMode);
}
return [
createGroup({
keyPath: "key",
-2
View File
@@ -1,5 +1,4 @@
import { appIntegrationBoardMode } from "./app-integration-board";
import { askAiMode } from "./ask-ai";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
@@ -14,6 +13,5 @@ export const searchModes = [
commandMode,
pageMode,
mediaMode,
askAiMode,
homeMode,
] as const;
@@ -14,10 +14,6 @@ export interface MediaRequestSearchScope {
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
// Dispatched to open the Open WebUI chat side panel (optionally with a query to
// start a conversation from). The app-level provider listens for this.
export const openWebUiChatEvent = "homarr:spotlight:open-web-ui-chat";
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
};
+5 -66
View File
@@ -1489,7 +1489,6 @@
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "Docker",
"aiChat": "AI Chat",
"preferences": "Your preferences",
"logout": "Logout",
"login": "Login",
@@ -1957,6 +1956,8 @@
"clear": "Clear formatting",
"heading": "Heading {level}",
"align": "Align text: {position}",
"directionLtr": "Set text direction left to right",
"directionRtl": "Set text direction right to left",
"blockquote": "Blockquote",
"horizontalLine": "Horizontal line",
"bulletList": "Bullet list",
@@ -3849,61 +3850,6 @@
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration."
},
"openWebUi": {
"name": "AI Chat",
"description": "Chat with AI models through Open WebUI, with model picking and history.",
"option": {
"systemPrompt": {
"label": "System prompt",
"description": "Optional instruction prepended to every conversation."
},
"showHistory": {
"label": "Show chat history",
"description": "Show a button to browse and reopen previous Open WebUI chats."
}
},
"selectModel": "Select a model",
"searchModel": "Search models…",
"noModels": "No models available",
"emptyState": "Start a conversation",
"messagePlaceholder": "Type a message…",
"newChat": "New chat",
"history": "History",
"back": "Back",
"send": "Send",
"stop": "Stop",
"retry": "Retry",
"visionWarning": "This model may not support images",
"attach": "Attach",
"uploadFiles": "Upload Files",
"capture": "Capture",
"attachImage": "Attach image",
"attachKnowledge": "Knowledge",
"noKnowledge": "No knowledge bases in Open WebUI",
"attachWebpage": "Webpage",
"attachFiles": "Files",
"attachNotes": "Notes",
"referenceChats": "Reference Chats",
"noFiles": "No files in Open WebUI",
"noNotes": "No notes in Open WebUI",
"noChats": "No previous chats",
"addUrl": "Add URL",
"removeAttachment": "Remove attachment",
"webError": "Could not load that web page",
"uploadError": "Could not upload the file",
"captureError": "Could not access the camera",
"chatError": "Could not load that chat",
"noteError": "Could not load that note",
"record": "Record voice message",
"stopRecording": "Stop recording",
"cancelRecording": "Cancel recording",
"micError": "Could not access the microphone",
"micPermissionError": "Microphone access was denied. Allow it in your browser to record voice messages.",
"transcribeError": "Could not transcribe the recording",
"error": {
"internalServerError": "Failed to reach Open WebUI"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -5259,6 +5205,9 @@
"title": "Containers",
"table": {
"updated": "Updated {when}",
"refresh": {
"lastUpdated": "Last refresh {when}"
},
"search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers",
@@ -5789,16 +5738,6 @@
"fetch": "An error occurred while fetching data"
},
"mode": {
"askAi": {
"help": "Start an AI chat",
"title": "AI Chat",
"group": {
"ask": {
"startWithQuery": "Ask AI: {query}",
"hint": "Start an AI chat"
}
}
},
"appIntegrationBoard": {
"help": "Search for apps, integrations or boards",
"group": {
+1 -2
View File
@@ -20,8 +20,7 @@
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts",
"./_open-webui/chat": "./src/open-webui/chat.tsx"
"./prefetch": "./src/prefetch.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -3,7 +3,7 @@
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition";
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
</Stack>
);
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
+23 -8
View File
@@ -3,13 +3,13 @@
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -110,7 +110,7 @@ const createColumns = (
return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -184,7 +184,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
const {
data,
refetch,
isFetching,
} = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000,
});
const containers = data?.containers ?? [];
@@ -297,12 +301,23 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.updated", { when: relativeTime })}
</Text>
<Tooltip
label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<ActionIcon
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
)}
+1
View File
@@ -25,6 +25,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]],
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
+16 -19
View File
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize, useIntegrationConnected } from "@homarr/common";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null;
},
},
{
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
...columnsDefBase({ key: "received", showHeader: true }),
Cell: ({ cell }) => {
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
return <Text size="xs">{humanFileSize(received)}</Text>;
return <Text size="xs">{formatBytes(received)}</Text>;
},
},
{
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
return sent && <Text size="xs">{formatBytes(sent)}</Text>;
},
},
{
...columnsDefBase({ key: "size", showHeader: true }),
Cell: ({ cell }) => {
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
return <Text size="xs">{humanFileSize(size)}</Text>;
return <Text size="xs">{formatBytes(size)}</Text>;
},
},
{
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>;
},
},
],
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
{item.type !== "miscellaneous" && (
<NormalizedLine
itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)}
/>
)}
<NormalizedLine
itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)}
/>
{item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} />
)}
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
<NormalizedLine itemKey="received" values={formatBytes(item.received)} />
<NormalizedLine itemKey="size" values={formatBytes(item.size)} />
<NormalizedLine
itemKey="progress"
values={new Intl.NumberFormat("en", {
@@ -744,10 +744,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{ paused: [] as string[], active: [] as string[] },
);
const someInteract = clients.some(({ interact }) => interact);
const totalSpeed = humanFileSize(
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
"/s",
);
const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0));
const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
@@ -849,21 +846,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{client.status.rates.up !== undefined ? (
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
<Text flex={1} ta="right">
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
{`↑ ${formatByteRate(client.status.rates.up)}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(client.status.totalUp ?? 0)}
{formatBytes(client.status.totalUp ?? 0)}
</Text>
</Group>
) : undefined}
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
<Text flex={1} ta="right">
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
{`↓ ${formatByteRate(client.status.rates.down)}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
{formatBytes(Math.floor(client.status.totalDown ?? 0))}
</Text>
</Group>
</Stack>
@@ -16,7 +16,7 @@ import {
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { capitalize, humanFileSize } from "@homarr/common";
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
@@ -93,16 +93,18 @@ const RightSection = ({ label, value }: RightSectionProps) => {
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const memory = formatBytesPair(item.memory.used, item.memory.total);
const storage = formatBytesPair(item.storage.used, item.storage.total);
return (
<List>
<List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores}
</List.Item>
<List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
{t("memory")} - {memory.used} / {memory.available}
</List.Item>
<List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
{t("storage")} - {storage.used} / {storage.available}
</List.Item>
<List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
@@ -121,6 +123,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
const storage = formatBytesPair(item.used, item.total);
return (
<Stack gap={0}>
<Center>
@@ -133,7 +136,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
/>
<Group align="center" gap={0}>
<Text>
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
{t("storage")} - {storage.used} / {storage.available}
</Text>
</Group>
</Center>
@@ -152,11 +155,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.storage.write)}</Text>
<Text>{formatBytes(item.storage.write)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.storage.read)}</Text>
<Text>{formatBytes(item.storage.read)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
@@ -172,11 +175,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.network.in)}</Text>
<Text>{formatBytes(item.network.in)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.network.out)}</Text>
<Text>{formatBytes(item.network.out)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
const formatFileSize = (value: string) => {
const bytes = Number(value);
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value;
};
interface FileSystem {
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
<StatItem
icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")}
value={humanFileSize(stats.totalLibraryUsageInBytes)}
value={formatBytes(stats.totalLibraryUsageInBytes)}
/>
)}
</Stack>
-2
View File
@@ -64,7 +64,6 @@ import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
import * as openWebUi from "./open-webui";
export type {
WidgetDefinition,
@@ -130,7 +129,6 @@ export const widgetImports = {
beszelSystemStats,
traefik,
customApi,
openWebUi,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
@@ -14,7 +14,7 @@ import {
} from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TdarrQueue } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
</Group>
</TableTd>
<TableTd py={2}>
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
<Text size="xs">{formatBytes(item.fileSize)}</Text>
</TableTd>
</TableTr>
))}
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
<StatisticItem
icon={IconDatabaseHeart}
label={t("savedSpace")}
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
value={formatBytes(Math.floor(allLibs.totalSavedSpace))}
/>
</Group>
<Group justify="center" wrap="wrap" grow>
@@ -39,6 +39,8 @@ import {
IconRowRemove,
IconTableOff,
IconTablePlus,
IconTextDirectionLtr,
IconTextDirectionRtl,
IconX,
} from "@tabler/icons-react";
import { Color } from "@tiptap/extension-color";
@@ -66,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
import "./notebook.css";
@@ -171,6 +174,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
}),
TaskList.configure({ itemTypeName: "taskItem" }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
NotebookTextDirection,
TextStyle,
],
shouldRerenderOnTransaction: true,
@@ -347,6 +351,22 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
position: t("widget.notebook.align.right"),
})}
/>
<RichTextEditor.Control
title={tControls("directionLtr")}
aria-label={tControls("directionLtr")}
active={editor?.isActive({ dir: "ltr" })}
onClick={() => editor && setTextDirection(editor, "ltr").run()}
>
<IconTextDirectionLtr {...controlIconProps} />
</RichTextEditor.Control>
<RichTextEditor.Control
title={tControls("directionRtl")}
aria-label={tControls("directionRtl")}
active={editor?.isActive({ dir: "rtl" })}
onClick={() => editor && setTextDirection(editor, "rtl").run()}
>
<IconTextDirectionRtl {...controlIconProps} />
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
@@ -0,0 +1,34 @@
import { Editor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
describe("notebook text direction", () => {
it("persists right-to-left direction in the notebook HTML", () => {
const editor = new Editor({
content: "<p>יש לי 16GB</p>",
extensions: [StarterKit, NotebookTextDirection],
});
setTextDirection(editor, "rtl").run();
expect(editor.getHTML()).toBe('<p dir="rtl">יש לי 16GB</p>');
editor.destroy();
});
it("reads and changes an existing direction attribute", () => {
const editor = new Editor({
content: '<h2 dir="rtl">Heading</h2>',
extensions: [StarterKit, NotebookTextDirection],
});
expect(editor.getHTML()).toBe('<h2 dir="rtl">Heading</h2>');
editor.commands.setTextSelection(1);
setTextDirection(editor, "ltr").run();
expect(editor.getHTML()).toContain('<h2 dir="ltr">Heading</h2>');
editor.destroy();
});
});
@@ -0,0 +1,28 @@
import { Extension } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
export type TextDirection = "ltr" | "rtl";
export const NotebookTextDirection = Extension.create({
name: "notebookTextDirection",
addGlobalAttributes() {
return [
{
types: ["heading", "paragraph"],
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir"),
renderHTML: ({ dir }: { dir?: TextDirection }) => (dir ? { dir } : {}),
},
},
},
];
},
});
export function setTextDirection(editor: Editor, direction: TextDirection) {
const nodeType = editor.isActive("heading") ? "heading" : "paragraph";
return editor.chain().focus().updateAttributes(nodeType, { dir: direction });
}
@@ -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,
};
}
@@ -5,7 +5,7 @@ import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantin
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
@@ -20,11 +20,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
case "percentage":
return `${Math.round(item.percentage)}%`;
case "absolute": {
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available);
const availableText = Number.isFinite(availableInBytes)
? humanFileSize(Math.round(availableInBytes))
: item.available;
return `${item.used} / ${availableText}`;
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
return `${used} / ${available}`;
}
return `${item.used} / ${item.available}`;
}
case "free":
return `${Math.round(100 - item.percentage)}% free`;
@@ -130,16 +132,8 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
throw new NoIntegrationDataError();
}
const fileSystem = filterStorageVolumes(
rawFileSystem,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
if (fileSystem.length === 0) return <WidgetEmptyState />;
@@ -1,7 +1,7 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -47,7 +47,7 @@ export const CombinedNetworkTrafficChart = ({
{payloadData.value === undefined ? (
<>N/A</>
) : (
<>{humanFileSize(Math.round(Number(payloadData.value)))}/s</>
formatByteRate(Math.round(Number(payloadData.value)))
)}
</Text>
</Group>
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytesPair } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -41,11 +41,11 @@ export const SystemResourceMemoryChart = ({
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} (
{Math.round((value / totalCapacityInBytes) * 100)}%)
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
</Text>
</Paper>
);
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -32,7 +32,7 @@ export const NetworkTrafficChart = ({
title={isUp ? t("up") : t("down")}
icon={isUp ? IconArrowUp : IconArrowDown}
yAxisProps={{ domain: [0, upperBound] }}
lastValue={`${humanFileSize(Math.round(max))}/s`}
lastValue={formatByteRate(Math.round(max))}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
tooltipProps={{
@@ -41,7 +41,7 @@ export const NetworkTrafficChart = ({
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))}/s
{formatByteRate(Math.round(value))}
</Text>
</Paper>
);
+999 -772
View File
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -53,22 +53,22 @@ catalog:
"@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4
"@tiptap/extension-color": 3.27.1
"@tiptap/extension-highlight": 3.27.1
"@tiptap/extension-image": 3.27.1
"@tiptap/extension-link": ^3.27.1
"@tiptap/extension-placeholder": ^3.27.1
"@tiptap/extension-table": 3.27.1
"@tiptap/extension-table-cell": 3.27.1
"@tiptap/extension-table-header": 3.27.1
"@tiptap/extension-table-row": 3.27.1
"@tiptap/extension-task-item": 3.27.1
"@tiptap/extension-task-list": 3.27.1
"@tiptap/extension-text-align": 3.27.1
"@tiptap/extension-text-style": 3.27.1
"@tiptap/extension-underline": 3.27.1
"@tiptap/react": ^3.27.1
"@tiptap/starter-kit": ^3.27.1
"@tiptap/extension-color": 3.27.3
"@tiptap/extension-highlight": 3.27.3
"@tiptap/extension-image": 3.27.3
"@tiptap/extension-link": ^3.27.3
"@tiptap/extension-placeholder": ^3.27.3
"@tiptap/extension-table": 3.27.3
"@tiptap/extension-table-cell": 3.27.3
"@tiptap/extension-table-header": 3.27.3
"@tiptap/extension-table-row": 3.27.3
"@tiptap/extension-task-item": 3.27.3
"@tiptap/extension-task-list": 3.27.3
"@tiptap/extension-text-align": 3.27.3
"@tiptap/extension-text-style": 3.27.3
"@tiptap/extension-underline": 3.27.3
"@tiptap/react": ^3.27.3
"@tiptap/starter-kit": ^3.27.3
"@trpc/client": ^11.18.0
"@trpc/next": ^11.18.0
"@trpc/react-query": ^11.18.0
@@ -93,7 +93,7 @@ catalog:
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.396.4
posthog-js: ^1.398.0
posthog-node: ^5.39.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
@@ -151,7 +151,7 @@ catalog:
next-intl: 4.13.1
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.57.0
oxfmt: ^0.58.0
oxlint: ^1.72.0
pg: ^8.22.0
postcss-preset-mantine: ^1.18.0
@@ -177,7 +177,7 @@ catalog:
tsdav: ^2.3.0
tsx: 4.20.4
turbo: ^2.10.2
typescript: ^6.0.3
typescript: ^7.0.2
vite: ^8.1.2
undici: 7.28.0
use-deep-compare-effect: ^1.8.1
@@ -218,7 +218,7 @@ overrides:
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
"esbuild@<=0.24.2": ">=0.28.1"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.27"
"hono@<4.6.5": ">=4.12.28"
"linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
"prismjs@<1.30.0": ">=1.30.0"