fix(open-webui): drop MCP exposure and address remaining review comments
Per ajnart's review (and CodeRabbit withdrawing its own MCP comment on the
predecessor PR), the Open WebUI chat isn't a meaningful MCP surface:
- remove the .meta({ mcp }) metadata from every openWebUi procedure
Remaining CodeRabbit comments from the predecessor PR that weren't carried over:
- processWeb: constrain the URL to http(s) and reject loopback/metadata hosts
(private LAN ranges stay allowed for self-hosters); it is also creator-gated
- uploadFile/transcribe: bound the base64 payload and validate filename/type
- docs: align the example URL with the integration default port (8080)
- new chat: clear the composer draft so it doesn't leak into the fresh chat
- web ingestion: keep the typed URL when ingestion fails
- persistence: fall back to the default title for image-only opening turns, and
don't persist a partial reply from a failed turn as finished history
- multimodal round-trip: make the native chat/history contracts accept the same
text-or-parts content union as completions, and flatten (not drop) non-text
nodes when rebuilding transcripts so attachment-backed chats aren't lost.
Image data URLs are still kept out of persisted history by design.
This commit is contained in:
7 files changed
+107
-78
No files matched your search
@@ -24,7 +24,7 @@ import { openWebUiWidget } from "../../widgets/open-webui";
|
||||
<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.
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof openWebUiChatMessageSchema>;
|
||||
|
||||
// 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<typeof openWebUiChatMessageSchema>;
|
||||
|
||||
// A completion message can carry plain text or multimodal parts (text + images).
|
||||
export const openWebUiCompletionMessageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.union([z.string(), z.array(openWebUiContentPartSchema)]),
|
||||
content: openWebUiMessageContentSchema,
|
||||
});
|
||||
|
||||
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<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;
|
||||
// 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
|
||||
|
||||
@@ -170,6 +170,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
|
||||
const handleNewChat = () => {
|
||||
stream.reset();
|
||||
attachments.reset();
|
||||
setInput("");
|
||||
setError(null);
|
||||
setHistoryOpened(false);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user