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:
Thomas Camlong committed 2026-07-11 19:11:12 +02:00
1 parent c076a36fec
commit 3d2a2d7b55
7 files changed
+107 -78

No files matched your search

+22 -5
View File
@@ -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
+1
View File
@@ -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);
},
},
);