diff --git a/web/package.json b/web/package.json index 8d69bbc209..7cbe778790 100644 --- a/web/package.json +++ b/web/package.json @@ -64,6 +64,7 @@ "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "katex": "^0.16.11", + "ky": "^1.7.2", "lamejs": "^1.2.1", "lexical": "^0.18.0", "lodash-es": "^4.17.21", @@ -84,9 +85,9 @@ "react-hook-form": "^7.53.1", "react-i18next": "^15.1.0", "react-infinite-scroll-component": "^6.1.0", + "react-markdown": "^9.0.1", "react-multi-email": "^1.0.25", "react-papaparse": "^4.4.0", - "react-markdown": "^9.0.1", "react-slider": "^2.0.6", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^15.6.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1a98266fdc..236c23c9c6 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: katex: specifier: ^0.16.11 version: 0.16.11 + ky: + specifier: ^1.7.2 + version: 1.7.2 lamejs: specifier: ^1.2.1 version: 1.2.1 @@ -5612,6 +5615,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + ky@1.7.2: + resolution: {integrity: sha512-OzIvbHKKDpi60TnF9t7UUVAF1B4mcqc02z5PIvrm08Wyb+yOcz63GRvEuVxNT18a9E1SrNouhB4W2NNLeD7Ykg==} + engines: {node: '>=18'} + lamejs@1.2.1: resolution: {integrity: sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==} @@ -14744,6 +14751,8 @@ snapshots: kleur@4.1.5: {} + ky@1.7.2: {} + lamejs@1.2.1: dependencies: use-strict: 1.0.1 diff --git a/web/service/base.ts b/web/service/base.ts index e1a04217c7..0f9059e617 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -1,4 +1,4 @@ -import { API_PREFIX, IS_CE_EDITION, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX } from '@/config' +import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config' import { refreshAccessTokenOrRelogin } from './refresh-token' import Toast from '@/app/components/base/toast' import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/base/chat/chat/type' @@ -17,27 +17,10 @@ import type { WorkflowStartedResponse, } from '@/types/workflow' import { removeAccessToken } from '@/app/components/share/utils' +import type { FetchOptionType, ResponseError } from './fetch' +import { ContentType, base, baseOptions, getPublicToken } from './fetch' const TIME_OUT = 100000 -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 - upload: 'multipart/form-data', // for upload -} - -const baseOptions = { - method: 'GET', - mode: 'cors', - credentials: 'include', // always send cookies、HTTP Basic authentication. - headers: new Headers({ - 'Content-Type': ContentType.json, - }), - redirect: 'follow', -} - export type IOnDataMoreInfo = { conversationId?: string taskId?: string @@ -100,17 +83,6 @@ export type IOtherOptions = { onTextReplace?: IOnTextReplace } -type ResponseError = { - code: string - message: string - status: number -} - -type FetchOptionType = Omit & { - params?: Record - body?: BodyInit | Record | null -} - function unicodeToChar(text: string) { if (!text) return '' @@ -277,153 +249,13 @@ const handleStream = ( read() } -const baseFetch = ( - url: string, - fetchOptions: FetchOptionType, - { - isPublicAPI = false, - isMarketplaceAPI = false, - bodyStringify = true, - needAllResponseContent, - deleteContentType, - getAbortController, - silent, - }: IOtherOptions, -): Promise => { - const options: typeof baseOptions & FetchOptionType = Object.assign({}, baseOptions, fetchOptions) - if (isMarketplaceAPI) - options.credentials = 'omit' - - if (getAbortController) { - const abortController = new AbortController() - getAbortController(abortController) - options.signal = abortController.signal - } - - if (isPublicAPI) { - const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0] - const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' }) - let accessTokenJson = { [sharedToken]: '' } - try { - accessTokenJson = JSON.parse(accessToken) - } - catch (e) { - - } - options.headers.set('Authorization', `Bearer ${accessTokenJson[sharedToken]}`) - } - else if (!isMarketplaceAPI) { - const accessToken = localStorage.getItem('console_token') || '' - options.headers.set('Authorization', `Bearer ${accessToken}`) - } - - if (deleteContentType) { - options.headers.delete('Content-Type') - } - else { - const contentType = options.headers.get('Content-Type') - if (!contentType) - options.headers.set('Content-Type', ContentType.json) - } - - const urlPrefix = (() => { - if (isMarketplaceAPI) - return MARKETPLACE_API_PREFIX - if (isPublicAPI) - return PUBLIC_API_PREFIX - return API_PREFIX - })() - let urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}` - - const { method, params, body } = options - // handle query - if (method === 'GET' && params) { - const paramsArray: string[] = [] - Object.keys(params).forEach(key => - paramsArray.push(`${key}=${encodeURIComponent(params[key])}`), - ) - if (urlWithPrefix.search(/\?/) === -1) - urlWithPrefix += `?${paramsArray.join('&')}` - - else - urlWithPrefix += `&${paramsArray.join('&')}` - - delete options.params - } - - if (body && bodyStringify) - options.body = JSON.stringify(body) - - // Handle timeout - return Promise.race([ - new Promise((resolve, reject) => { - setTimeout(() => { - reject(new Error('request timeout')) - }, TIME_OUT) - }), - new Promise((resolve, reject) => { - globalThis.fetch(urlWithPrefix, options as RequestInit) - .then((res) => { - const resClone = res.clone() - // Error handler - if (!/^(2|3)\d{2}$/.test(String(res.status))) { - const bodyJson = res.json() - switch (res.status) { - case 401: - return Promise.reject(resClone) - case 403: - bodyJson.then((data: ResponseError) => { - if (!silent) - Toast.notify({ type: 'error', message: data.message }) - if (data.code === 'already_setup') - globalThis.location.href = `${globalThis.location.origin}/signin` - }) - break - // fall through - default: - bodyJson.then((data: ResponseError) => { - if (!silent) - Toast.notify({ type: 'error', message: data.message }) - }) - } - return Promise.reject(resClone) - } - - // handle delete api. Delete api not return content. - if (res.status === 204) { - resolve({ result: 'success' }) - return - } - - // return data - if (options.headers.get('Content-type') === ContentType.download || options.headers.get('Content-type') === ContentType.audio) - resolve(needAllResponseContent ? resClone : res.blob()) - - else resolve(needAllResponseContent ? resClone : res.json()) - }) - .catch((err) => { - if (!silent) - Toast.notify({ type: 'error', message: err }) - reject(err) - }) - }), - ]) as Promise -} +const baseFetch = base export const upload = (options: any, isPublicAPI?: boolean, url?: string, searchParams?: string): Promise => { const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX let token = '' if (isPublicAPI) { - const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0] - const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' }) - let accessTokenJson = { [sharedToken]: '' } - try { - accessTokenJson = JSON.parse(accessToken) - } - catch (e) { - - } - token = accessTokenJson[sharedToken] + token = getPublicToken() } else { const accessToken = localStorage.getItem('console_token') || '' @@ -499,9 +331,9 @@ export const ssePost = ( signal: abortController.signal, }, fetchOptions) - const contentType = options.headers.get('Content-Type') + const contentType = (options.headers as Headers).get('Content-Type') if (!contentType) - options.headers.set('Content-Type', ContentType.json) + (options.headers as Headers).set('Content-Type', ContentType.json) getAbortController?.(abortController) @@ -559,18 +391,17 @@ export const ssePost = ( } // base request -export const request = (url: string, options = {}, otherOptions?: IOtherOptions) => { +export const request = (url: string, options = {}, otherOptions: IOtherOptions = {}) => { return new Promise((resolve, reject) => { - const otherOptionsForBaseFetch = otherOptions || {} - baseFetch(url, options, otherOptionsForBaseFetch).then(resolve).catch((errResp) => { + baseFetch(url, options, otherOptions).then(resolve).catch((errResp) => { if (errResp?.status === 401) { return refreshAccessTokenOrRelogin(TIME_OUT).then(() => { - baseFetch(url, options, otherOptionsForBaseFetch).then(resolve).catch(reject) + baseFetch(url, options, otherOptions).then(resolve).catch(reject) }).catch(() => { const { isPublicAPI = false, silent, - } = otherOptionsForBaseFetch + } = otherOptions const bodyJson = errResp.json() if (isPublicAPI) { return bodyJson.then((data: ResponseError) => { diff --git a/web/service/fetch.ts b/web/service/fetch.ts new file mode 100644 index 0000000000..46e0e6295d --- /dev/null +++ b/web/service/fetch.ts @@ -0,0 +1,187 @@ +import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } from 'ky' +import ky from 'ky' +import type { IOtherOptions } from './base' +import Toast from '@/app/components/base/toast' +import { API_PREFIX, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX } from '@/config' + +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 + 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, _options, response) => { + if (!/^(2|3)\d{2}$/.test(String(response.status))) { + const bodyJson = response.json() as Promise + switch (response.status) { + case 401: + return Promise.reject(response) + 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 + // fall through + default: + bodyJson.then((data: ResponseError) => { + if (!otherOptions.silent) + Toast.notify({ type: 'error', message: data.message }) + }) + } + throw response + } + } +} + +const beforeErrorToast = (otherOptions: IOtherOptions): BeforeErrorHook => { + return (error) => { + if (!otherOptions.silent) + Toast.notify({ type: 'error', message: error.message }) + return error + } +} + +export const getPublicToken = () => { + let token = '' + const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0] + const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' }) + let accessTokenJson = { [sharedToken]: '' } + try { + accessTokenJson = JSON.parse(accessToken) + } + catch {} + token = accessTokenJson[sharedToken] + return token || '' +} + +const beforeRequestPublicAuthorization: BeforeRequestHook = (request) => { + const token = getPublicToken() + request.headers.set('Authorization', `Bearer ${token}`) +} + +const beforeRequestAuthorization: BeforeRequestHook = (request) => { + const accessToken = localStorage.getItem('console_token') || '' + request.headers.set('Authorization', `Bearer ${accessToken}`) +} + +const beforeRequestDeleteContentType: BeforeRequestHook = (request) => { + request.headers.delete('Content-Type') +} + +const baseHooks: Hooks = { + afterResponse: [ + afterResponse204, + ], +} + +const client = ky.create({ + hooks: baseHooks, + timeout: TIME_OUT, +}) + +export const baseOptions: 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 { params, body, ...init } = Object.assign({}, baseOptions, options) + const { + isPublicAPI = false, + isMarketplaceAPI = false, + bodyStringify = true, + needAllResponseContent, + deleteContentType, + getAbortController, + } = otherOptions + + const base + = isMarketplaceAPI + ? MARKETPLACE_API_PREFIX + : isPublicAPI + ? PUBLIC_API_PREFIX + : API_PREFIX + + if (getAbortController) { + const abortController = new AbortController() + getAbortController(abortController) + options.signal = abortController.signal + } + + const fetchPathname = `${base}${url.startsWith('/') ? url : `/${url}`}` + + const res = await client.extend({ + hooks: { + ...baseHooks, + beforeError: [ + ...baseHooks.beforeError || [], + beforeErrorToast(otherOptions), + ], + beforeRequest: [ + ...baseHooks.beforeRequest || [], + isPublicAPI && beforeRequestPublicAuthorization, + !isPublicAPI && !isMarketplaceAPI && beforeRequestAuthorization, + deleteContentType && beforeRequestDeleteContentType, + ].filter(i => !!i), + afterResponse: [ + ...baseHooks.afterResponse || [], + afterResponseErrorCode(otherOptions), + ], + }, + })(fetchPathname, { + credentials: isMarketplaceAPI + ? 'omit' + : (options.credentials || 'include'), + ...init, + retry: { + methods: [], + }, + ...(bodyStringify ? { json: body } : { body: body as BodyInit }), + searchParams: params, + }) + + if (needAllResponseContent) + return res as T + const contentType = res.headers.get('content-type') + if ( + contentType + && [ContentType.download, ContentType.audio].includes(contentType) + ) + return await res.blob() as T + + return await res.json() as T +} + +export { + client, + base, +}