first relase

This commit is contained in:
Lê đ tú 2025-10-17 11:11:14 +07:00
parent d3a42ba6e9
commit c6afce22ed
288 changed files with 55505 additions and 192 deletions

460
service/base.ts Normal file
View file

@ -0,0 +1,460 @@
import { removeAccessToken } from '@/app/components/share/utils';
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config';
import { asyncRunSafe } from '@/utils';
import { basePath } from '@/utils/var';
import type { FetchOptionType, ResponseError } from './fetch';
import { ContentType, base, getAccessToken, getBaseOptions } from './fetch';
const TIME_OUT = 100000;
export type IOnDataMoreInfo = {
conversationId?: string;
taskId?: string;
messageId: string;
errorMessage?: string;
errorCode?: string;
};
export type IOnData = (
message: string,
isFirstMessage: boolean,
moreInfo: IOnDataMoreInfo,
) => void;
const baseFetch = base;
type UploadOptions = {
xhr: XMLHttpRequest;
method: string;
url?: string;
headers?: Record<string, string>;
data: FormData;
onprogress?: (this: XMLHttpRequest, ev: ProgressEvent<EventTarget>) => void;
};
type UploadResponse = {
id: string;
[key: string]: unknown;
};
export const upload = async (
options: UploadOptions,
isPublicAPI?: boolean,
url?: string,
searchParams?: string,
): Promise<UploadResponse> => {
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX;
const token = await getAccessToken(isPublicAPI);
const defaultOptions = {
method: 'POST',
url:
(url ? `${urlPrefix}${url}` : `${urlPrefix}/files/upload`) +
(searchParams || ''),
headers: {
Authorization: `Bearer ${token}`,
},
};
const mergedOptions = {
...defaultOptions,
...options,
url: options.url || defaultOptions.url,
headers: { ...defaultOptions.headers, ...options.headers } as Record<
string,
string
>,
};
return new Promise((resolve, reject) => {
const xhr = mergedOptions.xhr;
xhr.open(mergedOptions.method, mergedOptions.url);
for (const key in mergedOptions.headers)
xhr.setRequestHeader(key, mergedOptions.headers[key]);
xhr.withCredentials = true;
xhr.responseType = 'json';
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 201) resolve(xhr.response);
else reject(xhr);
}
};
if (mergedOptions.onprogress)
xhr.upload.onprogress = mergedOptions.onprogress;
xhr.send(mergedOptions.data);
});
};
export const ssePost = async (
url: string,
fetchOptions: FetchOptionType,
otherOptions: IOtherOptions,
) => {
const {
isPublicAPI = false,
onData,
onCompleted,
onThought,
onFile,
onMessageEnd,
onMessageReplace,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onError,
getAbortController,
onLoopStart,
onLoopNext,
onLoopFinish,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
} = otherOptions;
const abortController = new AbortController();
const token = localStorage.getItem('console_token');
const baseOptions = getBaseOptions();
const options = Object.assign(
{},
baseOptions,
{
method: 'POST',
signal: abortController.signal,
headers: new Headers({
Authorization: `Bearer ${token}`,
}),
} as RequestInit,
fetchOptions,
);
const contentType = (options.headers as Headers).get('Content-Type');
if (!contentType)
(options.headers as Headers).set('Content-Type', ContentType.json);
getAbortController?.(abortController);
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX;
const urlWithPrefix =
url.startsWith('http://') || url.startsWith('https://')
? url
: `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`;
const { body } = options;
if (body) options.body = JSON.stringify(body);
const accessToken = await getAccessToken(isPublicAPI);
(options.headers as Headers).set('Authorization', `Bearer ${accessToken}`);
globalThis
.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^[23]\d{2}$/.test(String(res.status))) {
if (res.status === 401) {
if (isPublicAPI) {
res.json().then((data: { code?: string; message?: string }) => {
if (isPublicAPI) {
if (data.code === 'web_app_access_denied')
requiredWebSSOLogin(data.message, 403);
if (data.code === 'web_sso_auth_required') {
removeAccessToken();
requiredWebSSOLogin();
}
if (data.code === 'unauthorized') {
removeAccessToken();
requiredWebSSOLogin();
}
}
});
} else {
refreshAccessTokenOrRelogin(TIME_OUT)
.then(() => {
ssePost(url, fetchOptions, otherOptions);
})
.catch((err) => {
console.error(err);
});
}
} else {
res.json().then((data) => {
Toast.notify({
type: 'error',
message: data.message || 'Server Error',
});
});
onError?.('Server Error');
}
return;
}
return handleStream(
res,
(str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode);
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (
moreInfo.errorMessage !==
'AbortError: The user aborted a request.' &&
!moreInfo.errorMessage.includes(
'TypeError: Cannot assign to read only property',
)
)
Toast.notify({ type: 'error', message: moreInfo.errorMessage });
return;
}
onData?.(str, isFirstMessage, moreInfo);
},
onCompleted,
onThought,
onMessageEnd,
onMessageReplace,
onFile,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
);
})
.catch((e) => {
if (
e.toString() !== 'AbortError: The user aborted a request.' &&
!e.toString().includes('TypeError: Cannot assign to read only property')
)
Toast.notify({ type: 'error', message: e });
onError?.(e);
});
};
// base request
export const request = async <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
try {
const otherOptionsForBaseFetch = otherOptions || {};
const [err, resp] = await asyncRunSafe<T>(
baseFetch(url, options, otherOptionsForBaseFetch),
);
if (err === null) return resp;
const errResp: Response = err as any;
if (errResp.status === 401) {
const [parseErr, errRespData] = await asyncRunSafe<ResponseError>(
errResp.json(),
);
const loginUrl = `${globalThis.location.origin}${basePath}/signin`;
if (parseErr) {
globalThis.location.href = loginUrl;
return Promise.reject(err);
}
if (/\/login/.test(url)) return Promise.reject(errRespData);
// special code
const { code, message } = errRespData;
// webapp sso
if (code === 'web_app_access_denied') {
requiredWebSSOLogin(message, 403);
return Promise.reject(err);
}
if (code === 'web_sso_auth_required') {
removeAccessToken();
requiredWebSSOLogin();
return Promise.reject(err);
}
if (code === 'unauthorized_and_force_logout') {
localStorage.removeItem('console_token');
localStorage.removeItem('refresh_token');
globalThis.location.reload();
return Promise.reject(err);
}
const { isPublicAPI = false, silent } = otherOptionsForBaseFetch;
if (isPublicAPI && code === 'unauthorized') {
removeAccessToken();
requiredWebSSOLogin();
return Promise.reject(err);
}
if (code === 'init_validate_failed' && IS_CE_EDITION && !silent) {
Toast.notify({ type: 'error', message, duration: 4000 });
return Promise.reject(err);
}
if (code === 'not_init_validated' && IS_CE_EDITION) {
globalThis.location.href = `${globalThis.location.origin}${basePath}/init`;
return Promise.reject(err);
}
if (code === 'not_setup' && IS_CE_EDITION) {
globalThis.location.href = `${globalThis.location.origin}${basePath}/install`;
return Promise.reject(err);
}
// refresh token
const [refreshErr] = await asyncRunSafe(
refreshAccessTokenOrRelogin(TIME_OUT),
);
if (refreshErr === null)
return baseFetch<T>(url, options, otherOptionsForBaseFetch);
if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) {
globalThis.location.href = loginUrl;
return Promise.reject(err);
}
if (!silent) {
Toast.notify({ type: 'error', message });
return Promise.reject(err);
}
globalThis.location.href = loginUrl;
return Promise.reject(err);
} else {
return Promise.reject(err);
}
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
// request methods
export const get = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return request<T>(
url,
Object.assign({}, options, { method: 'GET' }),
otherOptions,
);
};
// For public API
export const getPublic = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return get<T>(url, options, { ...otherOptions, isPublicAPI: true });
};
// For Marketplace API
export const getMarketplace = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return get<T>(url, options, { ...otherOptions, isMarketplaceAPI: true });
};
export const post = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return request<T>(
url,
Object.assign({}, options, { method: 'POST' }),
otherOptions,
);
};
// For Marketplace API
export const postMarketplace = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return post<T>(url, options, { ...otherOptions, isMarketplaceAPI: true });
};
export const postPublic = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return post<T>(url, options, { ...otherOptions, isPublicAPI: true });
};
export const put = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return request<T>(
url,
Object.assign({}, options, { method: 'PUT' }),
otherOptions,
);
};
export const putPublic = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return put<T>(url, options, { ...otherOptions, isPublicAPI: true });
};
export const del = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return request<T>(
url,
Object.assign({}, options, { method: 'DELETE' }),
otherOptions,
);
};
export const delPublic = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return del<T>(url, options, { ...otherOptions, isPublicAPI: true });
};
export const patch = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return request<T>(
url,
Object.assign({}, options, { method: 'PATCH' }),
otherOptions,
);
};
export const patchPublic = <T>(
url: string,
options = {},
otherOptions?: IOtherOptions,
) => {
return patch<T>(url, options, { ...otherOptions, isPublicAPI: true });
};

