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); }, }, );