Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6768cab
fix: Clear TTS Audio Cache On Session End and Hash Its Keys
berry-13 Oct 11, 2026
a9c1287
fix: Read Manual TTS Replays From the Hashed Cache Key
berry-13 Oct 11, 2026
53655de
fix: Purge Legacy TTS Cache Keys and Fence Writes Across Logout
berry-13 Oct 11, 2026
96c1cf9
fix: Keep TTS Cache Writes Off the Playback Path
berry-13 Oct 11, 2026
e8b5c1c
test: Expect Hashed Keys in the StreamAudio Cache Lookup
berry-13 Oct 11, 2026
12efaf5
test: Use a Single-Token Reply Label in the TTS Cache Scenario
berry-13 Oct 11, 2026
6bcba46
fix: Settle TTS Cache Clear Before IdP Logout and Across Tabs
berry-13 Oct 11, 2026
ef6e7b7
test: Cover the TTS Cache Fence When Another Tab Logs Out
berry-13 Oct 11, 2026
316950e
test: Wait for the Second Tab Before Logging Out in the TTS Scenario
berry-13 Oct 11, 2026
e9cb38b
fix: Latch TTS Cache Writes Shut Until the Tab Authenticates Again
berry-13 Oct 11, 2026
067f6a1
fix: Reopen TTS Cache Writes When Authentication Completes In Place
berry-13 Oct 11, 2026
2212ef0
fix: Leave for the IdP Even When TTS Cache Cleanup Fails
berry-13 Oct 11, 2026
632cb35
fix: Retry a Failed Legacy TTS Purge and Fence Other Tabs on Manual C…
berry-13 Oct 12, 2026
7c87ea8
fix: Rerun the Legacy TTS Purge After Each Clear and Sign-In
berry-13 Oct 12, 2026
4329e4a
fix: Rerun the Legacy TTS Purge When Another Tab Clears
berry-13 Oct 12, 2026
f5b453e
fix: Fence Pre-Auth TTS Writes, Retry Failed Clears, and Fall Back to…
berry-13 Oct 12, 2026
e2ada9b
fix: Version the Hashed TTS Cache Key Path
berry-13 Oct 12, 2026
f350030
fix: Move TTS Audio to a New Cache and Keep It Closed Until a Failed …
berry-13 Oct 12, 2026
55e94bd
fix: Persist a Pending TTS Cache Delete Across Navigation
berry-13 Oct 12, 2026
6979f4f
fix: Reopen TTS Caching Only on Session Start and Fence Cache Reads
berry-13 Oct 12, 2026
90c12e8
fix: Keep the TTS Delete Fence in Cache Storage and Close Reads After…
berry-13 Oct 12, 2026
afca692
refactor: Keep Read-Aloud Audio in Tab Memory Instead of Cache Storage
berry-13 Oct 12, 2026
c537924
test: Return to the Conversation After Re-Login in the TTS Logout Sce…
berry-13 Oct 12, 2026
2538ec5
test: Close the Phone Sidebar Before Replaying in the TTS Logout Scen…
berry-13 Oct 12, 2026
c2631d0
fix: Bound the In-Memory TTS Cache by Size as Well as Count
berry-13 Oct 12, 2026
f3bc45c
fix: Scope Cached TTS Audio to the Signed-In User
berry-13 Oct 12, 2026
a88ded6
fix: Keep the Settings Cache Delete Available While Legacy Audio Remains
berry-13 Oct 12, 2026
9c92ddb
fix: Recheck Legacy TTS Audio After a Settings Clear and on Unowned H…
berry-13 Oct 12, 2026
ab18c37
fix: Fence TTS Writes Started Before an Account Signs In
berry-13 Oct 12, 2026
ff97e0d
test: Opt the Fresh TTS Account Into Enter-to-Send
berry-13 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions client/src/components/Chat/Input/StreamAudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ import {
MediaSourceAppender,
usePauseGlobalAudio,
} from '~/hooks/Audio';
import {
logger,
getTTSCacheKey,
setCachedTTSAudio,
getCachedTTSAudio,
getTTSCacheGeneration,
} from '~/utils';
import { useAuthContext } from '~/hooks';
import { globalAudioId } from '~/common';
import { logger } from '~/utils';
import store from '~/store';

