import Toast from '@/components/base/toast'; import { API_PREFIX, APP_VERSION, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX, } from '@/config'; import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks, } from 'ky'; import ky from 'ky'; const TIME_OUT = 100000; export const ContentType = { json: 'application/json', stream: 'text/event-stream', audio: 'audio/mpeg', form: 'application/x-www-form-urlencoded; charset=UTF-8', download: 'application/octet-stream', // for download downloadZip: 'application/zip', // for download upload: 'multipart/form-data', // for upload }; export type FetchOptionType = Omit & { params?: Record; body?: BodyInit | Record | null; }; const afterResponse204: AfterResponseHook = async ( _request, _options, response, ) => { if (response.status === 204) return Response.json({ result: 'success' }); }; export type ResponseError = { code: string; message: string; status: number; }; const afterResponseErrorCode = ( otherOptions: IOtherOptions, ): AfterResponseHook => { return async ( _request: Request, _options: RequestInit, response: Response, ) => { const clonedResponse = response.clone(); if (!/^([23])\d{2}$/.test(String(clonedResponse.status))) { const bodyJson = clonedResponse.json() as Promise; switch (clonedResponse.status) { case 403: bodyJson.then((data: ResponseError) => { if (!otherOptions.silent) Toast.notify({ type: 'error', message: data.message }); if (data.code === 'already_setup') globalThis.location.href = `${globalThis.location.origin}/signin`; }); break; case 401: return Promise.reject(response); // fall through default: bodyJson.then((data: ResponseError) => { if (!otherOptions.silent) Toast.notify({ type: 'error', message: data.message }); }); return Promise.reject(response); } } }; }; const beforeErrorToast = (otherOptions: IOtherOptions): BeforeErrorHook => { return (error) => { if (!otherOptions.silent) Toast.notify({ type: 'error', message: error.message }); return error; }; }; export async function getAccessToken(isPublicAPI?: boolean) { if (isPublicAPI) { const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]; const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id; const accessToken = localStorage.getItem('token') || JSON.stringify({ version: 2 }); let accessTokenJson: Record = { version: 2 }; try { accessTokenJson = JSON.parse(accessToken); if (isTokenV1(accessTokenJson)) accessTokenJson = getInitialTokenV2(); } catch {} return accessTokenJson[sharedToken]?.[userId || 'DEFAULT']; } else { return localStorage.getItem('console_token') || ''; } } const beforeRequestPublicAuthorization: BeforeRequestHook = async (request) => { const token = await getAccessToken(true); request.headers.set('Authorization', `Bearer ${token}`); }; const beforeRequestAuthorization: BeforeRequestHook = async (request) => { const accessToken = await getAccessToken(); request.headers.set('Authorization', `Bearer ${accessToken}`); }; const baseHooks: Hooks = { afterResponse: [afterResponse204], }; const baseClient = ky.create({ hooks: baseHooks, timeout: TIME_OUT, }); export const getBaseOptions = (): RequestInit => ({ method: 'GET', mode: 'cors', credentials: 'include', // always send cookies、HTTP Basic authentication. headers: new Headers({ 'Content-Type': ContentType.json, }), redirect: 'follow', }); async function base( url: string, options: FetchOptionType = {}, otherOptions: IOtherOptions = {}, ): Promise { const baseOptions = getBaseOptions(); const { params, body, headers, ...init } = Object.assign( {}, baseOptions, options, ); const { isPublicAPI = false, isMarketplaceAPI = false, bodyStringify = true, needAllResponseContent, deleteContentType, getAbortController, } = otherOptions; let base: string; if (isMarketplaceAPI) base = MARKETPLACE_API_PREFIX; else if (isPublicAPI) base = PUBLIC_API_PREFIX; else base = API_PREFIX; if (getAbortController) { const abortController = new AbortController(); getAbortController(abortController); options.signal = abortController.signal; } const fetchPathname = base + (url.startsWith('/') ? url : `/${url}`); if (deleteContentType) (headers as any).delete('Content-Type'); // ! For Marketplace API, help to filter tags added in new version if (isMarketplaceAPI) (headers as any).set('X-Dify-Version', APP_VERSION); const client = baseClient.extend({ hooks: { ...baseHooks, beforeError: [ ...(baseHooks.beforeError || []), beforeErrorToast(otherOptions), ], beforeRequest: [ ...(baseHooks.beforeRequest || []), isPublicAPI && beforeRequestPublicAuthorization, !isPublicAPI && !isMarketplaceAPI && beforeRequestAuthorization, ].filter((h): h is BeforeRequestHook => Boolean(h)), afterResponse: [ ...(baseHooks.afterResponse || []), afterResponseErrorCode(otherOptions), ], }, }); const res = await client(fetchPathname, { ...init, headers, credentials: isMarketplaceAPI ? 'omit' : options.credentials || 'include', retry: { methods: [], }, ...(bodyStringify ? { json: body } : { body: body as BodyInit }), searchParams: params, fetch(resource: RequestInfo | URL, options?: RequestInit) { if (resource instanceof Request && options) { const mergedHeaders = new Headers(options.headers || {}); resource.headers.forEach((value, key) => { mergedHeaders.append(key, value); }); options.headers = mergedHeaders; } return globalThis.fetch(resource, options); }, }); if (needAllResponseContent) return res as T; const contentType = res.headers.get('content-type'); if ( contentType && [ContentType.download, ContentType.audio, ContentType.downloadZip].includes( contentType, ) ) return (await res.blob()) as T; return (await res.json()) as T; } export { base };