feat(open-webui): tag chats per user, harden error/voice states, add tests

- persistence: tag created/updated Open WebUI chats with a homarr-user:<id> 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
This commit is contained in:
Thomas Camlong committed 2026-07-11 19:11:13 +02:00
1 parent e648abf78e
commit 9301e224ec
6 files changed
+122 -8

No files matched your search

@@ -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;
@@ -227,9 +227,12 @@ export const openWebUiChatSchema = z.object({
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
// 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[] };
}
@@ -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();
});
});
});
+1
View File
@@ -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"
+21 -4
View File
@@ -8,6 +8,7 @@ import {
CloseButton,
Group,
Image,
Loader,
Modal,
NavLink,
Paper,
@@ -73,7 +74,11 @@ export function Chat({
const headerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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({
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
<Stack gap="xs" p="xs" pt={headerHeight + 8} pb={composerHeight + 24}>
{stream.messages.length === 0 && !stream.isStreaming ? (
<Stack align="center" justify="center" gap="xs" mt="xl" c="dimmed">
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.emptyState")}</Text>
<Stack align="center" justify="center" gap="xs" mt="xl" c={modelsError ? "red.6" : "dimmed"}>
{modelsLoading ? (
<Loader size="sm" />
) : modelsError ? (
<>
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.error.internalServerError")}</Text>
</>
) : (
<>
<IconRobot size={32} />
<Text size="sm">{t("widget.openWebUi.emptyState")}</Text>
</>
)}
</Stack>
) : null}
{stream.messages.map((message, index) => (
@@ -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);
}
};