diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 530d426d..37a6d08e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 => - 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 => + transport(`${baseURL}${url}`, init); - post: ( - url: string, - data: any, - token: string | null, - headers = {}, - signal?: AbortSignal, - ): Promise => - 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 => + request(url, { + method: 'GET', + headers: getHeaders(token, headers), + signal, + }), - postFormData: ( - url: string, - formData: FormData, - token: string | null, - headers = {}, - signal?: AbortSignal, - ): Promise => { - 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 => + 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 => - 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 => + request(url, { + method: 'POST', + headers: getHeaders(token, headers, true), + body: formData, + signal, + }), - patch: ( - url: string, - data: any, - token: string | null, - headers = {}, - signal?: AbortSignal, - ): Promise => - 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 => + 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 => { - 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 => + request(url, { + method: 'PATCH', + headers: getHeaders(token, headers), + body: JSON.stringify(data), + signal, + }), - delete: ( - url: string, - token: string | null, - headers = {}, - signal?: AbortSignal, - ): Promise => - 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 => + request(url, { + method: 'PUT', + headers: getHeaders(token, headers, true), + body: formData, + signal, + }), + + delete: ( + url: string, + token: string | null, + headers = {}, + signal?: AbortSignal, + ): Promise => + 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).__apiClient = apiClient; + (window as unknown as Record).__throttledApiClient = + throttledApiClient; + (window as unknown as Record).__baseURL = baseURL; +} + export default apiClient; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index d277e639..07685d1f 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -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', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 6e8c5f15..28488f63 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -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 => apiClient.get(endpoints.USER.CONFIG, null), + getConfig: (): Promise => + throttledApiClient.get(endpoints.USER.CONFIG, null), getNewToken: (): Promise => - apiClient.get(endpoints.USER.NEW_TOKEN, null), + throttledApiClient.get(endpoints.USER.NEW_TOKEN, null), getDocs: (token: string | null): Promise => apiClient.get(`${endpoints.USER.DOCS}`, token), getDocsWithPagination: (query: string, token: string | null): Promise => @@ -17,9 +18,9 @@ const userService = { deleteAPIKey: (data: any, token: string | null): Promise => apiClient.post(endpoints.USER.DELETE_API_KEY, data, token), getAgent: (id: string, token: string | null): Promise => - apiClient.get(endpoints.USER.AGENT(id), token), + throttledApiClient.get(endpoints.USER.AGENT(id), token), getAgents: (token: string | null): Promise => - apiClient.get(endpoints.USER.AGENTS, token), + throttledApiClient.get(endpoints.USER.AGENTS, token), createAgent: (data: any, token: string | null): Promise => apiClient.postFormData(endpoints.USER.CREATE_AGENT, data, token), updateAgent: ( @@ -31,19 +32,19 @@ const userService = { deleteAgent: (id: string, token: string | null): Promise => apiClient.delete(endpoints.USER.DELETE_AGENT(id), token), getPinnedAgents: (token: string | null): Promise => - apiClient.get(endpoints.USER.PINNED_AGENTS, token), + throttledApiClient.get(endpoints.USER.PINNED_AGENTS, token), togglePinAgent: (id: string, token: string | null): Promise => apiClient.post(endpoints.USER.TOGGLE_PIN_AGENT(id), {}, token), getSharedAgent: (id: string, token: string | null): Promise => apiClient.get(endpoints.USER.SHARED_AGENT(id), token), getSharedAgents: (token: string | null): Promise => - apiClient.get(endpoints.USER.SHARED_AGENTS, token), + throttledApiClient.get(endpoints.USER.SHARED_AGENTS, token), shareAgent: (data: any, token: string | null): Promise => apiClient.put(endpoints.USER.SHARE_AGENT, data, token), removeSharedAgent: (id: string, token: string | null): Promise => apiClient.delete(endpoints.USER.REMOVE_SHARED_AGENT(id), token), getTemplateAgents: (token: string | null): Promise => - apiClient.get(endpoints.USER.TEMPLATE_AGENTS, token), + throttledApiClient.get(endpoints.USER.TEMPLATE_AGENTS, token), adoptAgent: (id: string, token: string | null): Promise => apiClient.post(endpoints.USER.ADOPT_AGENT(id), {}, token), getAgentWebhook: (id: string, token: string | null): Promise => @@ -61,7 +62,7 @@ const userService = { deletePath: (docPath: string, token: string | null): Promise => apiClient.get(endpoints.USER.DELETE_PATH(docPath), token), getTaskStatus: (task_id: string, token: string | null): Promise => - 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 => apiClient.post(endpoints.USER.MESSAGE_ANALYTICS, data, token), getTokenAnalytics: (data: any, token: string | null): Promise => @@ -149,7 +150,7 @@ const userService = { path?: string, search?: string, ): Promise => - 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 => apiClient.put(endpoints.USER.UPDATE_CHUNK, data, token), getDirectoryStructure: (docId: string, token: string | null): Promise => - apiClient.get(endpoints.USER.DIRECTORY_STRUCTURE(docId), token), + throttledApiClient.get(endpoints.USER.DIRECTORY_STRUCTURE(docId), token), manageSourceFiles: (data: FormData, token: string | null): Promise => apiClient.postFormData(endpoints.USER.MANAGE_SOURCE_FILES, data, token), testMCPConnection: (data: any, token: string | null): Promise => @@ -172,9 +173,9 @@ const userService = { saveMCPServer: (data: any, token: string | null): Promise => apiClient.post(endpoints.USER.MCP_SAVE_SERVER, data, token), getMCPOAuthStatus: (task_id: string, token: string | null): Promise => - 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 => - 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 => + apiClient.get(endpoints.USER.CONNECTOR_AUTH(provider), token), + getConnectorFiles: ( + data: any, + token: string | null, + signal?: AbortSignal, + ): Promise => + throttledApiClient.post( + endpoints.USER.CONNECTOR_FILES, + data, + token, + {}, + signal, + ), + validateConnectorSession: ( + provider: string, + token: string | null, + ): Promise => + apiClient.post( + endpoints.USER.CONNECTOR_VALIDATE_SESSION, + { + provider, + session_token: getSessionToken(provider), + }, + token, + ), + disconnectConnector: ( + provider: string, + sessionToken: string, + token: string | null, + ): Promise => + apiClient.post( + endpoints.USER.CONNECTOR_DISCONNECT, + { provider, session_token: sessionToken }, + token, + ), + textToSpeech: ( + text: string, + token: string | null, + signal?: AbortSignal, + ): Promise => + apiClient.post(endpoints.USER.TTS, { text }, token, {}, signal), getAgentFolders: (token: string | null): Promise => - apiClient.get(endpoints.USER.AGENT_FOLDERS, token), + throttledApiClient.get(endpoints.USER.AGENT_FOLDERS, token), createAgentFolder: ( data: { name: string; parent_id?: string }, token: string | null, diff --git a/frontend/src/api/throttle.ts b/frontend/src/api/throttle.ts new file mode 100644 index 00000000..55cf0d58 --- /dev/null +++ b/frontend/src/api/throttle.ts @@ -0,0 +1,223 @@ +/** + * Transport-layer middleware factory for the frontend API layer. + */ + +export type FetchLike = ( + input: string, + init?: RequestInit, +) => Promise; + +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; + inflightPerRoute: Map; + inflightGets: Map>; + 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, + ): 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 => + 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 => { + 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; +} diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx index f66f3cbd..6f9adc86 100644 --- a/frontend/src/components/Chunks.tsx +++ b/frontend/src/components/Chunks.tsx @@ -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 = ({ const [totalChunks, setTotalChunks] = useState(0); const [loading, setLoading] = useLoaderState(true); const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebouncedValue(searchTerm, 300); const [editingChunk, setEditingChunk] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [editingText, setEditingText] = useState(''); @@ -151,7 +153,7 @@ const Chunks: React.FC = ({ perPage, token, path, - searchTerm, + debouncedSearchTerm, ); if (!response.ok) { @@ -276,16 +278,12 @@ const Chunks: React.FC = ({ }; 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(); diff --git a/frontend/src/components/ConnectorAuth.tsx b/frontend/src/components/ConnectorAuth.tsx index d857b8ec..951e71fb 100644 --- a/frontend/src/components/ConnectorAuth.tsx +++ b/frontend/src/components/ConnectorAuth.tsx @@ -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 = ({ 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) { diff --git a/frontend/src/components/FilePicker.tsx b/frontend/src/components/FilePicker.tsx index adaef433..7b8e3892 100644 --- a/frontend/src/components/FilePicker.tsx +++ b/frontend/src/components/FilePicker.tsx @@ -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 = ({ const [activeTab, setActiveTab] = useState<'my_files' | 'shared'>('my_files'); const scrollContainerRef = useRef(null); - const searchTimeoutRef = useRef | null>(null); const abortControllerRef = useRef(null); const isFolder = (file: CloudFile) => { @@ -126,7 +127,6 @@ export const FilePicker: React.FC = ({ setIsLoading(true); - const apiHost = import.meta.env.VITE_API_HOST; if (!pageToken) { setFiles([]); } @@ -141,15 +141,11 @@ export const FilePicker: React.FC = ({ 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 = ({ } 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 = ({ 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 = ({ 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); diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index bc37ed2a..2b463de0 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -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 = ({ 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); } diff --git a/frontend/src/components/TextToSpeechButton.tsx b/frontend/src/components/TextToSpeechButton.tsx index deda84e4..4a640a4f 100644 --- a/frontend/src/components/TextToSpeechButton.tsx +++ b/frontend/src/components/TextToSpeechButton.tsx @@ -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; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 49e4cd55..533cd00a 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,4 +1,10 @@ -import { useEffect, RefObject, useState } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + RefObject, +} from 'react'; export function useOutsideAlerter( ref: RefObject, @@ -113,6 +119,51 @@ export function useDarkTheme() { return [isDarkTheme, toggleTheme, componentMounted] as const; } +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} + +export function useDebouncedCallback( + callback: (...args: A) => void, + delay = 300, +): ((...args: A) => void) & { cancel: () => void } { + const callbackRef = useRef(callback); + const timerRef = useRef | 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, diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index 2272c57d..ad8bd99e 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -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(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); const [modalState, setModalState] = useState('INACTIVE'); const [isOnboarding, setIsOnboarding] = useState(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, diff --git a/frontend/src/utils/providerUtils.ts b/frontend/src/utils/providerUtils.ts index 01f25c5c..837df1c7 100644 --- a/frontend/src/utils/providerUtils.ts +++ b/frontend/src/utils/providerUtils.ts @@ -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); };