feat(open-webui): add image, knowledge and webpage attachments with retry

Extend the AI Chat widget with three ways to ground a conversation:

- Images: attach images to a message, sent inline as OpenAI multimodal
  content parts for vision-capable models. A hint warns when the selected
  model likely has no vision support.
- Knowledge bases: pick RAG collections exposed by /api/v1/knowledge/.
- Web pages: ingest a URL via /api/v1/retrieval/process/web.

Open WebUI does not run RAG from the chat-completions `files` parameter,
so attached collections (web pages and knowledge bases) are grounded by
retrieving the most relevant chunks at send time
(/api/v1/retrieval/query/collection) and injecting them as system context.

Also add a Retry action to re-run the last turn after a model failure, and
handle the paginated `{ items, total }` shape of the knowledge endpoint on
newer Open WebUI versions.
This commit is contained in:
Lok3sh-Kumar committed 2026-06-27 14:15:08 +05:30
1 parent 005c2e4cd1
commit f61e7539d7
8 files changed
+624 -30

No files matched your search

+81 -2
View File
@@ -6,6 +6,7 @@ import { createIntegrationAsync } from "@homarr/integrations";
import {
openWebUiChatRequestHandler,
openWebUiChatsRequestHandler,
openWebUiKnowledgeRequestHandler,
openWebUiModelsRequestHandler,
} from "@homarr/request-handler/open-webui";
@@ -19,6 +20,18 @@ const messageSchema = z.object({
content: z.string(),
});
// Outbound completion messages may carry multimodal parts (text + images),
// so users can attach images to a message.
const contentPartSchema = 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() }) }),
]);
const completionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(contentPartSchema)]),
});
const chatPayloadSchema = z.object({
title: z.string(),
models: z.array(z.string()),
@@ -27,6 +40,47 @@ const chatPayloadSchema = z.object({
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
type CompletionMessage = z.infer<typeof completionMessageSchema>;
// Extract the plain text of a message, flattening multimodal content parts.
const messageText = (message: CompletionMessage): string =>
typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
// Retrieve relevant chunks from the attached collections and prepend them as a
// system context message so the model can ground its reply on them.
const groundMessagesAsync = async (
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
messages: CompletionMessage[],
collections: string[],
): Promise<CompletionMessage[]> => {
if (collections.length === 0) return messages;
const lastUserMessage = messages.findLast((message) => message.role === "user");
const query = lastUserMessage ? messageText(lastUserMessage) : "";
let documents: string[] = [];
try {
documents = await client.queryCollectionsAsync(collections, query, 4);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
if (documents.length === 0) return messages;
const context = documents.join("\n\n");
const contextMessage: CompletionMessage = {
role: "system",
content:
"Use the following context from the user's attached sources (web pages and knowledge bases) " +
`to answer. If the answer is not in the context, say so.\n\n${context}`,
};
return [contextMessage, ...messages];
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
@@ -40,6 +94,22 @@ export const openWebUiRouter = createTRPCRouter({
return data;
}),
getKnowledge: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.input(z.object({ url: z.string().url() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
getChat: publicProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
@@ -78,7 +148,15 @@ export const openWebUiRouter = createTRPCRouter({
// 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) }))
.input(
z.object({
model: z.string(),
messages: z.array(completionMessageSchema),
// Collections to ground the reply on (web pages + knowledge bases). We
// retrieve relevant chunks and inject them as context before streaming.
collections: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.subscription(({ ctx, input }) => {
return observable<StreamEvent>((emit) => {
@@ -87,8 +165,9 @@ export const openWebUiRouter = createTRPCRouter({
void (async () => {
try {
const client = await createIntegrationAsync(ctx.integration);
const messages = await groundMessagesAsync(client, input.messages, input.collections ?? []);
await client.streamChatCompletionAsync(
{ model: input.model, messages: input.messages },
{ model: input.model, messages },
(delta) => emit.next({ type: "delta", content: delta }),
controller.signal,
);