fix: address CodeRabbit review comments
API / router (packages/api):
- open-webui: expose serializable procedures as MCP tools via .meta({ mcp })
and gate knowledge/files/notes reads behind protectedProcedure + interact so
anonymous board viewers can't enumerate private source content
- integration create: wrap integration + creator-permission + secret writes in
a single cross-driver transaction so a partial failure can't leave an
unusable integration
- integration test: scope the permission lookup to the created integration
Integration (packages/integrations):
- open-webui: log only safe metadata (not raw completion chunks) on parse
failure, flush the final buffered SSE frame before treating the stream as
truncated, and apply a default request timeout (streaming opts out)
Widget (packages/widgets):
- collapse knowledge rows by default to avoid an N+1 getKnowledgeFiles burst
- strip markdown images from assistant output to block untrusted fetches
- reconcile the selected model when the model list changes
- clear draft attachments when switching to a saved chat
- dedupe async note/chat attaches after the fetch resolves
- discard stale persist responses via a session token so a cleared chat can't
be rebound to an old Open WebUI chat id
- release the mic stream on mid-start failures and mark the recording cancelled
on unmount so partial audio isn't transcribed
This commit is contained in:
9 files changed
+253
-71
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<Parameters<typeof fetchWithTrustedCertificatesAsync>[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<RequestOptions, "headers"> & { headers?: Record<string, string> },
|
||||
) {
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</Text>
|
||||
) : (
|
||||
<div className={classes.markdown}>
|
||||
<ReactMarkdown skipHtml>{content}</ReactMarkdown>
|
||||
{/* skipHtml only strips raw HTML; drop markdown images too so
|
||||
untrusted model output can't trigger arbitrary browser fetches
|
||||
on render. */}
|
||||
<ReactMarkdown skipHtml components={{ img: () => null }}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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<ChatMessage[] | null>(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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user