function timeoutPromise(ms: number, message?: string) {
Expand All @@ -25,7 +31,7 @@ const promiseTimeoutMessage = 'Reader promise timed out';
const maxPromiseTime = 15000;

export default function StreamAudio({ index = 0 }) {
const { token } = useAuthContext();
const { token, user } = useAuthContext();

const cacheTTS = useRecoilValue(store.cacheTTS);
const playbackRate = useRecoilValue(store.playbackRate);
Expand Down Expand Up @@ -57,6 +63,7 @@ export default function StreamAudio({ index = 0 }) {

async function fetchAudio() {
setIsFetching(true);
const generation = getTTSCacheGeneration();
let mediaSource: MediaSourceAppender | undefined;

try {
Expand All @@ -68,15 +75,15 @@ export default function StreamAudio({ index = 0 }) {

/** Keyed by the spoken text, as manual playback is, so audio cached from an
* unfiltered message (reasoning included) is never replayed. */
let cacheKey = latestMessage ? getSpeechText(latestMessage) : '';
const cache = await caches.open('tts-responses');
const cachedResponse = await cache.match(cacheKey);
let cacheKey = latestMessage
? await getTTSCacheKey(getSpeechText(latestMessage), voice, user?.id)
: null;
const cachedAudio = cacheKey ? getCachedTTSAudio(cacheKey) : undefined;

setAudioRunId(activeRunId);
if (cachedResponse) {
if (cachedAudio) {
logger.log('Audio found in cache');
const audioBlob = await cachedResponse.blob();
const blobUrl = URL.createObjectURL(audioBlob);
const blobUrl = URL.createObjectURL(cachedAudio);
setGlobalAudioURL(blobUrl);
setIsFetching(false);
return;
Expand Down Expand Up @@ -141,12 +148,14 @@ export default function StreamAudio({ index = 0 }) {
const targetMessage = latestMessages.find(
(msg) => msg.messageId === latestMessage?.messageId,
);
cacheKey = targetMessage ? getSpeechText(targetMessage) : '';
cacheKey = targetMessage
? await getTTSCacheKey(getSpeechText(targetMessage), voice, user?.id)
: null;
if (!cacheKey) {
logger.warn('Cache key not found, skipping audio cache');
} else {
logger.log('Adding audio to cache');
await cache.put(cacheKey, new Response(audioBlob));
setCachedTTSAudio(cacheKey, audioBlob, generation);
}
}
}
Expand Down Expand Up @@ -181,6 +190,7 @@ export default function StreamAudio({ index = 0 }) {
audioRef,
voice,
token,
user?.id,
]);

useEffect(() => {
Expand Down
20 changes: 12 additions & 8 deletions client/src/components/Chat/Input/__tests__/StreamAudio.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
import React from 'react';
import { TextEncoder } from 'util';
import { webcrypto } from 'crypto';
import { RecoilRoot } from 'recoil';
import { render, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { TMessage, TConversation } from 'librechat-data-provider';
import { getTTSCacheKey, setCachedTTSAudio, getTTSCacheGeneration } from '~/utils';
import StreamAudio from '../StreamAudio';
import store from '~/store';

jest.mock('~/hooks', () => ({ useAuthContext: () => ({ token: 'token' }) }));

const conversationId = 'convo-1';

const match = jest.fn(async (_key: string) => ({ blob: async () => new Blob() }));
const recording = new Blob(['spoken answer'], { type: 'audio/mpeg' });
const fetchMock = jest.fn(() => Promise.reject(new Error('not cached')));

describe('StreamAudio cache', () => {
beforeAll(() => {
Object.defineProperty(global, 'caches', {
writable: true,
configurable: true,
value: { open: async () => ({ match, put: jest.fn() }) },
});
Object.defineProperty(global, 'crypto', { value: webcrypto, configurable: true });
Object.assign(global, { TextEncoder });
Object.assign(global, { fetch: fetchMock });
URL.createObjectURL = jest.fn(() => 'blob:audio');
URL.revokeObjectURL = jest.fn();
});

/** Audio cached before reasoning was filtered is keyed by the full text; looking up the
* spoken answer instead means that recording is never replayed. */
it('looks up cached audio by the spoken answer, not the stored text', async () => {
const key = await getTTSCacheKey('The capital of Germany is Berlin.');
setCachedTTSAudio(key as string, recording, getTTSCacheGeneration());
const message = {
messageId: 'assistant-1',
conversationId,
Expand Down Expand Up @@ -58,7 +62,7 @@ describe('StreamAudio cache', () => {
</QueryClientProvider>,
);

await waitFor(() => expect(match).toHaveBeenCalled());
expect(match).toHaveBeenCalledWith('The capital of Germany is Berlin.');
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledWith(recording));
expect(fetchMock).not.toHaveBeenCalled();
});
});
12 changes: 6 additions & 6 deletions client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
InfoHoverCard,
useOnClickOutside,
} from '@librechat/client';
import { clearTTSCache, hasLegacyTTSCache, hasCachedTTSAudio } from '~/utils';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';

Expand All @@ -22,9 +23,7 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);

const checkCache = useCallback(async () => {
const cache = await caches.open('tts-responses');
const keys = await cache.keys();
setIsCacheEmpty(keys.length === 0);
setIsCacheEmpty(!hasCachedTTSAudio() && !(await hasLegacyTTSCache()));
}, []);

useEffect(() => {
Expand All @@ -33,10 +32,11 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {

const revokeAllUserKeys = useCallback(async () => {
setIsLoading(true);
const cache = await caches.open('tts-responses');
await cache.keys().then((keys) => Promise.all(keys.map((key) => cache.delete(key))));
await clearTTSCache();
/** A legacy delete that failed leaves the action available for another try. */
await checkCache();
setIsLoading(false);
}, []);
}, [checkCache]);

return (
<div className="flex items-center justify-between">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { clearTTSCache, setCachedTTSAudio, getTTSCacheGeneration } from '~/utils';
import { DeleteCache } from '../DeleteCache';

jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));

const deleteButton = () => screen.getByRole('button', { name: 'com_nav_delete_cache_storage' });

describe('DeleteCache', () => {
const has = jest.fn(async (_name: string) => false);

beforeEach(() => {
has.mockResolvedValue(false);
Object.defineProperty(globalThis, 'caches', {
value: { has, delete: jest.fn(async () => true) },
configurable: true,
});
clearTTSCache();
});

afterEach(() => {
Reflect.deleteProperty(globalThis, 'caches');
});

it('is disabled when nothing is cached in memory or left on disk', async () => {
render(<DeleteCache />);
await waitFor(() => expect(has).toHaveBeenCalled());
expect(deleteButton()).toBeDisabled();
});

it('is enabled while audio is cached in this tab', async () => {
setCachedTTSAudio('key', new Blob(['audio']), getTTSCacheGeneration());
render(<DeleteCache />);
await waitFor(() => expect(deleteButton()).toBeEnabled());
});

it("stays available when an earlier build's cache could not be deleted at startup", async () => {
has.mockImplementation(async (name: string) => name === 'tts-responses');
render(<DeleteCache />);
await waitFor(() => expect(deleteButton()).toBeEnabled());
});

it('stays available after a delete whose legacy cache could not be removed', async () => {
setCachedTTSAudio('key', new Blob(['audio']), getTTSCacheGeneration());
has.mockImplementation(async (name: string) => name === 'tts-responses');
render(<DeleteCache />);
await waitFor(() => expect(deleteButton()).toBeEnabled());

has.mockClear();
fireEvent.click(deleteButton());
fireEvent.click(await screen.findByRole('button', { name: 'com_ui_delete' }));
/** The recheck after the delete finds the legacy cache still there. */
await waitFor(() => expect(has).toHaveBeenCalled());
expect(deleteButton()).toBeEnabled();
});
});
6 changes: 6 additions & 0 deletions client/src/hooks/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import {
import type * as t from 'librechat-data-provider';
import type { ReactNode } from 'react';
import {
clearTTSCache,
isSafeRedirect,
setTTSCacheOwner,
getPostLoginRedirect,
clearPostLoginRedirect,
persistRedirectToSession,
Expand Down Expand Up @@ -66,6 +68,7 @@ const endSessionClientState = (): void => {
clearRetainedFileDeletions();
clearComposerDraftStorage();
endPullRequestSession();
void clearTTSCache();
};
/**
* Only recognized codes override the HTTP status used by the login error translation.
Expand Down Expand Up @@ -119,6 +122,8 @@ const AuthContextProvider = ({
setIsAuthReady(true);
if (isAuthenticated) {
setQueriesEnabled(true);
/** A different account signing in without a logout must not inherit cached audio. */
setTTSCacheOwner(user?.id);
/**
* Any accepted full-auth response supersedes a staged enrollment, whichever path minted
* it. The setup endpoints trust the stored bearer independently of the session, so a
Expand Down Expand Up @@ -259,6 +264,7 @@ const AuthContextProvider = ({
setIsAuthReady(true);
setQueriesEnabled(true);
openFileDeletionRetention();
setTTSCacheOwner(authenticatedUser.id);
navigate(redirect, { replace: true });
},
[navigate, setQueriesEnabled, setUser],
Expand Down
31 changes: 21 additions & 10 deletions client/src/hooks/Input/useTextToSpeechExternal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import {
getTTSCacheKey,
setCachedTTSAudio,
getCachedTTSAudio,
getTTSCacheGeneration,
} from '~/utils';
import { useTextToSpeechMutation, useVoicesQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { useLocalize, useAuthContext } from '~/hooks';
import store from '~/store';

const createFormData = (text: string, voice: string) => {
Expand All @@ -28,6 +34,7 @@ function useTextToSpeechExternal({
index = 0,
}: TUseTTSExternal) {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const voice = useRecoilValue(store.voice);
const cacheTTS = useRecoilValue(store.cacheTTS);
Expand All @@ -36,6 +43,8 @@ function useTextToSpeechExternal({
const [downloadFile, setDownloadFile] = useState(false);

const promiseAudioRef = useRef<HTMLAudioElement | null>(null);
/** The cache generation each request started in, so audio from before a logout is not kept. */
const requestGenerations = useRef(new WeakMap<FormData, number>());

/* Global Audio Variables */
const globalIsFetching = useRecoilValue(store.globalAudioFetchingFamily(index));
Expand Down Expand Up @@ -105,11 +114,12 @@ function useTextToSpeechExternal({
const inputText = (variables.get('input') ?? '') as string;
const audioBlob = new Blob([data], { type: 'audio/mpeg' });

if (cacheTTS && inputText) {
const cache = await caches.open('tts-responses');
const request = new Request(inputText);
const response = new Response(audioBlob);
cache.put(request, response);
const generation = requestGenerations.current.get(variables);
if (cacheTTS && inputText && generation != null) {
/** Not awaited: caching must never delay playback. */
void getTTSCacheKey(inputText, (variables.get('voice') ?? '') as string, user?.id).then(
(cacheKey) => cacheKey && setCachedTTSAudio(cacheKey, audioBlob, generation),
);
}

const blobUrl = URL.createObjectURL(audioBlob);
Expand All @@ -134,6 +144,7 @@ function useTextToSpeechExternal({

const startMutation = (text: string, download: boolean) => {
const formData = createFormData(text, voice ?? '');
requestGenerations.current.set(formData, getTTSCacheGeneration());
setDownloadFile(download);
processAudio(formData);
};
Expand All @@ -147,12 +158,12 @@ function useTextToSpeechExternal({
};

const handleCachedResponse = async (text: string, download: boolean) => {
const cachedResponse = await caches.match(text);
if (!cachedResponse) {
const cacheKey = await getTTSCacheKey(text, voice ?? '', user?.id);
const cachedAudio = cacheKey ? getCachedTTSAudio(cacheKey) : undefined;
if (!cachedAudio) {
return startMutation(text, download);
}
const audioBlob = await cachedResponse.blob();
const blobUrl = URL.createObjectURL(audioBlob);
const blobUrl = URL.createObjectURL(cachedAudio);
if (download) {
downloadAudio(blobUrl);
} else {
Expand Down
Loading
Loading