mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 16:15:14 +00:00
Throttle + debounce (#2458)
* (feat:apiClient) replace direct fetch in fe * (feat)fe: throttle api client * (feat)fe: debounce hooks
This commit is contained in:
1 parent
e245057822
commit
4d6f360e3a
12 files changed
+511
-236
No files matched your search
+111
-100
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
};
|
||||
Reference in new issue
Block a user