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:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user