5
service/common.ts Normal file
View file

@ -0,0 +1,5 @@
import type { SystemFeatures } from '@/types/feature';
import { get } from './base';
export const getSystemFeatures = () => {
return get<SystemFeatures>('/system-features');
};

226
service/fetch.ts Normal file
View file

@ -0,0 +1,226 @@
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<RequestInit, 'body'> & {
params?: Record<string, any>;
body?: BodyInit | Record<string, any> | 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<ResponseError>;
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<string, any> = { 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<T>(
url: string,
options: FetchOptionType = {},
otherOptions: IOtherOptions = {},
): Promise<T> {
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 };

92
service/refresh-token.ts Normal file
View file

@ -0,0 +1,92 @@
import { API_PREFIX } from '@/config'
import { fetchWithRetry } from '@/utils'
const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing'
let isRefreshing = false
function waitUntilTokenRefreshed() {
return new Promise<void>((resolve) => {
function _check() {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
setTimeout(() => {
_check()
}, 1000)
}
else {
resolve()
}
}
_check()
})
}
const isRefreshingSignAvailable = function (delta: number) {
const nowTime = new Date().getTime()
const lastTime = globalThis.localStorage.getItem('last_refresh_time') || '0'
return nowTime - Number.parseInt(lastTime) <= delta
}
// only one request can send
async function getNewAccessToken(timeout: number): Promise<void> {
try {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
if ((isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) || isRefreshing) {
await waitUntilTokenRefreshed()
}
else {
isRefreshing = true
globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1')
globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString())
globalThis.addEventListener('beforeunload', releaseRefreshLock)
const refresh_token = globalThis.localStorage.getItem('refresh_token')
// Do not use baseFetch to refresh tokens.
// If a 401 response occurs and baseFetch itself attempts to refresh the token,
// it can lead to an infinite loop if the refresh attempt also returns 401.
// To avoid this, handle token refresh separately in a dedicated function
// that does not call baseFetch and uses a single retry mechanism.
const [error, ret] = await fetchWithRetry(globalThis.fetch(`${API_PREFIX}/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json;utf-8',
},
body: JSON.stringify({ refresh_token }),
}))
if (error) {
return Promise.reject(error)
}
else {
if (ret.status === 401)
return Promise.reject(ret)
const { data } = await ret.json()
globalThis.localStorage.setItem('console_token', data.access_token)
globalThis.localStorage.setItem('refresh_token', data.refresh_token)
}
}
}
catch (error) {
console.error(error)
return Promise.reject(error)
}
finally {
releaseRefreshLock()
}
}
function releaseRefreshLock() {
if (isRefreshing) {
isRefreshing = false
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
globalThis.localStorage.removeItem('last_refresh_time')
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
}
}
export async function refreshAccessTokenOrRelogin(timeout: number) {
return Promise.race([new Promise<void>((resolve, reject) => setTimeout(() => {
releaseRefreshLock()
reject(new Error('request timeout'))
}, timeout)), getNewAccessToken(timeout)])
}

0
service/share.ts Normal file
View file

26
service/use-base.ts Normal file
View file

@ -0,0 +1,26 @@
import {
type QueryKey,
useQueryClient,
} from '@tanstack/react-query'
export const useInvalid = (key: QueryKey) => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries(
{
queryKey: key,
},
)
}
}
export const useReset = (key: QueryKey) => {
const queryClient = useQueryClient()
return () => {
queryClient.resetQueries(
{
queryKey: key,
},
)
}
}