feat: add Open WebUI AI chat integration and widget
Adds a new "openWebUi" integration kind (under a new "ai" category) and an AI Chat widget for chatting with models served by Open WebUI. - Integration: Bearer API key auth, model list, native chat history CRUD, and token-by-token streaming chat completions (SSE). - Widget: in-input model picker, streaming responses, and an in-widget history panel. Reconstructs conversations from Open WebUI's message tree with a flat fallback. - Streaming is delivered over the WebSocket subscription link. - Adds integration and widget documentation.
This commit is contained in:
1 parent
f3608b26a0
commit
005c2e4cd1
25 files changed
+1290
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: "Open WebUI"
|
||||||
|
description: "Self-hosted AI chat interface for local and remote LLM providers"
|
||||||
|
hide_title: true
|
||||||
|
---
|
||||||
|
|
||||||
|
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||||
|
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||||
|
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||||
|
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||||
|
import { openWebUiIntegration } from ".";
|
||||||
|
import { openWebUiWidget } from "../../widgets/open-webui";
|
||||||
|
|
||||||
|
<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:3000`. Homarr calls the Open WebUI API under `/api` automatically.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
<IntegrationSecrets
|
||||||
|
secrets={[
|
||||||
|
{
|
||||||
|
credentials: ["apiKey"],
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IntegrationDefinition } from "@site/src/types";
|
||||||
|
|
||||||
|
export const openWebUiIntegration: IntegrationDefinition = {
|
||||||
|
name: "Open WebUI",
|
||||||
|
description: "Self-hosted AI chat interface for local and remote LLM providers",
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
|
||||||
|
path: "../../integrations/open-webui",
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: "AI Chat"
|
||||||
|
description: "Chat with AI models through Open WebUI, with model picking and history."
|
||||||
|
hide_title: true
|
||||||
|
---
|
||||||
|
|
||||||
|
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||||
|
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||||
|
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||||
|
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||||
|
import { openWebUiWidget } from ".";
|
||||||
|
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
|
||||||
|
|
||||||
|
<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.
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { WidgetDefinition } from "@site/src/types";
|
||||||
|
import { IconRobot } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export const openWebUiWidget: WidgetDefinition = {
|
||||||
|
icon: IconRobot,
|
||||||
|
name: "AI Chat",
|
||||||
|
description: "Chat with AI models through Open WebUI, with model picking and history.",
|
||||||
|
path: "../../widgets/open-webui",
|
||||||
|
configuration: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "System prompt",
|
||||||
|
description: "Optional instruction prepended to every conversation.",
|
||||||
|
values: { type: "string" },
|
||||||
|
defaultValue: "empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Show chat history",
|
||||||
|
description: "Show a button to browse and reopen previous Open WebUI chats.",
|
||||||
|
values: { type: "boolean" },
|
||||||
|
defaultValue: "yes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||||
|
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
import {
|
||||||
|
openWebUiChatRequestHandler,
|
||||||
|
openWebUiChatsRequestHandler,
|
||||||
|
openWebUiModelsRequestHandler,
|
||||||
|
} from "@homarr/request-handler/open-webui";
|
||||||
|
|
||||||
|
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "openWebUiRouter" });
|
||||||
|
|
||||||
|
const messageSchema = z.object({
|
||||||
|
role: z.enum(["system", "user", "assistant"]),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatPayloadSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
models: z.array(z.string()),
|
||||||
|
messages: z.array(messageSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
|
||||||
|
|
||||||
|
export const openWebUiRouter = createTRPCRouter({
|
||||||
|
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||||
|
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
|
||||||
|
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getChats: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||||
|
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
|
||||||
|
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getChat: publicProcedure
|
||||||
|
.input(z.object({ chatId: z.string() }))
|
||||||
|
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
|
||||||
|
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
|
||||||
|
createChat: protectedProcedure
|
||||||
|
.input(chatPayloadSchema)
|
||||||
|
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const client = await createIntegrationAsync(ctx.integration);
|
||||||
|
const chat = await client.createChatAsync({
|
||||||
|
title: input.title,
|
||||||
|
models: input.models,
|
||||||
|
messages: input.messages,
|
||||||
|
});
|
||||||
|
await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync();
|
||||||
|
return chat;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateChat: protectedProcedure
|
||||||
|
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
|
||||||
|
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const client = await createIntegrationAsync(ctx.integration);
|
||||||
|
const chat = await client.updateChatAsync(input.chatId, input.chat);
|
||||||
|
await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync();
|
||||||
|
await openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId }).invalidateAsync();
|
||||||
|
return chat;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Streaming chat completion. Delivered over the WebSocket subscription link so
|
||||||
|
// tokens can be emitted as they arrive. Uses "query" access (same level as
|
||||||
|
// viewing the widget) to match the other integration subscriptions in the app.
|
||||||
|
sendMessage: publicProcedure
|
||||||
|
.input(z.object({ model: z.string(), messages: z.array(messageSchema) }))
|
||||||
|
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||||
|
.subscription(({ ctx, input }) => {
|
||||||
|
return observable<StreamEvent>((emit) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const client = await createIntegrationAsync(ctx.integration);
|
||||||
|
await client.streamChatCompletionAsync(
|
||||||
|
{ model: input.model, messages: input.messages },
|
||||||
|
(delta) => emit.next({ type: "delta", content: delta }),
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
emit.next({ type: "done" });
|
||||||
|
emit.complete();
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
logger.error("Open WebUI chat completion failed", {
|
||||||
|
integrationId: ctx.integration.id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
emit.next({
|
||||||
|
type: "error",
|
||||||
|
message: error instanceof Error ? error.message : "Failed to stream chat completion",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emit.complete();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -195,6 +195,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/ntfy"
|
| "/docs/integrations/ntfy"
|
||||||
| "/docs/integrations/nzbget"
|
| "/docs/integrations/nzbget"
|
||||||
| "/docs/integrations/open-media-vault"
|
| "/docs/integrations/open-media-vault"
|
||||||
|
| "/docs/integrations/open-webui"
|
||||||
| "/docs/integrations/opnsense"
|
| "/docs/integrations/opnsense"
|
||||||
| "/docs/integrations/overseerr"
|
| "/docs/integrations/overseerr"
|
||||||
| "/docs/integrations/paperless-ngx"
|
| "/docs/integrations/paperless-ngx"
|
||||||
|
|||||||
@@ -59,5 +59,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
peaNut: "peanut",
|
peaNut: "peanut",
|
||||||
beszel: "beszel",
|
beszel: "beszel",
|
||||||
archiveTeamWarrior: null,
|
archiveTeamWarrior: null,
|
||||||
|
openWebUi: "open-webui",
|
||||||
mock: null,
|
mock: null,
|
||||||
};
|
};
|
||||||
@@ -51,4 +51,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
|||||||
beszelAlerts: "beszel-alerts",
|
beszelAlerts: "beszel-alerts",
|
||||||
beszelSystemStats: "beszel-system-stats",
|
beszelSystemStats: "beszel-system-stats",
|
||||||
customApi: "custom-api",
|
customApi: "custom-api",
|
||||||
|
openWebUi: "open-webui",
|
||||||
};
|
};
|
||||||
@@ -504,6 +504,15 @@ export const integrationDefs = {
|
|||||||
defaultUrl: "http://localhost:8001",
|
defaultUrl: "http://localhost:8001",
|
||||||
documentationUrl: null,
|
documentationUrl: null,
|
||||||
},
|
},
|
||||||
|
openWebUi: {
|
||||||
|
name: "Open WebUI",
|
||||||
|
secretKinds: [["apiKey"]],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
|
||||||
|
category: ["ai"],
|
||||||
|
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
|
||||||
|
defaultPort: 8080,
|
||||||
|
apiKeySettingsPath: "/?settings=open#account",
|
||||||
|
},
|
||||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||||
mock: {
|
mock: {
|
||||||
name: "Mock",
|
name: "Mock",
|
||||||
@@ -617,6 +626,7 @@ export const integrationCategories = [
|
|||||||
"documents",
|
"documents",
|
||||||
"mediaLibrary",
|
"mediaLibrary",
|
||||||
"uptimeMonitoring",
|
"uptimeMonitoring",
|
||||||
|
"ai",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||||
@@ -40,6 +40,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
audioStats: ["navidrome", "audiobookshelf"],
|
audioStats: ["navidrome", "audiobookshelf"],
|
||||||
vpn: getIntegrationKindsByCategory("vpn"),
|
vpn: getIntegrationKindsByCategory("vpn"),
|
||||||
anchorNote: ["anchor"],
|
anchorNote: ["anchor"],
|
||||||
|
openWebUi: ["openWebUi"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const widgetKinds = [
|
|||||||
"beszelAlerts",
|
"beszelAlerts",
|
||||||
"beszelSystemStats",
|
"beszelSystemStats",
|
||||||
"customApi",
|
"customApi",
|
||||||
|
"openWebUi",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
|||||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||||
import { NPMIntegration } from "../npm/npm-integration";
|
import { NPMIntegration } from "../npm/npm-integration";
|
||||||
import { GotifyIntegration } from "../gotify/gotify-integration";
|
import { GotifyIntegration } from "../gotify/gotify-integration";
|
||||||
|
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
|
||||||
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||||
@@ -143,6 +144,7 @@ export const integrationCreators = {
|
|||||||
uptimeKuma: UptimeKumaIntegration,
|
uptimeKuma: UptimeKumaIntegration,
|
||||||
peaNut: PeaNutIntegration,
|
peaNut: PeaNutIntegration,
|
||||||
beszel: BeszelIntegration,
|
beszel: BeszelIntegration,
|
||||||
|
openWebUi: OpenWebUiIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
|
|||||||
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
|
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
|
||||||
export { UmamiIntegration } from "./umami/umami-integration";
|
export { UmamiIntegration } from "./umami/umami-integration";
|
||||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||||
|
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { IntegrationInput } from "./base/integration";
|
export type { IntegrationInput } from "./base/integration";
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { ResponseError } from "@homarr/common/server";
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
|
import type {
|
||||||
|
OpenWebUiChat,
|
||||||
|
OpenWebUiChatListItem,
|
||||||
|
OpenWebUiChatMessage,
|
||||||
|
OpenWebUiChatPayload,
|
||||||
|
OpenWebUiModel,
|
||||||
|
} from "./open-webui-types";
|
||||||
|
import {
|
||||||
|
openWebUiChatListItemSchema,
|
||||||
|
openWebUiChatSchema,
|
||||||
|
openWebUiCompletionChunkSchema,
|
||||||
|
openWebUiModelsResponseSchema,
|
||||||
|
} from "./open-webui-types";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "open-webui-integration" });
|
||||||
|
|
||||||
|
export class OpenWebUiIntegration extends Integration {
|
||||||
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
const response = await input.fetchAsync(this.url("/api/models"), {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the models served by Open WebUI (aggregates all configured providers).
|
||||||
|
*/
|
||||||
|
public async getModelsAsync(): Promise<OpenWebUiModel[]> {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/models"), {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openWebUiModelsResponseSchema.parse(await response.json()).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the user's chats (native Open WebUI history).
|
||||||
|
*/
|
||||||
|
public async listChatsAsync(): Promise<OpenWebUiChatListItem[]> {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/chats/"), {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openWebUiChatListItemSchema.array().parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single chat including its messages.
|
||||||
|
*/
|
||||||
|
public async getChatAsync(chatId: string): Promise<OpenWebUiChat> {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/chats/${chatId}`), {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openWebUiChatSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new chat in the native history.
|
||||||
|
*/
|
||||||
|
public async createChatAsync(payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/chats/new"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...this.getAuthHeaders(), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ chat: payload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openWebUiChatSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing chat in the native history.
|
||||||
|
*/
|
||||||
|
public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/chats/${chatId}`), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...this.getAuthHeaders(), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ chat: payload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openWebUiChatSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream an assistant completion token-by-token.
|
||||||
|
* Reads the OpenAI-compatible SSE response and invokes `onDelta` for each
|
||||||
|
* content delta. Resolves when the stream completes ([DONE]) or is aborted.
|
||||||
|
*/
|
||||||
|
public async streamChatCompletionAsync(
|
||||||
|
params: { model: string; messages: OpenWebUiChatMessage[] },
|
||||||
|
onDelta: (delta: string) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/chat/completions"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...this.getAuthHeaders(), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
const processLine = (line: string): boolean => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) return false;
|
||||||
|
|
||||||
|
const payload = trimmed.slice(5).trim();
|
||||||
|
if (payload === "") return false;
|
||||||
|
if (payload === "[DONE]") return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = openWebUiCompletionChunkSchema.parse(JSON.parse(payload));
|
||||||
|
const delta = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
onDelta(delta);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.debug("Failed to parse Open WebUI completion chunk", { line: trimmed });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (processLine(line)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.cancel().catch(() => {
|
||||||
|
// ignore cancellation errors during teardown
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Open WebUI API types and schemas.
|
||||||
|
// Open WebUI exposes an OpenAI-compatible API plus its own native chat-history API.
|
||||||
|
// Schemas are intentionally defensive (`.nullish()`) because the native chat
|
||||||
|
// endpoints are undocumented and vary between Open WebUI versions.
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
// GET /api/models — OpenAI-compatible. Open WebUI returns `{ data: [...] }`.
|
||||||
|
export const openWebUiModelSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().nullish(),
|
||||||
|
object: z.string().nullish(),
|
||||||
|
owned_by: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const openWebUiModelsResponseSchema = z.object({
|
||||||
|
data: z.array(openWebUiModelSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpenWebUiModel = z.infer<typeof openWebUiModelSchema>;
|
||||||
|
|
||||||
|
// POST /api/chat/completions (stream: true) — OpenAI-compatible SSE delta chunk.
|
||||||
|
export const openWebUiCompletionChunkSchema = z.object({
|
||||||
|
id: z.string().nullish(),
|
||||||
|
choices: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
delta: z
|
||||||
|
.object({
|
||||||
|
role: z.string().nullish(),
|
||||||
|
content: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
|
finish_reason: z.string().nullish(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A single chat message exchanged with the model / persisted in a chat.
|
||||||
|
export const openWebUiChatMessageSchema = z.object({
|
||||||
|
role: z.enum(["system", "user", "assistant"]),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
|
||||||
|
|
||||||
|
// GET /api/v1/chats/ — list of the user's chats (native Open WebUI shape).
|
||||||
|
export const openWebUiChatListItemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string().nullish(),
|
||||||
|
updated_at: z.number().nullish(),
|
||||||
|
created_at: z.number().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpenWebUiChatListItem = z.infer<typeof openWebUiChatListItemSchema>;
|
||||||
|
|
||||||
|
// GET /api/v1/chats/{id} — full chat.
|
||||||
|
// Two message representations exist: a flat `chat.messages` list (used by chats
|
||||||
|
// we create) and a `chat.history` tree keyed by message id with a `currentId`
|
||||||
|
// leaf (used by chats created in the Open WebUI UI). The history tree is
|
||||||
|
// authoritative when present, since `chat.messages` can be stale/partial.
|
||||||
|
export const openWebUiHistoryMessageSchema = z.object({
|
||||||
|
id: z.string().nullish(),
|
||||||
|
parentId: z.string().nullish(),
|
||||||
|
role: z.string().nullish(),
|
||||||
|
content: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const openWebUiChatSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string().nullish(),
|
||||||
|
updated_at: z.number().nullish(),
|
||||||
|
created_at: z.number().nullish(),
|
||||||
|
chat: z
|
||||||
|
.object({
|
||||||
|
title: z.string().nullish(),
|
||||||
|
models: z.array(z.string()).nullish(),
|
||||||
|
messages: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
role: z.string().nullish(),
|
||||||
|
content: z.string().nullish(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.nullish(),
|
||||||
|
history: z
|
||||||
|
.object({
|
||||||
|
currentId: z.string().nullish(),
|
||||||
|
messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
|
||||||
|
|
||||||
|
// Body used when creating/updating a chat through the native API.
|
||||||
|
export interface OpenWebUiChatPayload {
|
||||||
|
title: string;
|
||||||
|
models: string[];
|
||||||
|
messages: OpenWebUiChatMessage[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Request, Response } from "undici";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import { OpenWebUiIntegration } from "../open-webui-integration";
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||||
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEST_API_KEY = "test-api-key-12345";
|
||||||
|
const TEST_URL = "https://openwebui.example.com";
|
||||||
|
|
||||||
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||||
|
|
||||||
|
const asFetchResult = (response: Response) =>
|
||||||
|
response as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||||
|
|
||||||
|
const setupMockFetch = (responses: Record<string, Record<string, unknown> | unknown[]>) => {
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
|
||||||
|
const path = new URL(urlString).pathname;
|
||||||
|
|
||||||
|
if (path in responses) {
|
||||||
|
return Promise.resolve(
|
||||||
|
asFetchResult(
|
||||||
|
new Response(JSON.stringify(responses[path]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(asFetchResult(new Response(JSON.stringify({ error: "Not Found" }), { status: 404 })));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamFrom = (chunks: string[]) =>
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(encoder.encode(chunk));
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createIntegration = () =>
|
||||||
|
new OpenWebUiIntegration({
|
||||||
|
id: "test-open-webui",
|
||||||
|
name: "Test Open WebUI",
|
||||||
|
url: TEST_URL,
|
||||||
|
externalUrl: null,
|
||||||
|
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenWebUiIntegration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getModelsAsync", () => {
|
||||||
|
test("parses the OpenAI-compatible model list", async () => {
|
||||||
|
setupMockFetch({
|
||||||
|
"/api/models": { data: [{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createIntegration().getModelsAsync();
|
||||||
|
|
||||||
|
expect(result).toEqual([{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }]);
|
||||||
|
expect(mockFetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends the bearer token", async () => {
|
||||||
|
setupMockFetch({ "/api/models": { data: [] } });
|
||||||
|
|
||||||
|
await createIntegration().getModelsAsync();
|
||||||
|
|
||||||
|
const headers = mockFetch.mock.calls.at(0)?.[1]?.headers as Record<string, string> | undefined;
|
||||||
|
expect(headers?.Authorization).toBe(`Bearer ${TEST_API_KEY}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the API returns an error", async () => {
|
||||||
|
mockFetch.mockResolvedValue(asFetchResult(new Response("Unauthorized", { status: 401 })));
|
||||||
|
|
||||||
|
await expect(createIntegration().getModelsAsync()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("streamChatCompletionAsync", () => {
|
||||||
|
test("emits each content delta and resolves on [DONE]", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
asFetchResult(
|
||||||
|
new Response(
|
||||||
|
streamFrom([
|
||||||
|
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hel" } }] })}\n`,
|
||||||
|
`data: ${JSON.stringify({ choices: [{ delta: { content: "lo" } }] })}\n`,
|
||||||
|
"data: [DONE]\n",
|
||||||
|
]),
|
||||||
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deltas: string[] = [];
|
||||||
|
await createIntegration().streamChatCompletionAsync(
|
||||||
|
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
|
||||||
|
(delta) => deltas.push(delta),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deltas).toEqual(["Hel", "lo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles a delta split across stream chunks", async () => {
|
||||||
|
const payload = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
|
||||||
|
const half = Math.floor(payload.length / 2);
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
asFetchResult(
|
||||||
|
new Response(streamFrom([`data: ${payload.slice(0, half)}`, `${payload.slice(half)}\n`, "data: [DONE]\n"]), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deltas: string[] = [];
|
||||||
|
await createIntegration().streamChatCompletionAsync(
|
||||||
|
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
|
||||||
|
(delta) => deltas.push(delta),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deltas.join("")).toBe("world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the completion endpoint errors", async () => {
|
||||||
|
mockFetch.mockResolvedValue(asFetchResult(new Response("Bad Gateway", { status: 502 })));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createIntegration().streamChatCompletionAsync(
|
||||||
|
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
|
||||||
|
() => undefined,
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,3 +25,4 @@ export * from "./paperless-ngx/paperless-ngx-types";
|
|||||||
export * from "./umami/umami-types";
|
export * from "./umami/umami-types";
|
||||||
export * from "./gluetun/gluetun-types";
|
export * from "./gluetun/gluetun-types";
|
||||||
export * from "./beszel/beszel-types";
|
export * from "./beszel/beszel-types";
|
||||||
|
export * from "./open-webui/open-webui-types";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
import type { OpenWebUiChat, OpenWebUiChatListItem, OpenWebUiModel } from "@homarr/integrations/types";
|
||||||
|
|
||||||
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
|
export const openWebUiModelsRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
|
OpenWebUiModel[],
|
||||||
|
"openWebUi",
|
||||||
|
Record<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getModelsAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(5, "minutes"),
|
||||||
|
queryKey: "openWebUiModels",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const openWebUiChatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
|
OpenWebUiChatListItem[],
|
||||||
|
"openWebUi",
|
||||||
|
Record<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.listChatsAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(15, "seconds"),
|
||||||
|
queryKey: "openWebUiChats",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const openWebUiChatRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
|
OpenWebUiChat,
|
||||||
|
"openWebUi",
|
||||||
|
{ chatId: string }
|
||||||
|
>({
|
||||||
|
async requestAsync(integration, input) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getChatAsync(input.chatId);
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(15, "seconds"),
|
||||||
|
queryKey: "openWebUiChat",
|
||||||
|
});
|
||||||
@@ -3540,6 +3540,32 @@
|
|||||||
"executeSuccess": "Action executed successfully",
|
"executeSuccess": "Action executed successfully",
|
||||||
"executeFailed": "Action failed. Check the widget configuration."
|
"executeFailed": "Action failed. Check the widget configuration."
|
||||||
},
|
},
|
||||||
|
"openWebUi": {
|
||||||
|
"name": "AI Chat",
|
||||||
|
"description": "Chat with AI models through Open WebUI, with model picking and history.",
|
||||||
|
"option": {
|
||||||
|
"systemPrompt": {
|
||||||
|
"label": "System prompt",
|
||||||
|
"description": "Optional instruction prepended to every conversation."
|
||||||
|
},
|
||||||
|
"showHistory": {
|
||||||
|
"label": "Show chat history",
|
||||||
|
"description": "Show a button to browse and reopen previous Open WebUI chats."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectModel": "Select a model",
|
||||||
|
"noModels": "No models available",
|
||||||
|
"emptyState": "Start a conversation",
|
||||||
|
"messagePlaceholder": "Type a message…",
|
||||||
|
"newChat": "New chat",
|
||||||
|
"history": "History",
|
||||||
|
"back": "Back",
|
||||||
|
"send": "Send",
|
||||||
|
"stop": "Stop",
|
||||||
|
"error": {
|
||||||
|
"internalServerError": "Failed to reach Open WebUI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"beszelSystemTable": {
|
"beszelSystemTable": {
|
||||||
"name": "Beszel Systems (Table)",
|
"name": "Beszel Systems (Table)",
|
||||||
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
|
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import * as beszelSystemStats from "./beszel-system-stats";
|
|||||||
import * as video from "./video";
|
import * as video from "./video";
|
||||||
import * as weather from "./weather";
|
import * as weather from "./weather";
|
||||||
import * as customApi from "./custom-api";
|
import * as customApi from "./custom-api";
|
||||||
|
import * as openWebUi from "./open-webui";
|
||||||
|
|
||||||
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
||||||
export type { WidgetComponentProps };
|
export type { WidgetComponentProps };
|
||||||
@@ -116,6 +117,7 @@ export const widgetImports = {
|
|||||||
beszelAlerts,
|
beszelAlerts,
|
||||||
beszelSystemStats,
|
beszelSystemStats,
|
||||||
customApi,
|
customApi,
|
||||||
|
openWebUi,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--mantine-spacing-xs);
|
||||||
|
z-index: 3;
|
||||||
|
width: min(50vw, calc(100% - var(--mantine-spacing-md)));
|
||||||
|
margin-inline: auto;
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSelect input {
|
||||||
|
text-align: right;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyList {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
NavLink,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowUp,
|
||||||
|
IconHistory,
|
||||||
|
IconPlayerStopFilled,
|
||||||
|
IconPlus,
|
||||||
|
IconRobot,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import classes from "./chat.module.css";
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chat({ options, integrationIds, isEditMode }: WidgetComponentProps<"openWebUi">) {
|
||||||
|
const t = useI18n();
|
||||||
|
const integrationId = integrationIds.at(0);
|
||||||
|
|
||||||
|
const [model, setModel] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [streamingText, setStreamingText] = useState("");
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||||
|
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [historyOpened, setHistoryOpened] = useState(false);
|
||||||
|
|
||||||
|
const messagesRef = useRef<ChatMessage[]>([]);
|
||||||
|
const streamingTextRef = useRef("");
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [composerHeight, setComposerHeight] = useState(72);
|
||||||
|
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
const { data: models = [], isLoading: modelsLoading } = clientApi.widget.openWebUi.getModels.useQuery(
|
||||||
|
{ integrationId: integrationId ?? "" },
|
||||||
|
{ enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: chats = [] } = clientApi.widget.openWebUi.getChats.useQuery(
|
||||||
|
{ integrationId: integrationId ?? "" },
|
||||||
|
{ enabled: Boolean(integrationId) && options.showHistory, refetchOnWindowFocus: false, retry: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: loadedChat } = clientApi.widget.openWebUi.getChat.useQuery(
|
||||||
|
{ integrationId: integrationId ?? "", chatId: selectedChatId ?? "" },
|
||||||
|
{ enabled: Boolean(integrationId) && Boolean(selectedChatId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createChat = clientApi.widget.openWebUi.createChat.useMutation();
|
||||||
|
const updateChat = clientApi.widget.openWebUi.updateChat.useMutation();
|
||||||
|
|
||||||
|
// Default to the first available model once the list loads.
|
||||||
|
useEffect(() => {
|
||||||
|
if (model !== null || models.length === 0) return;
|
||||||
|
setModel(models[0]?.id ?? null);
|
||||||
|
}, [model, models]);
|
||||||
|
|
||||||
|
// Hydrate the conversation when a history chat finishes loading.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadedChat) return;
|
||||||
|
const loadedMessages = extractChatMessages(loadedChat.chat);
|
||||||
|
setMessages(loadedMessages);
|
||||||
|
messagesRef.current = loadedMessages;
|
||||||
|
setActiveChatId(loadedChat.id);
|
||||||
|
if (loadedChat.chat?.models?.[0]) {
|
||||||
|
setModel(loadedChat.chat.models[0]);
|
||||||
|
}
|
||||||
|
setHistoryOpened(false);
|
||||||
|
}, [loadedChat]);
|
||||||
|
|
||||||
|
// Track the floating composer's height so messages can pad below it.
|
||||||
|
useEffect(() => {
|
||||||
|
const element = composerRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const observer = new ResizeObserver(() => setComposerHeight(element.offsetHeight));
|
||||||
|
observer.observe(element);
|
||||||
|
setComposerHeight(element.offsetHeight);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll to the bottom as messages or streamed tokens arrive.
|
||||||
|
useEffect(() => {
|
||||||
|
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
|
||||||
|
}, [messages, streamingText, composerHeight]);
|
||||||
|
|
||||||
|
clientApi.widget.openWebUi.sendMessage.useSubscription(
|
||||||
|
{ integrationId: integrationId ?? "", model: model ?? "", messages: pendingMessages },
|
||||||
|
{
|
||||||
|
enabled: isStreaming && pendingMessages.length > 0,
|
||||||
|
onData: (event) => {
|
||||||
|
if (event.type === "delta") {
|
||||||
|
streamingTextRef.current += event.content;
|
||||||
|
setStreamingText(streamingTextRef.current);
|
||||||
|
} else if (event.type === "done") {
|
||||||
|
finalizeAssistantMessage();
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
setError(event.message);
|
||||||
|
finalizeAssistantMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message);
|
||||||
|
finalizeAssistantMessage();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalizeAssistantMessage = () => {
|
||||||
|
const finalText = streamingTextRef.current;
|
||||||
|
if (finalText.length > 0) {
|
||||||
|
const finalMessages = [...messagesRef.current, { role: "assistant" as const, content: finalText }];
|
||||||
|
setMessages(finalMessages);
|
||||||
|
messagesRef.current = finalMessages;
|
||||||
|
void persistChat(finalMessages);
|
||||||
|
}
|
||||||
|
streamingTextRef.current = "";
|
||||||
|
setStreamingText("");
|
||||||
|
setIsStreaming(false);
|
||||||
|
setPendingMessages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistChat = async (finalMessages: ChatMessage[]) => {
|
||||||
|
if (!integrationId || !model) return;
|
||||||
|
const title = finalMessages.find((message) => message.role === "user")?.content.slice(0, 80) ?? "New chat";
|
||||||
|
const payload = { title, models: [model], messages: finalMessages };
|
||||||
|
try {
|
||||||
|
if (activeChatId) {
|
||||||
|
await updateChat.mutateAsync({ integrationId, chatId: activeChatId, chat: payload });
|
||||||
|
} else {
|
||||||
|
const chat = await createChat.mutateAsync({ integrationId, ...payload });
|
||||||
|
setActiveChatId(chat.id);
|
||||||
|
}
|
||||||
|
if (options.showHistory) {
|
||||||
|
await utils.widget.openWebUi.getChats.invalidate({ integrationId });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Persistence is best-effort; the conversation remains usable in-memory.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed || !model || !integrationId || isStreaming) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const userMessage: ChatMessage = { role: "user", content: trimmed };
|
||||||
|
const nextMessages = [...messagesRef.current, userMessage];
|
||||||
|
setMessages(nextMessages);
|
||||||
|
messagesRef.current = nextMessages;
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
const systemPrompt = options.systemPrompt.trim();
|
||||||
|
const payloadMessages: ChatMessage[] = systemPrompt
|
||||||
|
? [{ role: "system", content: systemPrompt }, ...nextMessages]
|
||||||
|
: nextMessages;
|
||||||
|
|
||||||
|
streamingTextRef.current = "";
|
||||||
|
setStreamingText("");
|
||||||
|
setPendingMessages(payloadMessages);
|
||||||
|
setIsStreaming(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewChat = () => {
|
||||||
|
setMessages([]);
|
||||||
|
messagesRef.current = [];
|
||||||
|
setActiveChatId(null);
|
||||||
|
setSelectedChatId(null);
|
||||||
|
setError(null);
|
||||||
|
streamingTextRef.current = "";
|
||||||
|
setStreamingText("");
|
||||||
|
setIsStreaming(false);
|
||||||
|
setPendingMessages([]);
|
||||||
|
setHistoryOpened(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelData = useMemo(() => models.map((m) => ({ value: m.id, label: m.name ?? m.id })), [models]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.root} style={isEditMode ? { pointerEvents: "none" } : undefined}>
|
||||||
|
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
|
||||||
|
<Stack gap="xs" p="xs" pb={composerHeight + 24}>
|
||||||
|
{messages.length === 0 && !isStreaming ? (
|
||||||
|
<Stack align="center" justify="center" gap="xs" mt="xl" c="dimmed">
|
||||||
|
<IconRobot size={32} />
|
||||||
|
<Text size="sm">{t("widget.openWebUi.emptyState")}</Text>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<MessageBubble key={index} from={message.role} content={message.content} />
|
||||||
|
))}
|
||||||
|
{isStreaming ? <MessageBubble from="assistant" content={streamingText || "…"} /> : null}
|
||||||
|
{error ? (
|
||||||
|
<Text size="xs" c="red.6">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Paper ref={composerRef} className={classes.composer} withBorder radius="md" p="xs">
|
||||||
|
<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">
|
||||||
|
<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 gap="xs" wrap="nowrap">
|
||||||
|
<Select
|
||||||
|
variant="unstyled"
|
||||||
|
size="sm"
|
||||||
|
searchable
|
||||||
|
data={modelData}
|
||||||
|
value={model}
|
||||||
|
onChange={setModel}
|
||||||
|
placeholder={t("widget.openWebUi.selectModel")}
|
||||||
|
nothingFoundMessage={t("widget.openWebUi.noModels")}
|
||||||
|
rightSection={modelsLoading ? <Loader size="xs" /> : undefined}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
|
maw={160}
|
||||||
|
className={classes.modelSelect}
|
||||||
|
/>
|
||||||
|
{isStreaming ? (
|
||||||
|
<Tooltip label={t("widget.openWebUi.stop")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
radius="xl"
|
||||||
|
color="red"
|
||||||
|
variant="filled"
|
||||||
|
onClick={finalizeAssistantMessage}
|
||||||
|
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={!input.trim() || !model}
|
||||||
|
aria-label={t("widget.openWebUi.send")}
|
||||||
|
>
|
||||||
|
<IconArrowUp size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{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 === activeChatId}
|
||||||
|
label={chat.title ?? chat.id}
|
||||||
|
onClick={() => setSelectedChatId(chat.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawMessage = { role?: string | null; content?: string | null };
|
||||||
|
type HistoryMessage = RawMessage & { parentId?: string | null };
|
||||||
|
interface RawChat {
|
||||||
|
messages?: RawMessage[] | null;
|
||||||
|
history?: { currentId?: string | null; messages?: Record<string, HistoryMessage> | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toChatMessage = (role: string | null | undefined, content: string | null | undefined): ChatMessage | null =>
|
||||||
|
(role === "system" || role === "user" || role === "assistant") && typeof content === "string"
|
||||||
|
? { role, content }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs the ordered conversation from an Open WebUI chat. Chats created in
|
||||||
|
* the Open WebUI UI store the active branch as a tree under `chat.history`
|
||||||
|
* (walk from `currentId` up through `parentId`); `chat.messages` can be stale, so
|
||||||
|
* the history tree takes precedence and we fall back to the flat list.
|
||||||
|
*/
|
||||||
|
const extractChatMessages = (chat: RawChat | null | undefined): ChatMessage[] => {
|
||||||
|
const history = chat?.history;
|
||||||
|
if (history?.messages && history.currentId) {
|
||||||
|
const map = history.messages;
|
||||||
|
const ordered: ChatMessage[] = [];
|
||||||
|
const seen = new Set<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);
|
||||||
|
};
|
||||||
|
|
||||||
|
function MessageBubble({ from, content }: { from: ChatMessage["role"]; content: string }) {
|
||||||
|
const isUser = from === "user";
|
||||||
|
const avatar = (
|
||||||
|
<Avatar size={22} radius="xl" variant="filled" color={isUser ? "gray" : "blue"} className={classes.avatar}>
|
||||||
|
{isUser ? <IconUser size={14} /> : <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"
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
{isUser ? avatar : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
const Chat = dynamic(() => import("./chat").then((module) => module.Chat), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function OpenWebUiWidget(props: WidgetComponentProps<"openWebUi">) {
|
||||||
|
return <Chat {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { IconRobot, IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const widgetKind = "openWebUi";
|
||||||
|
|
||||||
|
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||||
|
icon: IconRobot,
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from((factory) => ({
|
||||||
|
systemPrompt: factory.text({
|
||||||
|
defaultValue: "",
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showHistory: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
supportedIntegrations: ["openWebUi"],
|
||||||
|
integrationsRequired: true,
|
||||||
|
maxIntegrations: 1,
|
||||||
|
errors: {
|
||||||
|
INTERNAL_SERVER_ERROR: {
|
||||||
|
icon: IconServerOff,
|
||||||
|
message: (t) => t("widget.openWebUi.error.internalServerError"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
Reference in new issue
Block a user