From 9301e224ec362c21e15a860744dce605ccf5ce94 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 11 Jul 2026 19:08:09 +0200 Subject: [PATCH] feat(open-webui): tag chats per user, harden error/voice states, add tests - persistence: tag created/updated Open WebUI chats with a homarr-user: tag (via chat meta.tags) so conversations can be found back per Homarr user even when an integration/account is shared - voice recording: distinguish a denied/dismissed microphone permission from a generic mic failure and show an actionable message - widget: surface the models query loading and connection-error states in the chat empty state instead of silently showing 'no models' - tests: cover the final-SSE-frame flush, the default request timeout (and the streaming opt-out), and multimodal chat parsing round-trip --- packages/api/src/router/widgets/open-webui.ts | 10 ++- .../src/open-webui/open-webui-types.ts | 5 +- .../test/open-webui-integration.spec.ts | 79 +++++++++++++++++++ packages/translation/src/lang/en.json | 1 + packages/widgets/src/open-webui/chat.tsx | 25 +++++- .../src/open-webui/use-voice-recording.ts | 10 ++- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/api/src/router/widgets/open-webui.ts b/packages/api/src/router/widgets/open-webui.ts index b5f48c988..eb0c53312 100644 --- a/packages/api/src/router/widgets/open-webui.ts +++ b/packages/api/src/router/widgets/open-webui.ts @@ -79,6 +79,10 @@ 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); +// Tag persisted conversations with the owning Homarr user so they can be found +// back in Open WebUI even when an integration/account is shared between users. +const homarrUserTag = (userId: string) => `homarr-user:${userId}`; + const messageSchema = z.object({ role: z.enum(["system", "user", "assistant"]), content: z.string(), @@ -293,6 +297,7 @@ export const openWebUiRouter = createTRPCRouter({ title: input.title, models: input.models, messages: input.messages, + meta: { tags: [homarrUserTag(ctx.session.user.id)] }, }); await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync(); return chat; @@ -304,7 +309,10 @@ export const openWebUiRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const client = await createIntegrationAsync(ctx.integration); - const chat = await client.updateChatAsync(input.chatId, input.chat); + const chat = await client.updateChatAsync(input.chatId, { + ...input.chat, + meta: { tags: [homarrUserTag(ctx.session.user.id)] }, + }); await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync(); await openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId }).invalidateAsync(); return chat; diff --git a/packages/integrations/src/open-webui/open-webui-types.ts b/packages/integrations/src/open-webui/open-webui-types.ts index d209dfb83..4f558e482 100644 --- a/packages/integrations/src/open-webui/open-webui-types.ts +++ b/packages/integrations/src/open-webui/open-webui-types.ts @@ -227,9 +227,12 @@ export const openWebUiChatSchema = z.object({ export type OpenWebUiChat = z.infer; -// Body used when creating/updating a chat through the native API. +// Body used when creating/updating a chat through the native API. `meta.tags` +// lets Homarr tag a conversation (e.g. with the owning Homarr user id) so it can +// be found back in Open WebUI even when an integration/account is shared. export interface OpenWebUiChatPayload { title: string; models: string[]; messages: OpenWebUiChatMessage[]; + meta?: { tags?: string[] }; } diff --git a/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts b/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts index b6d94d604..9ebd8367e 100644 --- a/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts +++ b/packages/integrations/src/open-webui/test/open-webui-integration.spec.ts @@ -147,5 +147,84 @@ describe("OpenWebUiIntegration", () => { ), ).rejects.toThrow(); }); + + test("flushes the final SSE frame when it has no trailing newline", async () => { + mockFetch.mockResolvedValue( + asFetchResult( + new Response( + streamFrom([ + `data: ${JSON.stringify({ choices: [{ delta: { content: "done" } }] })}\n`, + // Final event: no trailing newline and ends via finish_reason (no [DONE]). + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`, + ]), + { status: 200 }, + ), + ), + ); + + const deltas: string[] = []; + await expect( + createIntegration().streamChatCompletionAsync( + { model: "llama3", messages: [{ role: "user", content: "Hi" }] }, + (delta) => deltas.push(delta), + new AbortController().signal, + ), + ).resolves.toBeUndefined(); + + expect(deltas.join("")).toBe("done"); + }); + }); + + describe("requestAsync timeouts", () => { + test("applies a default timeout to non-streaming requests", async () => { + setupMockFetch({ "/api/models": { data: [] } }); + + await createIntegration().getModelsAsync(); + + const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined; + expect(options?.timeout).toBeGreaterThan(0); + }); + + test("does not time out the long-lived streaming completion", async () => { + mockFetch.mockResolvedValue(asFetchResult(new Response(streamFrom(["data: [DONE]\n"]), { status: 200 }))); + + await createIntegration().streamChatCompletionAsync( + { model: "llama3", messages: [{ role: "user", content: "Hi" }] }, + () => undefined, + new AbortController().signal, + ); + + const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined; + expect(options?.timeout).toBeUndefined(); + }); + }); + + describe("getChatAsync", () => { + test("parses a chat whose message content is multimodal (text + image parts)", async () => { + setupMockFetch({ + "/api/v1/chats/abc": { + id: "abc", + chat: { + title: "Vision", + models: ["llava"], + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ], + }, + { role: "assistant", content: "A cat." }, + ], + }, + }, + }); + + const chat = await createIntegration().getChatAsync("abc"); + + expect(chat.id).toBe("abc"); + expect(chat.chat?.messages?.[0]?.content).toBeDefined(); + }); }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 674cb96fe..7257461fe 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3898,6 +3898,7 @@ "stopRecording": "Stop recording", "cancelRecording": "Cancel recording", "micError": "Could not access the microphone", + "micPermissionError": "Microphone access was denied. Allow it in your browser to record voice messages.", "transcribeError": "Could not transcribe the recording", "error": { "internalServerError": "Failed to reach Open WebUI" diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx index fd08afbc9..e4332e564 100644 --- a/packages/widgets/src/open-webui/chat.tsx +++ b/packages/widgets/src/open-webui/chat.tsx @@ -8,6 +8,7 @@ import { CloseButton, Group, Image, + Loader, Modal, NavLink, Paper, @@ -73,7 +74,11 @@ export function Chat({ const headerRef = useRef(null); const fileInputRef = useRef(null); - const { data: models = [], isLoading: modelsLoading } = clientApi.widget.openWebUi.getModels.useQuery( + const { + data: models = [], + isLoading: modelsLoading, + isError: modelsError, + } = clientApi.widget.openWebUi.getModels.useQuery( { integrationId: integrationId ?? "" }, { enabled: Boolean(integrationId), refetchOnWindowFocus: false, retry: false }, ); @@ -111,6 +116,7 @@ export function Chat({ onError: setError, onTranscribed: (text) => setInput((previous) => (previous ? `${previous} ${text}` : text)), micErrorMessage: t("widget.openWebUi.micError"), + micPermissionErrorMessage: t("widget.openWebUi.micPermissionError"), transcribeErrorMessage: t("widget.openWebUi.transcribeError"), }); @@ -252,9 +258,20 @@ export function Chat({ {stream.messages.length === 0 && !stream.isStreaming ? ( - - - {t("widget.openWebUi.emptyState")} + + {modelsLoading ? ( + + ) : modelsError ? ( + <> + + {t("widget.openWebUi.error.internalServerError")} + + ) : ( + <> + + {t("widget.openWebUi.emptyState")} + + )} ) : null} {stream.messages.map((message, index) => ( diff --git a/packages/widgets/src/open-webui/use-voice-recording.ts b/packages/widgets/src/open-webui/use-voice-recording.ts index b98ef7cb7..c61c510e5 100644 --- a/packages/widgets/src/open-webui/use-voice-recording.ts +++ b/packages/widgets/src/open-webui/use-voice-recording.ts @@ -9,6 +9,7 @@ interface UseVoiceRecordingOptions { onError: (message: string) => void; onTranscribed: (text: string) => void; micErrorMessage: string; + micPermissionErrorMessage: string; transcribeErrorMessage: string; } @@ -22,6 +23,7 @@ export function useVoiceRecording({ onError, onTranscribed, micErrorMessage, + micPermissionErrorMessage, transcribeErrorMessage, }: UseVoiceRecordingOptions) { const [isRecording, setIsRecording] = useState(false); @@ -106,14 +108,18 @@ export function useVoiceRecording({ setRecordingSeconds(0); recordingTimerRef.current = window.setInterval(() => setRecordingSeconds((value) => value + 1), 1000); setIsRecording(true); - } catch { + } catch (error) { // getUserMedia may have succeeded before AudioContext/MediaRecorder setup // threw; release the mic stream and context so it isn't left running. releaseAudioResources(); mediaRecorderRef.current = null; setAnalyser(null); setIsRecording(false); - onError(micErrorMessage); + // Distinguish a denied/dismissed permission prompt from other mic failures + // so the user gets an actionable message. + const denied = + error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError"); + onError(denied ? micPermissionErrorMessage : micErrorMessage); } };