diff --git a/apps/docs/docs/integrations/open-webui/index.mdx b/apps/docs/docs/integrations/open-webui/index.mdx
index 1594a3a6d..514927ede 100644
--- a/apps/docs/docs/integrations/open-webui/index.mdx
+++ b/apps/docs/docs/integrations/open-webui/index.mdx
@@ -24,7 +24,7 @@ import { openWebUiWidget } from "../../widgets/open-webui";
:::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.
+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
diff --git a/packages/api/src/router/widgets/open-webui.ts b/packages/api/src/router/widgets/open-webui.ts
index fe2c22b69..b5f48c988 100644
--- a/packages/api/src/router/widgets/open-webui.ts
+++ b/packages/api/src/router/widgets/open-webui.ts
@@ -38,6 +38,47 @@ const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: s
}
};
+// 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);
+
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
@@ -123,12 +164,6 @@ const groundMessagesAsync = async (
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "List the available Open WebUI chat models for the selected integration.",
- },
- })
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
@@ -140,12 +175,6 @@ export const openWebUiRouter = createTRPCRouter({
// 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
- .meta({
- mcp: {
- enabled: true,
- description: "List the saved Open WebUI chat conversations for the selected integration.",
- },
- })
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -158,12 +187,6 @@ export const openWebUiRouter = createTRPCRouter({
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "List the Open WebUI knowledge bases available for the selected integration.",
- },
- })
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -173,12 +196,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getFiles: protectedProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "List the files stored in Open WebUI for the selected integration.",
- },
- })
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -188,12 +205,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getNotes: protectedProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "List the notes stored in Open WebUI for the selected integration.",
- },
- })
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -203,12 +214,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getNote: protectedProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "Get the full contents of a single Open WebUI note by id.",
- },
- })
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
@@ -219,12 +224,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getKnowledgeFiles: protectedProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "List the files belonging to an Open WebUI knowledge base.",
- },
- })
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
@@ -239,13 +238,7 @@ export const openWebUiRouter = createTRPCRouter({
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
- .meta({
- mcp: {
- enabled: true,
- description: "Ingest a web page into Open WebUI so its content can ground later chat messages.",
- },
- })
- .input(z.object({ url: z.string().url() }))
+ .input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -256,7 +249,9 @@ export const openWebUiRouter = createTRPCRouter({
// 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: z.string(), contentBase64: z.string(), contentType: z.string() }))
+ .input(
+ z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
+ )
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -266,7 +261,9 @@ export const openWebUiRouter = createTRPCRouter({
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
- .input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
+ .input(
+ z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
+ )
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
diff --git a/packages/integrations/src/open-webui/open-webui-types.ts b/packages/integrations/src/open-webui/open-webui-types.ts
index 77040ea37..d209dfb83 100644
--- a/packages/integrations/src/open-webui/open-webui-types.ts
+++ b/packages/integrations/src/open-webui/open-webui-types.ts
@@ -47,25 +47,30 @@ export const openWebUiCompletionChunkSchema = z.object({
.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;
-
-// Multimodal content part (OpenAI vision format). Used only for outbound
-// completion requests so users can attach images to a message.
+// 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;
+
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
- content: z.union([z.string(), z.array(openWebUiContentPartSchema)]),
+ content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer;
@@ -190,7 +195,7 @@ export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
- content: z.string().nullish(),
+ content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
@@ -206,7 +211,7 @@ export const openWebUiChatSchema = z.object({
.array(
z.object({
role: z.string().nullish(),
- content: z.string().nullish(),
+ content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
diff --git a/packages/widgets/src/open-webui/chat-utils.ts b/packages/widgets/src/open-webui/chat-utils.ts
index 0f29c9508..eed819539 100644
--- a/packages/widgets/src/open-webui/chat-utils.ts
+++ b/packages/widgets/src/open-webui/chat-utils.ts
@@ -42,17 +42,34 @@ export const toCompletionMessages = (messages: ChatMessage[]): CompletionMessage
: { role: message.role, content: message.content },
);
-type RawMessage = { role?: string | null; content?: string | null };
+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 | 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;
+// 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
diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx
index d4638b1e5..266e02d77 100644
--- a/packages/widgets/src/open-webui/chat.tsx
+++ b/packages/widgets/src/open-webui/chat.tsx
@@ -170,6 +170,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const handleNewChat = () => {
stream.reset();
attachments.reset();
+ setInput("");
setError(null);
setHistoryOpened(false);
};
diff --git a/packages/widgets/src/open-webui/use-attachments.ts b/packages/widgets/src/open-webui/use-attachments.ts
index 64b6e2d16..659a247f5 100644
--- a/packages/widgets/src/open-webui/use-attachments.ts
+++ b/packages/widgets/src/open-webui/use-attachments.ts
@@ -98,9 +98,11 @@ export function useAttachments({ integrationId, onError, errorMessages }: UseAtt
const raw = webInput.trim();
if (!raw) return;
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
- setWebInput("");
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
diff --git a/packages/widgets/src/open-webui/use-chat-stream.ts b/packages/widgets/src/open-webui/use-chat-stream.ts
index bf1431262..32abb741c 100644
--- a/packages/widgets/src/open-webui/use-chat-stream.ts
+++ b/packages/widgets/src/open-webui/use-chat-stream.ts
@@ -77,7 +77,12 @@ export function useChatStream({
const runPersist = async (finalMessages: ChatMessage[], session: number) => {
if (!integrationId || !model) return;
// Native history stores plain text; image data URLs are dropped on persist.
- const title = finalMessages.find((message) => message.role === "user")?.content.slice(0, 80) ?? newChatTitle;
+ // 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],
@@ -131,15 +136,15 @@ export function useChatStream({
setPendingContextTexts([]);
};
- const finalizeAssistantMessage = (stopped = false) => {
+ 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 user-stopped reply is kept on screen but not written to history as a
- // finished turn; a later real turn will persist the whole conversation.
- if (!stopped) void persistChat(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("");
@@ -165,12 +170,14 @@ export function useChatStream({
finalizeAssistantMessage();
} else if (event.type === "error") {
setError(event.message);
- finalizeAssistantMessage();
+ // Don't persist a partial reply from a failed turn as finished history.
+ finalizeAssistantMessage(true);
}
},
onError: (err) => {
setError(err.message);
- finalizeAssistantMessage();
+ // Don't persist a partial reply from a failed turn as finished history.
+ finalizeAssistantMessage(true);
},
},
);