refactor(open-webui): split chat widget into hooks and components

Break the monolithic chat.tsx into focused hooks (use-attachments,
use-chat-stream, use-voice-recording, use-camera-capture) and
presentational components (chat-components), with shared types and
utils extracted to chat-types/chat-utils.

Review fixes applied on top:
- require auth for the streaming completion (protectedProcedure) so
  anonymous board viewers cannot spend the owner's LLM credits
- rename image-only state to images and the picker query options object
  to pickerQueryOptions for clarity
- extract AttachPopover, clearPending(), composerEmpty and
  releaseAudioResources() to remove duplication
This commit is contained in:
Lok3sh-Kumar committed 2026-06-27 14:17:59 +05:30
1 parent 84275bd19a
commit 8e0f8cd46a
11 files changed
+1563 -1189

No files matched your search

+26 -10
View File
@@ -55,6 +55,15 @@ const messageText = (message: CompletionMessage): string =>
.map((part) => part.text)
.join(" ");
// Number of chunks to retrieve per collection when grounding a reply.
const RETRIEVAL_K = 4;
// Instruction prepended to the retrieved context so the model knows how to use
// it. Model-facing, so intentionally not localized.
const RETRIEVAL_INSTRUCTION =
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
"files, notes and chats) to answer. If the answer is not in the context, say so.";
// 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 (
@@ -65,13 +74,19 @@ const groundMessagesAsync = async (
): Promise<CompletionMessage[]> => {
if (collections.length === 0 && contextTexts.length === 0) return messages;
const lastUserMessage = messages.findLast((message) => message.role === "user");
const query = lastUserMessage ? messageText(lastUserMessage) : "";
// Use the most recent user message that has text. The latest turn can be
// image-only (no text), in which case we fall back to the prior question so
// attached collections still get a meaningful retrieval query.
const query =
messages
.filter((message) => message.role === "user")
.map(messageText)
.findLast((text) => text.trim() !== "") ?? "";
let documents: string[] = [];
if (collections.length > 0) {
try {
documents = await client.queryCollectionsAsync(collections, query, 4);
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
@@ -82,9 +97,7 @@ const groundMessagesAsync = async (
const contextMessage: CompletionMessage = {
role: "system",
content:
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
`files, notes and chats) to answer. If the answer is not in the context, say so.\n\n${blocks.join("\n\n")}`,
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
};
return [contextMessage, ...messages];
};
@@ -205,9 +218,12 @@ export const openWebUiRouter = createTRPCRouter({
}),
// Streaming chat completion. Delivered over the WebSocket subscription link so
// 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
// tokens can be emitted as they arrive. A completion spends the owner's upstream
// LLM credits, so it must not be reachable by anonymous viewers of a public
// board: protectedProcedure requires a session (the WS context resolves it from
// the session-token cookie) and the "interact" middleware checks integration
// access, matching processWeb/uploadFile/transcribe above.
sendMessage: protectedProcedure
.input(
z.object({
model: z.string(),
@@ -219,7 +235,7 @@ export const openWebUiRouter = createTRPCRouter({
contextTexts: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
return observable<StreamEvent>((emit) => {
const controller = new AbortController();