Throttle + debounce (#2458)

* (feat:apiClient) replace direct fetch in fe

* (feat)fe: throttle api client

* (feat)fe: debounce hooks
This commit is contained in:
Manish Madan authored and GitHub committed 2026-05-11 23:05:16 +01:00
1 parent e245057822
commit 4d6f360e3a
12 files changed
+511 -236

No files matched your search

+111 -100
View File
@@ -1,3 +1,5 @@
import { withThrottle, type FetchLike } from './throttle';
export const baseURL =
import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
@@ -18,112 +20,121 @@ const getHeaders = (
return headers;
};
const apiClient = {
get: (
url: string,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
fetch(`${baseURL}${url}`, {
method: 'GET',
headers: getHeaders(token, headers),
signal,
}).then((response) => {
return response;
}),
const createClient = (transport: FetchLike) => {
const request = (url: string, init: RequestInit): Promise<Response> =>
transport(`${baseURL}${url}`, init);
post: (
url: string,
data: any,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
fetch(`${baseURL}${url}`, {
method: 'POST',
headers: getHeaders(token, headers),
body: JSON.stringify(data),
signal,
}).then((response) => {
return response;
}),
return {
get: (
url: string,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
request(url, {
method: 'GET',
headers: getHeaders(token, headers),
signal,
}),
postFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> => {
return fetch(`${baseURL}${url}`, {
method: 'POST',
headers: getHeaders(token, headers, true),
body: formData,
signal,
});
},
post: (
url: string,
data: any,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
request(url, {
method: 'POST',
headers: getHeaders(token, headers),
body: JSON.stringify(data),
signal,
}),
put: (
url: string,
data: any,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
fetch(`${baseURL}${url}`, {
method: 'PUT',
headers: getHeaders(token, headers),
body: JSON.stringify(data),
signal,
}).then((response) => {
return response;
}),
postFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> =>
request(url, {
method: 'POST',
headers: getHeaders(token, headers, true),
body: formData,
signal,
}),
patch: (
url: string,
data: any,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
fetch(`${baseURL}${url}`, {
method: 'PATCH',
headers: getHeaders(token, headers),
body: JSON.stringify(data),
signal,
}).then((response) => {
return response;
}),
put: (
url: string,
data: any,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
request(url, {
method: 'PUT',
headers: getHeaders(token, headers),
body: JSON.stringify(data),
signal,
}),
putFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> => {
return fetch(`${baseURL}${url}`, {
method: 'PUT',
headers: getHeaders(token, headers, true),
body: formData,
signal,
});
},
patch: (
url: string,
data: any,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
request(url, {
method: 'PATCH',
headers: getHeaders(token, headers),
body: JSON.stringify(data),
signal,
}),
delete: (
url: string,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
fetch(`${baseURL}${url}`, {
method: 'DELETE',
headers: getHeaders(token, headers),
signal,
}).then((response) => {
return response;
}),
putFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> =>
request(url, {
method: 'PUT',
headers: getHeaders(token, headers, true),
body: formData,
signal,
}),
delete: (
url: string,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<any> =>
request(url, {
method: 'DELETE',
headers: getHeaders(token, headers),
signal,
}),
};
};
const apiClient = createClient((url, init) => fetch(url, init));
// Throttled client for endpoints that fan out, are polled, or are commonly
// requested concurrently from multiple components. Shares a single concurrency
// budget and de-duplicates identical in-flight GETs.
export const throttledApiClient = createClient(
withThrottle((url, init) => fetch(url, init), { debugLabel: 'api' }),
);
if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).__apiClient = apiClient;
(window as unknown as Record<string, unknown>).__throttledApiClient =
throttledApiClient;
(window as unknown as Record<string, unknown>).__baseURL = baseURL;
}
export default apiClient;
+6
View File
@@ -43,6 +43,11 @@ const endpoints = {
DELETE_TOOL: '/api/delete_tool',
PARSE_SPEC: '/api/parse_spec',
SYNC_CONNECTOR: '/api/connectors/sync',
CONNECTOR_AUTH: (provider: string) =>
`/api/connectors/auth?provider=${provider}`,
CONNECTOR_FILES: '/api/connectors/files',
CONNECTOR_VALIDATE_SESSION: '/api/connectors/validate-session',
CONNECTOR_DISCONNECT: '/api/connectors/disconnect',
GET_CHUNKS: (
docId: string,
page: number,
@@ -59,6 +64,7 @@ const endpoints = {
UPDATE_CHUNK: '/api/update_chunk',
STORE_ATTACHMENT: '/api/store_attachment',
STT: '/api/stt',
TTS: '/api/tts',
LIVE_STT_START: '/api/stt/live/start',
LIVE_STT_CHUNK: '/api/stt/live/chunk',
LIVE_STT_FINISH: '/api/stt/live/finish',
+57 -14
View File
@@ -1,11 +1,12 @@
import { getSessionToken } from '../../utils/providerUtils';
import apiClient from '../client';
import apiClient, { throttledApiClient } from '../client';
import endpoints from '../endpoints';
const userService = {
getConfig: (): Promise<any> => apiClient.get(endpoints.USER.CONFIG, null),
getConfig: (): Promise<any> =>
throttledApiClient.get(endpoints.USER.CONFIG, null),
getNewToken: (): Promise<any> =>
apiClient.get(endpoints.USER.NEW_TOKEN, null),
throttledApiClient.get(endpoints.USER.NEW_TOKEN, null),
getDocs: (token: string | null): Promise<any> =>
apiClient.get(`${endpoints.USER.DOCS}`, token),
getDocsWithPagination: (query: string, token: string | null): Promise<any> =>
@@ -17,9 +18,9 @@ const userService = {
deleteAPIKey: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.DELETE_API_KEY, data, token),
getAgent: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT(id), token),
throttledApiClient.get(endpoints.USER.AGENT(id), token),
getAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENTS, token),
throttledApiClient.get(endpoints.USER.AGENTS, token),
createAgent: (data: any, token: string | null): Promise<any> =>
apiClient.postFormData(endpoints.USER.CREATE_AGENT, data, token),
updateAgent: (
@@ -31,19 +32,19 @@ const userService = {
deleteAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.DELETE_AGENT(id), token),
getPinnedAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.PINNED_AGENTS, token),
throttledApiClient.get(endpoints.USER.PINNED_AGENTS, token),
togglePinAgent: (id: string, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.TOGGLE_PIN_AGENT(id), {}, token),
getSharedAgent: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.SHARED_AGENT(id), token),
getSharedAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.SHARED_AGENTS, token),
throttledApiClient.get(endpoints.USER.SHARED_AGENTS, token),
shareAgent: (data: any, token: string | null): Promise<any> =>
apiClient.put(endpoints.USER.SHARE_AGENT, data, token),
removeSharedAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.REMOVE_SHARED_AGENT(id), token),
getTemplateAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.TEMPLATE_AGENTS, token),
throttledApiClient.get(endpoints.USER.TEMPLATE_AGENTS, token),
adoptAgent: (id: string, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.ADOPT_AGENT(id), {}, token),
getAgentWebhook: (id: string, token: string | null): Promise<any> =>
@@ -61,7 +62,7 @@ const userService = {
deletePath: (docPath: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.DELETE_PATH(docPath), token),
getTaskStatus: (task_id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.TASK_STATUS(task_id), token),
throttledApiClient.get(endpoints.USER.TASK_STATUS(task_id), token),
getMessageAnalytics: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.MESSAGE_ANALYTICS, data, token),
getTokenAnalytics: (data: any, token: string | null): Promise<any> =>
@@ -149,7 +150,7 @@ const userService = {
path?: string,
search?: string,
): Promise<any> =>
apiClient.get(
throttledApiClient.get(
endpoints.USER.GET_CHUNKS(docId, page, perPage, path, search),
token,
),
@@ -164,7 +165,7 @@ const userService = {
updateChunk: (data: any, token: string | null): Promise<any> =>
apiClient.put(endpoints.USER.UPDATE_CHUNK, data, token),
getDirectoryStructure: (docId: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.DIRECTORY_STRUCTURE(docId), token),
throttledApiClient.get(endpoints.USER.DIRECTORY_STRUCTURE(docId), token),
manageSourceFiles: (data: FormData, token: string | null): Promise<any> =>
apiClient.postFormData(endpoints.USER.MANAGE_SOURCE_FILES, data, token),
testMCPConnection: (data: any, token: string | null): Promise<any> =>
@@ -172,9 +173,9 @@ const userService = {
saveMCPServer: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.MCP_SAVE_SERVER, data, token),
getMCPOAuthStatus: (task_id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.MCP_OAUTH_STATUS(task_id), token),
throttledApiClient.get(endpoints.USER.MCP_OAUTH_STATUS(task_id), token),
getMCPAuthStatus: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.MCP_AUTH_STATUS, token),
throttledApiClient.get(endpoints.USER.MCP_AUTH_STATUS, token),
syncConnector: (
docId: string,
provider: string,
@@ -191,8 +192,50 @@ const userService = {
token,
);
},
getConnectorAuthUrl: (provider: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.CONNECTOR_AUTH(provider), token),
getConnectorFiles: (
data: any,
token: string | null,
signal?: AbortSignal,
): Promise<any> =>
throttledApiClient.post(
endpoints.USER.CONNECTOR_FILES,
data,
token,
{},
signal,
),
validateConnectorSession: (
provider: string,
token: string | null,
): Promise<any> =>
apiClient.post(
endpoints.USER.CONNECTOR_VALIDATE_SESSION,
{
provider,
session_token: getSessionToken(provider),
},
token,
),
disconnectConnector: (
provider: string,
sessionToken: string,
token: string | null,
): Promise<any> =>
apiClient.post(
endpoints.USER.CONNECTOR_DISCONNECT,
{ provider, session_token: sessionToken },
token,
),
textToSpeech: (
text: string,
token: string | null,
signal?: AbortSignal,
): Promise<any> =>
apiClient.post(endpoints.USER.TTS, { text }, token, {}, signal),
getAgentFolders: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT_FOLDERS, token),
throttledApiClient.get(endpoints.USER.AGENT_FOLDERS, token),
createAgentFolder: (
data: { name: string; parent_id?: string },
token: string | null,
+223
View File
@@ -0,0 +1,223 @@
/**
* Transport-layer middleware factory for the frontend API layer.
*/
export type FetchLike = (
input: string,
init?: RequestInit,
) => Promise<Response>;
export interface ThrottleConfig {
maxConcurrentGlobal?: number;
maxConcurrentPerRoute?: number;
dedupe?: boolean;
dedupeKey?: (url: string, init?: RequestInit) => string | false;
debugLabel?: string;
}
const DEFAULT_MAX_CONCURRENT_GLOBAL = 8;
const DEFAULT_MAX_CONCURRENT_PER_ROUTE = 3;
type QueueItem = {
run: () => void;
signal?: AbortSignal;
onAbort: () => void;
};
function routeKey(method: string, url: string): string {
let pathname = url;
try {
pathname = new URL(url, 'http://_').pathname;
} catch {
pathname = url.split('?')[0];
}
return `${method.toUpperCase()} ${pathname}`;
}
function abortError(): DOMException {
return new DOMException('The operation was aborted.', 'AbortError');
}
interface ThrottleState {
perRouteQueues: Map<string, QueueItem[]>;
inflightPerRoute: Map<string, number>;
inflightGets: Map<string, Promise<Response>>;
inflightGlobal: number;
}
function createState(): ThrottleState {
return {
perRouteQueues: new Map(),
inflightPerRoute: new Map(),
inflightGets: new Map(),
inflightGlobal: 0,
};
}
export function withThrottle(
fetchLike: FetchLike,
config: ThrottleConfig = {},
): FetchLike & { __reset: () => void } {
const maxGlobal = config.maxConcurrentGlobal ?? DEFAULT_MAX_CONCURRENT_GLOBAL;
const maxPerRoute =
config.maxConcurrentPerRoute ?? DEFAULT_MAX_CONCURRENT_PER_ROUTE;
const dedupeEnabled = config.dedupe !== false;
const state = createState();
// Toggle in DevTools with: localStorage.setItem('debug:throttle', '1')
const isDebug = (): boolean => {
try {
return (
typeof localStorage !== 'undefined' &&
localStorage.getItem('debug:throttle') === '1'
);
} catch {
return false;
}
};
const log = (
event: string,
key: string,
extra?: Record<string, unknown>,
): void => {
if (!isDebug()) return;
const queued = state.perRouteQueues.get(key)?.length ?? 0;
const perRoute = state.inflightPerRoute.get(key) ?? 0;
const tag = config.debugLabel
? `[throttle:${config.debugLabel}]`
: '[throttle]';
console.debug(
`${tag} ${event} ${key} | inflight=${state.inflightGlobal}/${maxGlobal} route=${perRoute}/${maxPerRoute} queued=${queued}`,
extra ?? '',
);
};
const canDispatch = (key: string): boolean => {
const perRoute = state.inflightPerRoute.get(key) ?? 0;
return state.inflightGlobal < maxGlobal && perRoute < maxPerRoute;
};
const pumpQueues = (): void => {
for (const [key, queue] of state.perRouteQueues) {
while (queue.length > 0 && canDispatch(key)) {
const item = queue.shift()!;
item.signal?.removeEventListener('abort', item.onAbort);
item.run();
}
if (queue.length === 0) state.perRouteQueues.delete(key);
}
};
const enqueue = (key: string, item: QueueItem): void => {
let queue = state.perRouteQueues.get(key);
if (!queue) {
queue = [];
state.perRouteQueues.set(key, queue);
}
queue.push(item);
};
const acquireSlot = (key: string, signal?: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(abortError());
return;
}
const item: QueueItem = {
signal,
run: () => {
state.inflightGlobal += 1;
state.inflightPerRoute.set(
key,
(state.inflightPerRoute.get(key) ?? 0) + 1,
);
resolve();
},
onAbort: () => {
const queue = state.perRouteQueues.get(key);
if (queue) {
const idx = queue.indexOf(item);
if (idx >= 0) queue.splice(idx, 1);
}
log('abort-queued', key);
reject(abortError());
},
};
const queued = state.perRouteQueues.get(key);
if ((!queued || queued.length === 0) && canDispatch(key)) {
item.run();
log('dispatch', key);
return;
}
signal?.addEventListener('abort', item.onAbort, { once: true });
enqueue(key, item);
log('queued', key);
});
const releaseSlot = (key: string): void => {
state.inflightGlobal = Math.max(0, state.inflightGlobal - 1);
const next = (state.inflightPerRoute.get(key) ?? 1) - 1;
if (next <= 0) state.inflightPerRoute.delete(key);
else state.inflightPerRoute.set(key, next);
log('release', key);
pumpQueues();
};
const wrapped = (async (url, init = {}) => {
const method = (init.method ?? 'GET').toUpperCase();
const signal = init.signal ?? undefined;
const key = routeKey(method, url);
// Dedupe is restricted to GETs without a caller-supplied AbortSignal:
// sharing a single underlying fetch across waiters means an abort by one
// caller would reject the others, which is not the contract callers expect.
const customKey = config.dedupeKey?.(url, init);
const dedupeAllowed =
dedupeEnabled &&
customKey !== false &&
method === 'GET' &&
!init.body &&
!signal;
const dedupeKey = typeof customKey === 'string' ? customKey : `GET ${url}`;
if (dedupeAllowed) {
const existing = state.inflightGets.get(dedupeKey);
if (existing) {
log('dedupe-hit', key, { dedupeKey });
return existing.then((r) => r.clone());
}
}
const run = async (): Promise<Response> => {
await acquireSlot(key, signal);
try {
return await fetchLike(url, init);
} finally {
releaseSlot(key);
}
};
if (dedupeAllowed) {
const promise = run();
state.inflightGets.set(dedupeKey, promise);
promise.finally(() => {
if (state.inflightGets.get(dedupeKey) === promise) {
state.inflightGets.delete(dedupeKey);
}
});
return promise.then((r) => r.clone());
}
return run();
}) as FetchLike & { __reset: () => void };
wrapped.__reset = () => {
state.perRouteQueues.clear();
state.inflightPerRoute.clear();
state.inflightGets.clear();
state.inflightGlobal = 0;
};
return wrapped;
}
+9 -11
View File
@@ -11,6 +11,7 @@ import NoFilesIcon from '../assets/no-files.svg';
import SearchIcon from '../assets/search.svg';
import {
useDarkTheme,
useDebouncedValue,
useLoaderState,
useMediaQuery,
useOutsideAlerter,
@@ -130,6 +131,7 @@ const Chunks: React.FC<ChunksProps> = ({
const [totalChunks, setTotalChunks] = useState(0);
const [loading, setLoading] = useLoaderState(true);
const [searchTerm, setSearchTerm] = useState<string>('');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 300);
const [editingChunk, setEditingChunk] = useState<ChunkType | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [editingText, setEditingText] = useState('');
@@ -151,7 +153,7 @@ const Chunks: React.FC<ChunksProps> = ({
perPage,
token,
path,
searchTerm,
debouncedSearchTerm,
);
if (!response.ok) {
@@ -276,16 +278,12 @@ const Chunks: React.FC<ChunksProps> = ({
};
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (page !== 1) {
setPage(1);
} else {
fetchChunks();
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [searchTerm]);
if (page !== 1) {
setPage(1);
} else {
fetchChunks();
}
}, [debouncedSearchTerm]);
useEffect(() => {
!loading && fetchChunks();
+4 -6
View File
@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
@@ -68,12 +69,9 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
completedRef.current = false;
cleanup();
const apiHost = import.meta.env.VITE_API_HOST;
const authResponse = await fetch(
`${apiHost}/api/connectors/auth?provider=${provider}`,
{
headers: { Authorization: `Bearer ${token}` },
},
const authResponse = await userService.getConnectorAuthUrl(
provider,
token,
);
if (!authResponse.ok) {
+32 -62
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import userService from '../api/services/userService';
import { formatBytes } from '../utils/stringUtils';
import { formatDate } from '../utils/dateTimeUtils';
import {
@@ -22,6 +23,7 @@ import {
TableHeader,
TableCell,
} from './Table';
import { useDebouncedCallback } from '../hooks';
interface CloudFile {
id: string;
@@ -100,7 +102,6 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
const [activeTab, setActiveTab] = useState<'my_files' | 'shared'>('my_files');
const scrollContainerRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const isFolder = (file: CloudFile) => {
@@ -126,7 +127,6 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
setIsLoading(true);
const apiHost = import.meta.env.VITE_API_HOST;
if (!pageToken) {
setFiles([]);
}
@@ -141,15 +141,11 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
search_query: searchQuery,
shared: shared,
};
const response = await fetch(`${apiHost}/api/connectors/files`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
const response = await userService.getConnectorFiles(
body,
token,
controller.signal,
);
const data = await response.json();
if (data.success) {
@@ -187,20 +183,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
}
try {
const apiHost = import.meta.env.VITE_API_HOST;
const validateResponse = await fetch(
`${apiHost}/api/connectors/validate-session`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: provider,
session_token: sessionToken,
}),
},
const validateResponse = await userService.validateConnectorSession(
provider,
token,
);
if (!validateResponse.ok) {
@@ -292,32 +277,26 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
abortControllerRef.current?.abort();
};
}, []);
const debouncedLoadFiles = useDebouncedCallback((query: string) => {
const sessionToken = getSessionToken(provider);
if (sessionToken) {
loadCloudFiles(
sessionToken,
currentFolderId,
undefined,
query,
activeTab === 'shared' && !currentFolderId,
);
}
}, 300);
const handleSearchChange = (query: string) => {
setSearchQuery(query);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
const sessionToken = getSessionToken(provider);
if (sessionToken) {
loadCloudFiles(
sessionToken,
currentFolderId,
undefined,
query,
activeTab === 'shared' && !currentFolderId,
);
}
}, 300);
debouncedLoadFiles(query);
};
const handleFolderClick = (folderId: string, folderName: string) => {
@@ -424,23 +403,14 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
onDisconnect={() => {
const sessionToken = getSessionToken(provider);
if (sessionToken) {
const apiHost = import.meta.env.VITE_API_HOST;
fetch(`${apiHost}/api/connectors/disconnect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: provider,
session_token: sessionToken,
}),
}).catch((err) =>
console.error(
`Error disconnecting from ${getProviderConfig(provider).displayName}:`,
err,
),
);
userService
.disconnectConnector(provider, sessionToken, token)
.catch((err) =>
console.error(
`Error disconnecting from ${getProviderConfig(provider).displayName}:`,
err,
),
);
}
removeSessionToken(provider);
+6 -12
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import useDrivePicker from 'react-google-drive-picker';
import userService from '../api/services/userService';
import ConnectorAuth from './ConnectorAuth';
import {
getSessionToken,
@@ -199,18 +200,11 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
const sessionToken = getSessionToken('google_drive');
if (sessionToken) {
try {
const apiHost = import.meta.env.VITE_API_HOST;
await fetch(`${apiHost}/api/connectors/disconnect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: 'google_drive',
session_token: sessionToken,
}),
});
await userService.disconnectConnector(
'google_drive',
sessionToken,
token,
);
} catch (err) {
console.error('Error disconnecting from Google Drive:', err);
}
@@ -2,8 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import Speaker from '../assets/speaker.svg?react';
import Stopspeech from '../assets/stopspeech.svg?react';
import LoadingIcon from '../assets/Loading.svg?react'; // Add a loading icon SVG here
const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
import userService from '../api/services/userService';
let currentlyPlayingAudio: {
audio: HTMLAudioElement;
@@ -114,12 +113,11 @@ export default function SpeakButton({ text }: { text: string }) {
},
};
const response = await fetch(apiHost + '/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
signal: abortController.signal,
});
const response = await userService.textToSpeech(
text,
null,
abortController.signal,
);
const data = await response.json();
abortControllerRef.current = null;
+52 -1
View File
@@ -1,4 +1,10 @@
import { useEffect, RefObject, useState } from 'react';
import {
useCallback,
useEffect,
useRef,
useState,
RefObject,
} from 'react';
export function useOutsideAlerter<T extends HTMLElement>(
ref: RefObject<T | null>,
@@ -113,6 +119,51 @@ export function useDarkTheme() {
return [isDarkTheme, toggleTheme, componentMounted] as const;
}
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
export function useDebouncedCallback<A extends unknown[]>(
callback: (...args: A) => void,
delay = 300,
): ((...args: A) => void) & { cancel: () => void } {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => cancel, [cancel]);
const debounced = useCallback(
(...args: A) => {
cancel();
timerRef.current = setTimeout(() => {
timerRef.current = null;
callbackRef.current(...args);
}, delay);
},
[delay, cancel],
);
return Object.assign(debounced, { cancel });
}
export function useLoaderState(
initialState = false,
delay = 250,
+2 -10
View File
@@ -17,7 +17,7 @@ import ContextMenu, { MenuOption } from '../components/ContextMenu';
import Pagination from '../components/DocumentPagination';
import DropdownMenu from '../components/DropdownMenu';
import SkeletonLoader from '../components/SkeletonLoader';
import { useDarkTheme, useLoaderState } from '../hooks';
import { useDarkTheme, useDebouncedValue, useLoaderState } from '../hooks';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
@@ -58,7 +58,7 @@ export default function Sources({
const token = useSelector(selectToken);
const [searchTerm, setSearchTerm] = useState<string>('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState<string>('');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const [modalState, setModalState] = useState<ActiveState>('INACTIVE');
const [isOnboarding, setIsOnboarding] = useState<boolean>(false);
const [loading, setLoading] = useLoaderState(false);
@@ -117,14 +117,6 @@ export default function Sources({
document: null,
});
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
const refreshDocs = useCallback(
(
field: 'date' | 'tokens' | undefined,
+3 -12
View File
@@ -3,6 +3,8 @@
* Follows the convention: {provider}_session_token
*/
import userService from '../api/services/userService';
export const getSessionToken = (provider: string): string | null => {
return localStorage.getItem(`${provider}_session_token`);
};
@@ -19,16 +21,5 @@ export const validateProviderSession = async (
token: string | null,
provider: string,
) => {
const apiHost = import.meta.env.VITE_API_HOST;
return await fetch(`${apiHost}/api/connectors/validate-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: provider,
session_token: getSessionToken(provider),
}),
});
return await userService.validateConnectorSession(provider, token);
};