diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 20e9975ef..af3520fe1 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -345,32 +345,66 @@ export const integrationRouter = createTRPCRouter({ const appId = await createAppIfNecessaryAsync(ctx.db, input.app); const integrationId = createId(); - await ctx.db.insert(integrations).values({ - id: integrationId, - name: input.name, - url: input.url, - kind: input.kind, - appId, - creatorId: ctx.session.user.id, - }); - - // Auto-grant the creator "full" permission so they can immediately use - // and manage the integration they just created. - await ctx.db.insert(integrationUserPermissions).values({ + const userId = ctx.session.user.id; + const secretValues = input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), integrationId, - userId: ctx.session.user.id, - permission: "full", - }); + })); - if (input.secrets.length >= 1) { - await ctx.db.insert(integrationSecrets).values( - input.secrets.map((secret) => ({ - kind: secret.kind, - value: encryptSecret(secret.value), - integrationId, - })), - ); - } + // Create the integration, its creator permission and secrets atomically so + // a failure part-way through can't leave a committed integration the + // creator can neither use nor manage. + await handleTransactionsAsync(ctx.db, { + handleAsync: async (db, schema) => { + await db.transaction(async (transaction) => { + await transaction.insert(schema.integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId, + creatorId: userId, + }); + // Auto-grant the creator "full" permission so they can immediately + // use and manage the integration they just created. + await transaction.insert(schema.integrationUserPermissions).values({ + integrationId, + userId, + permission: "full", + }); + if (secretValues.length >= 1) { + await transaction.insert(schema.integrationSecrets).values(secretValues); + } + }); + }, + handleSync: (db) => { + db.transaction((transaction) => { + transaction + .insert(integrations) + .values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId, + creatorId: userId, + }) + .run(); + transaction + .insert(integrationUserPermissions) + .values({ + integrationId, + userId, + permission: "full", + }) + .run(); + if (secretValues.length >= 1) { + transaction.insert(integrationSecrets).values(secretValues).run(); + } + }); + }, + }); logger.info("Created integration", { id: integrationId, diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 204e073aa..9a229376f 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; +import { and, eq } from "@homarr/db"; import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -321,7 +322,12 @@ describe("create should create a new integration", () => { const dbIntegration = await db.query.integrations.findFirst(); const dbSecret = await db.query.integrationSecrets.findFirst(); - const dbPermission = await db.query.integrationUserPermissions.findFirst(); + const dbPermission = await db.query.integrationUserPermissions.findFirst({ + where: and( + eq(integrationUserPermissions.integrationId, dbIntegration!.id), + eq(integrationUserPermissions.userId, defaultUserId), + ), + }); expect(dbIntegration).toBeDefined(); expect(dbIntegration!.name).toBe(input.name); expect(dbIntegration!.kind).toBe(input.kind); diff --git a/packages/api/src/router/widgets/open-webui.ts b/packages/api/src/router/widgets/open-webui.ts index fe16d341f..fe2c22b69 100644 --- a/packages/api/src/router/widgets/open-webui.ts +++ b/packages/api/src/router/widgets/open-webui.ts @@ -122,16 +122,30 @@ const groundMessagesAsync = async ( }; export const openWebUiRouter = createTRPCRouter({ - getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => { - throwIfNotCreator(ctx.integration, ctx.session?.user.id); - const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {}); - const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return data; - }), + getModels: publicProcedure + .meta({ + mcp: { + enabled: true, + description: "List the available Open WebUI chat models for the selected integration.", + }, + }) + .concat(createOneIntegrationMiddleware("query", "openWebUi")) + .query(async ({ ctx }) => { + throwIfNotCreator(ctx.integration, ctx.session?.user.id); + const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {}); + const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), // Chat history exposes private user data, so we require "interact" access // (not just "query") and verify the caller is the integration's creator. getChats: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List the saved Open WebUI chat conversations for the selected integration.", + }, + }) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); @@ -140,42 +154,81 @@ export const openWebUiRouter = createTRPCRouter({ return data; }), - getKnowledge: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => { - throwIfNotCreator(ctx.integration, ctx.session?.user.id); - const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {}); - const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return data; - }), + // Knowledge bases, files and notes expose private source content/metadata, so + // gate them like the interactive chat paths (protected + "interact" access, + // creator-only) instead of leaving them reachable by anonymous board viewers. + getKnowledge: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List the Open WebUI knowledge bases available for the selected integration.", + }, + }) + .concat(createOneIntegrationMiddleware("interact", "openWebUi")) + .query(async ({ ctx }) => { + throwIfNotCreator(ctx.integration, ctx.session.user.id); + const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {}); + const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), - getFiles: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => { - throwIfNotCreator(ctx.integration, ctx.session?.user.id); - const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {}); - const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return data; - }), + getFiles: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List the files stored in Open WebUI for the selected integration.", + }, + }) + .concat(createOneIntegrationMiddleware("interact", "openWebUi")) + .query(async ({ ctx }) => { + throwIfNotCreator(ctx.integration, ctx.session.user.id); + const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {}); + const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), - getNotes: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => { - throwIfNotCreator(ctx.integration, ctx.session?.user.id); - const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {}); - const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return data; - }), + getNotes: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List the notes stored in Open WebUI for the selected integration.", + }, + }) + .concat(createOneIntegrationMiddleware("interact", "openWebUi")) + .query(async ({ ctx }) => { + throwIfNotCreator(ctx.integration, ctx.session.user.id); + const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {}); + const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), - getNote: publicProcedure + getNote: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "Get the full contents of a single Open WebUI note by id.", + }, + }) .input(z.object({ noteId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "openWebUi")) + .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx, input }) => { - throwIfNotCreator(ctx.integration, ctx.session?.user.id); + throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), - getKnowledgeFiles: publicProcedure + getKnowledgeFiles: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List the files belonging to an Open WebUI knowledge base.", + }, + }) .input(z.object({ knowledgeId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "openWebUi")) + .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx, input }) => { - throwIfNotCreator(ctx.integration, ctx.session?.user.id); + throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, { knowledgeId: input.knowledgeId, }); @@ -186,6 +239,12 @@ export const openWebUiRouter = createTRPCRouter({ // Ingest a web page so its content can ground later messages. Returns the // collection to retrieve from plus a display title. processWeb: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "Ingest a web page into Open WebUI so its content can ground later chat messages.", + }, + }) .input(z.object({ url: z.string().url() })) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .mutation(async ({ ctx, input }) => { diff --git a/packages/integrations/src/open-webui/open-webui-integration.ts b/packages/integrations/src/open-webui/open-webui-integration.ts index 67f412efb..9f5484da0 100644 --- a/packages/integrations/src/open-webui/open-webui-integration.ts +++ b/packages/integrations/src/open-webui/open-webui-integration.ts @@ -35,6 +35,10 @@ import { const logger = createLogger({ module: "open-webui-integration" }); +// Default per-request timeout so a stalled upstream can't hang model/file/note/ +// chat calls indefinitely. Streaming completions opt out (they are long-lived). +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + // The (undici) request options accepted by the shared fetch helper. Derived so // the request helpers stay in sync with the underlying fetch types. type RequestOptions = NonNullable[1]>; @@ -220,6 +224,10 @@ export class OpenWebUiIntegration extends Integration { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }), + // Long-lived stream: opt out of the default request timeout and rely on the + // caller's abort signal instead (the timeout path would also replace this + // signal, breaking cancellation). + timeout: undefined, signal, }); @@ -248,8 +256,13 @@ export class OpenWebUiIntegration extends Integration { // Some providers end the stream with a finish_reason chunk and no // explicit [DONE] sentinel; treat that as a clean completion too. if (choice?.finish_reason) return true; - } catch { - logger.debug("Failed to parse Open WebUI completion chunk", { line: trimmed }); + } catch (error) { + // The chunk can contain model output or grounded user data, so log only + // safe metadata rather than the raw payload. + logger.debug("Failed to parse Open WebUI completion chunk", { + error: error instanceof Error ? error.message : "unknown", + payloadLength: payload.length, + }); } return false; }; @@ -258,7 +271,15 @@ export class OpenWebUiIntegration extends Integration { try { while (!signal.aborted) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + // The server may close after the final data: event without a trailing + // newline; parse whatever is left so a valid completion isn't treated + // as a truncated stream. + if (buffer.trim() !== "" && processLine(buffer)) { + completed = true; + } + break; + } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); @@ -301,6 +322,10 @@ export class OpenWebUiIntegration extends Integration { init?: Omit & { headers?: Record }, ) { const response = await fetchWithTrustedCertificatesAsync(this.url(path), { + // Apply a default timeout unless the caller opts out (e.g. streaming + // completions pass `timeout: undefined` to stay open indefinitely). A + // spread after this key lets an explicit caller value win. + timeout: DEFAULT_REQUEST_TIMEOUT_MS, ...init, headers: { ...this.getAuthHeaders(), ...init?.headers }, }); diff --git a/packages/widgets/src/open-webui/chat-components.tsx b/packages/widgets/src/open-webui/chat-components.tsx index 6561fccc1..5976ceb28 100644 --- a/packages/widgets/src/open-webui/chat-components.tsx +++ b/packages/widgets/src/open-webui/chat-components.tsx @@ -213,8 +213,10 @@ export function KnowledgeRow({ onToggleFile: (file: FileAttachment) => void; emptyFilesLabel: string; }) { - // Default to expanded so a base's documents are visible without an extra click. - const [expanded, setExpanded] = useState(true); + // Start collapsed so opening the picker doesn't fire getKnowledgeFiles for + // every knowledge base at once (an N+1 burst on large accounts). Files load + // only when a specific row is expanded. + const [expanded, setExpanded] = useState(false); const { data: files = [], isLoading } = clientApi.widget.openWebUi.getKnowledgeFiles.useQuery( { integrationId, knowledgeId }, { enabled: expanded && Boolean(integrationId), refetchOnWindowFocus: false, retry: false }, @@ -577,7 +579,12 @@ export function MessageBubble({ ) : (
- {content} + {/* skipHtml only strips raw HTML; drop markdown images too so + untrusted model output can't trigger arbitrary browser fetches + on render. */} + null }}> + {content} +
) ) : null} diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx index 2dbb8318f..d4638b1e5 100644 --- a/packages/widgets/src/open-webui/chat.tsx +++ b/packages/widgets/src/open-webui/chat.tsx @@ -112,10 +112,18 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro captureErrorMessage: t("widget.openWebUi.captureError"), }); - // Default to the first available model once the list loads. + // Keep the selection reconciled with the available models: default to the + // first one, and reset if the current id disappears (integration change or a + // model removed/renamed upstream) so sends don't use a stale id. useEffect(() => { - if (model !== null || models.length === 0) return; - setModel(models[0]?.id ?? null); + if (models.length === 0) { + if (model !== null) setModel(null); + return; + } + + if (model === null || !models.some((item) => item.id === model)) { + setModel(models[0]?.id ?? null); + } }, [model, models]); // Track the floating composer's height so messages can pad below it. @@ -505,7 +513,13 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro key={chat.id} active={chat.id === stream.activeChatId} label={chat.title ?? chat.id} - onClick={() => stream.setSelectedChatId(chat.id)} + onClick={() => { + // Drop the draft's grounding so a loaded conversation + // isn't answered against the previous draft's attachments. + attachments.reset(); + setError(null); + stream.setSelectedChatId(chat.id); + }} /> )) )} diff --git a/packages/widgets/src/open-webui/use-attachments.ts b/packages/widgets/src/open-webui/use-attachments.ts index 695d9cf58..64b6e2d16 100644 --- a/packages/widgets/src/open-webui/use-attachments.ts +++ b/packages/widgets/src/open-webui/use-attachments.ts @@ -128,7 +128,13 @@ export function useAttachments({ integrationId, onError, errorMessages }: UseAtt } try { const full = await utils.widget.openWebUi.getNote.fetch({ integrationId, noteId: note.id }); - setNoteItems((previous) => [...previous, { id: full.id, title: full.title, content: full.content }]); + // A double-click can start two fetches before state updates; dedupe on the + // resolved id so the note isn't attached (and grounded) twice. + setNoteItems((previous) => + previous.some((item) => item.id === full.id) + ? previous + : [...previous, { id: full.id, title: full.title, content: full.content }], + ); } catch { onError(errorMessages.note); } @@ -150,7 +156,13 @@ export function useAttachments({ integrationId, onError, errorMessages }: UseAtt const transcript = extractChatMessages(full.chat) .map((message) => `${message.role}: ${message.content}`) .join("\n"); - setChatItems((previous) => [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }]); + // Dedupe on the resolved id so a double-click can't attach the same chat + // (and its transcript) twice. + setChatItems((previous) => + previous.some((item) => item.id === chat.id) + ? previous + : [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }], + ); } catch { onError(errorMessages.chat); } diff --git a/packages/widgets/src/open-webui/use-chat-stream.ts b/packages/widgets/src/open-webui/use-chat-stream.ts index ca3f7f49b..bf1431262 100644 --- a/packages/widgets/src/open-webui/use-chat-stream.ts +++ b/packages/widgets/src/open-webui/use-chat-stream.ts @@ -45,6 +45,10 @@ export function useChatStream({ // Read inside the serialized persist loop so a burst of turns reuses the chat // created by the first write instead of racing on the not-yet-committed state. const activeChatIdRef = useRef(null); + // Bumped whenever the conversation is replaced (history load / new chat) so a + // detached persist that started against an old conversation can be discarded + // instead of rebinding the cleared chat to a stale Open WebUI chat id. + const sessionRef = useRef(0); const persistInFlightRef = useRef(false); const pendingPersistRef = useRef(null); @@ -60,6 +64,7 @@ export function useChatStream({ // Hydrate the conversation when a history chat finishes loading. useEffect(() => { if (!loadedChat) return; + sessionRef.current += 1; const loadedMessages = extractChatMessages(loadedChat.chat); setMessages(loadedMessages); messagesRef.current = loadedMessages; @@ -69,7 +74,7 @@ export function useChatStream({ onChatLoaded(); }, [loadedChat, setModel, onChatLoaded]); - const runPersist = async (finalMessages: ChatMessage[]) => { + const runPersist = async (finalMessages: ChatMessage[], session: number) => { if (!integrationId || !model) return; // Native history stores plain text; image data URLs are dropped on persist. const title = finalMessages.find((message) => message.role === "user")?.content.slice(0, 80) ?? newChatTitle; @@ -84,9 +89,13 @@ export function useChatStream({ await updateChat.mutateAsync({ integrationId, chatId, chat: payload }); } else { const chat = await createChat.mutateAsync({ integrationId, ...payload }); + // The conversation was cleared/replaced while this create was in flight; + // don't rebind the now-stale chat id onto the current conversation. + if (session !== sessionRef.current) return; activeChatIdRef.current = chat.id; setActiveChatId(chat.id); } + if (session !== sessionRef.current) return; await utils.widget.openWebUi.getChats.invalidate({ integrationId }); } catch { // Persistence is best-effort; the conversation remains usable in-memory. @@ -98,6 +107,7 @@ export function useChatStream({ // a single trailing save of the latest messages so turns don't stack // concurrent POSTs (or create duplicate chats on the first save). const persistChat = async (finalMessages: ChatMessage[]) => { + const session = sessionRef.current; if (persistInFlightRef.current) { pendingPersistRef.current = finalMessages; return; @@ -107,7 +117,7 @@ export function useChatStream({ let next: ChatMessage[] | null = finalMessages; while (next) { pendingPersistRef.current = null; - await runPersist(next); + await runPersist(next, session); next = pendingPersistRef.current; } } finally { @@ -207,6 +217,7 @@ export function useChatStream({ }; const reset = () => { + sessionRef.current += 1; setMessages([]); messagesRef.current = []; setActiveChatId(null); diff --git a/packages/widgets/src/open-webui/use-voice-recording.ts b/packages/widgets/src/open-webui/use-voice-recording.ts index 949a77d9f..b98ef7cb7 100644 --- a/packages/widgets/src/open-webui/use-voice-recording.ts +++ b/packages/widgets/src/open-webui/use-voice-recording.ts @@ -107,6 +107,12 @@ export function useVoiceRecording({ recordingTimerRef.current = window.setInterval(() => setRecordingSeconds((value) => value + 1), 1000); setIsRecording(true); } catch { + // 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); } }; @@ -121,8 +127,16 @@ export function useVoiceRecording({ mediaRecorderRef.current?.stop(); }; - // Tear down the microphone stream and timer on unmount. - useEffect(() => () => releaseAudioResources(), []); + // Tear down the microphone stream and timer on unmount. Mark the session + // cancelled first so a recorder "stop" fired while stopping the tracks doesn't + // upload/transcribe partial audio after the component is gone. + useEffect( + () => () => { + recordingCancelledRef.current = true; + releaseAudioResources(); + }, + [], + ); return { isRecording,