diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 470f4477fa..dfb0c75d60 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -23,12 +23,14 @@ import AppSideBar from '@/app/components/app-sidebar' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import s from './style.module.css' const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { @@ -108,7 +110,7 @@ const AppDetailLayout: FC = (props) => { useEffect(() => { if (appDetail) { - const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' + const localeMode = storage.get(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand' const mode = isMobile ? 'collapse' : 'expand' setAppSidebarExpand(isMobile ? mode : localeMode) // TODO: consider screen size and mode diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index f07b2932c9..74bebe1238 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -15,7 +15,7 @@ import Loading from '@/app/components/base/loading' import { ToastContext } from '@/app/components/base/toast' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import { isTriggerNode } from '@/app/components/workflow/types' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { fetchAppDetail, updateAppSiteAccessToken, @@ -25,6 +25,7 @@ import { import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum } from '@/types/app' import { asyncRunSafe } from '@/utils' +import { storage } from '@/utils/storage' export type ICardViewProps = { appId: string @@ -126,7 +127,7 @@ const CardView: FC = ({ appId, isInPanel, className }) => { }) as Promise, ) if (!err) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') handleCallbackResult(err) } diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 29c8e07c44..b436703567 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -112,7 +112,7 @@ const DatasetDetailLayout: FC = (props) => { const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand) useEffect(() => { - const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' + const localeMode = storage.get(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand' const mode = isMobile ? 'collapse' : 'expand' setAppSidebarExpand(isMobile ? mode : localeMode) }, [isMobile, setAppSidebarExpand]) diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index 9b9a853cdd..06eaa4fb85 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -8,12 +8,14 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' -import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { sendResetPasswordCode } from '@/service/common' +import { storage } from '@/utils/storage' export default function CheckCode() { const { t } = useTranslation() @@ -41,7 +43,7 @@ export default function CheckCode() { setIsLoading(true) const res = await sendResetPasswordCode(email, locale) if (res.result === 'success') { - localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS) const params = new URLSearchParams(searchParams) params.set('token', encodeURIComponent(res.data)) params.set('email', encodeURIComponent(email)) diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index 5aa9d9f141..17440219b2 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' -import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useLocale } from '@/context/i18n' import { sendWebAppEMailLoginCode } from '@/service/common' +import { storage } from '@/utils/storage' export default function MailAndCodeAuth() { const { t } = useTranslation() @@ -36,7 +38,7 @@ export default function MailAndCodeAuth() { setIsLoading(true) const ret = await sendWebAppEMailLoginCode(email, locale) if (ret.result === 'success') { - localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS) const params = new URLSearchParams(searchParams) params.set('email', encodeURIComponent(email)) params.set('token', encodeURIComponent(ret.data)) diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index 87ca6a689c..b33abd9812 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -10,6 +10,7 @@ import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast' +import { STORAGE_KEYS } from '@/config/storage-keys' import { checkEmailExisted, resetEmail, @@ -18,6 +19,7 @@ import { } from '@/service/common' import { useLogout } from '@/service/use-common' import { asyncRunSafe } from '@/utils' +import { storage } from '@/utils/storage' type Props = { show: boolean @@ -172,7 +174,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => { const handleLogout = async () => { await logout() - localStorage.removeItem('setup_status') + storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS) // Tokens are now stored in cookies and cleared by backend router.push('/signin') diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 8ea29e8e45..5d89c0a3be 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -10,9 +10,11 @@ import { resetUser } from '@/app/components/base/amplitude/utils' import Avatar from '@/app/components/base/avatar' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { useLogout } from '@/service/use-common' +import { storage } from '@/utils/storage' export type IAppSelector = { isMobile: boolean @@ -28,7 +30,7 @@ export default function AppSelector() { const handleLogout = async () => { await logout() - localStorage.removeItem('setup_status') + storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS) resetUser() // Tokens are now stored in cookies and cleared by backend diff --git a/web/app/account/(commonLayout)/delete-account/index.tsx b/web/app/account/(commonLayout)/delete-account/index.tsx index 416f680a60..eb35d15a40 100644 --- a/web/app/account/(commonLayout)/delete-account/index.tsx +++ b/web/app/account/(commonLayout)/delete-account/index.tsx @@ -2,7 +2,9 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import CustomDialog from '@/app/components/base/dialog' -import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { STORAGE_KEYS } from '@/config/storage-keys' +import { storage } from '@/utils/storage' import CheckEmail from './components/check-email' import FeedBack from './components/feed-back' import VerifyEmail from './components/verify-email' @@ -21,7 +23,7 @@ export default function DeleteAccount(props: DeleteAccountProps) { const handleEmailCheckSuccess = useCallback(async () => { try { setShowVerifyEmail(true) - localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS) } catch (error) { console.error(error) } }, []) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index c923d6457a..9b0c5d0a6e 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -17,11 +17,12 @@ import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useIsLogin } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' +import { storage } from '@/utils/storage' import { - OAUTH_AUTHORIZE_PENDING_KEY, OAUTH_AUTHORIZE_PENDING_TTL, REDIRECT_URL_KEY, } from './constants' @@ -31,7 +32,7 @@ function setItemWithExpiry(key: string, value: string, ttl: number) { value, expiry: dayjs().add(ttl, 'seconds').unix(), } - localStorage.setItem(key, JSON.stringify(item)) + storage.set(key, JSON.stringify(item)) } function buildReturnUrl(pathname: string, search: string) { @@ -86,7 +87,7 @@ export default function OAuthAuthorize() { const onLoginSwitchClick = () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) - setItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL) + setItemWithExpiry(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL) router.push(`/signin?${REDIRECT_URL_KEY}=${encodeURIComponent(returnUrl)}`) } catch { diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index 3410ecbe9a..dd0fe55d51 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -7,13 +7,16 @@ import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useEffect, useState } from 'react' import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, - EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import { LEGACY_KEY_MIGRATIONS, STORAGE_KEYS } from '@/config/storage-keys' import { sendGAEvent } from '@/utils/gtag' import { fetchSetupStatusWithCache } from '@/utils/setup-status' +import { storage } from '@/utils/storage' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' import { trackEvent } from './base/amplitude' +storage.runMigrations(LEGACY_KEY_MIGRATIONS) + type AppInitializerProps = { children: ReactNode } @@ -75,7 +78,7 @@ export const AppInitializer = ({ } if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) - localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') + storage.set(STORAGE_KEYS.EDUCATION.VERIFYING, 'yes') try { const isFinished = await isSetupFinished() diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index 255feaccdf..c4089fa997 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -22,7 +22,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import Button from '@/app/components/base/button' import ContentDialog from '@/app/components/base/content-dialog' import { ToastContext } from '@/app/components/base/toast' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' @@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import AppIcon from '../base/app-icon' import AppOperations from './app-operations' @@ -128,7 +129,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx type: 'success', message: t('newApp.appCreated', { ns: 'app' }), }) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') onPlanInfoChanged() getRedirection(true, newApp, replace) } diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index f5ebaac3ca..cb7f42ec4c 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -30,8 +30,10 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import { STORAGE_KEYS } from '@/config/storage-keys' import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug' import { useGenerateRuleTemplate } from '@/service/use-apps' +import { storage } from '@/utils/storage' import IdeaOutput from './idea-output' import InstructionEditorInBasic from './instruction-editor' import InstructionEditorInWorkflow from './instruction-editor-in-workflow' @@ -83,9 +85,7 @@ const GetAutomaticRes: FC = ({ onFinished, }) => { const { t } = useTranslation() - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model - : null + const localModel = storage.get(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL) const [model, setModel] = React.useState(localModel || { name: '', provider: '', @@ -178,9 +178,7 @@ const GetAutomaticRes: FC = ({ useEffect(() => { if (defaultModel) { - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') || '') - : null + const localModel = storage.get(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL) if (localModel) { setModel(localModel) } @@ -209,7 +207,7 @@ const GetAutomaticRes: FC = ({ mode: newValue.mode as ModelModeType, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel) }, [model, setModel]) const handleCompletionParamsChange = useCallback((newParams: FormValue) => { @@ -218,7 +216,7 @@ const GetAutomaticRes: FC = ({ completion_params: newParams as CompletionParams, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel) }, [model, setModel]) const onGenerate = async () => { diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index 5166ae49f3..4a8c6c704e 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -17,8 +17,10 @@ import Toast from '@/app/components/base/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import { STORAGE_KEYS } from '@/config/storage-keys' import { generateRule } from '@/service/debug' import { useGenerateRuleTemplate } from '@/service/use-apps' +import { storage } from '@/utils/storage' import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index' import IdeaOutput from '../automatic/idea-output' import InstructionEditor from '../automatic/instruction-editor-in-workflow' @@ -62,9 +64,7 @@ export const GetCodeGeneratorResModal: FC = ( presence_penalty: 0, frequency_penalty: 0, } - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model - : null + const localModel = storage.get(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL) const [model, setModel] = React.useState(localModel || { name: '', provider: '', @@ -115,7 +115,7 @@ export const GetCodeGeneratorResModal: FC = ( mode: newValue.mode as ModelModeType, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel) }, [model, setModel]) const handleCompletionParamsChange = useCallback((newParams: FormValue) => { @@ -124,7 +124,7 @@ export const GetCodeGeneratorResModal: FC = ( completion_params: newParams as CompletionParams, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel) }, [model, setModel]) const onGenerate = async () => { @@ -168,9 +168,7 @@ export const GetCodeGeneratorResModal: FC = ( useEffect(() => { if (defaultModel) { - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') || '') - : null + const localModel = storage.get(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL) if (localModel) { setModel({ ...localModel, diff --git a/web/app/components/app/configuration/debug/hooks.tsx b/web/app/components/app/configuration/debug/hooks.tsx index e5dba3640d..5cd305dd07 100644 --- a/web/app/components/app/configuration/debug/hooks.tsx +++ b/web/app/components/app/configuration/debug/hooks.tsx @@ -14,27 +14,20 @@ import { } from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' import { AgentStrategy, } from '@/types/app' import { promptVariablesToUserInputsForm } from '@/utils/model-config' +import { storage } from '@/utils/storage' import { ORCHESTRATE_CHANGED } from './types' export const useDebugWithSingleOrMultipleModel = (appId: string) => { - const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem('app-debug-with-single-or-multiple-models') + const localeDebugWithSingleOrMultipleModelConfigs = storage.get(STORAGE_KEYS.CONFIG.DEBUG_MODELS) - const debugWithSingleOrMultipleModelConfigs = useRef({}) - - if (localeDebugWithSingleOrMultipleModelConfigs) { - try { - debugWithSingleOrMultipleModelConfigs.current = JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {} - } - catch (e) { - console.error(e) - } - } + const debugWithSingleOrMultipleModelConfigs = useRef(localeDebugWithSingleOrMultipleModelConfigs || {}) const [ debugWithMultipleModel, @@ -55,7 +48,7 @@ export const useDebugWithSingleOrMultipleModel = (appId: string) => { configs: modelConfigs, } debugWithSingleOrMultipleModelConfigs.current[appId] = value - localStorage.setItem('app-debug-with-single-or-multiple-models', JSON.stringify(debugWithSingleOrMultipleModelConfigs.current)) + storage.set(STORAGE_KEYS.CONFIG.DEBUG_MODELS, debugWithSingleOrMultipleModelConfigs.current) setDebugWithMultipleModel(value.multiple) setMultipleModelConfigs(value.configs) }, [appId]) diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index b967ba7d55..b9503491eb 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -16,7 +16,7 @@ import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { DSLImportMode } from '@/models/app' import { importDSL } from '@/service/apps' @@ -25,6 +25,7 @@ import { useExploreAppList } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import AppCard from '../app-card' import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar' @@ -145,7 +146,7 @@ const Apps = ({ onSuccess() if (app.app_id) await handleCheckPluginDependencies(app.app_id) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push) } catch { diff --git a/web/app/components/app/create-app-modal/index.spec.tsx b/web/app/components/app/create-app-modal/index.spec.tsx index cb8f4db67f..f17ea9b5df 100644 --- a/web/app/components/app/create-app-modal/index.spec.tsx +++ b/web/app/components/app/create-app-modal/index.spec.tsx @@ -4,7 +4,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { trackEvent } from '@/app/components/base/amplitude' import { ToastContext } from '@/app/components/base/toast' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { createApp } from '@/service/apps' @@ -12,6 +11,8 @@ import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import CreateAppModal from './index' +const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList' + vi.mock('ahooks', () => ({ useDebounceFn: (fn: (...args: any[]) => any) => { const run = (...args: any[]) => fn(...args) @@ -142,7 +143,7 @@ describe('CreateAppModal', () => { expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() - await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')) + await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY_PREFIXED, '1')) await waitFor(() => expect(mockGetRedirection).toHaveBeenCalledWith(true, mockApp, mockPush)) }) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index e2b50cf030..c17e18fcbc 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -19,7 +19,7 @@ import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import { ToastContext } from '@/app/components/base/toast' import AppsFull from '@/app/components/billing/apps-full-in-dialog' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' @@ -27,6 +27,7 @@ import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import { basePath } from '@/utils/var' import AppIconPicker from '../../base/app-icon-picker' @@ -91,7 +92,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) }) onSuccess() onClose() - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') getRedirection(isCurrentWorkspaceEditor, app, push) } catch (e: any) { diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 838e9cc03f..f88c4fe7d7 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -15,7 +15,7 @@ import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { @@ -28,6 +28,7 @@ import { } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import Uploader from './uploader' type CreateFromDSLModalProps = { @@ -130,7 +131,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }), children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), }) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') if (app_id) await handleCheckPluginDependencies(app_id) getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push) @@ -190,7 +191,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS }) if (app_id) await handleCheckPluginDependencies(app_id) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push) } else if (status === DSLImportStatus.FAILED) { diff --git a/web/app/components/app/switch-app-modal/index.spec.tsx b/web/app/components/app/switch-app-modal/index.spec.tsx index f0400f8b75..1501831601 100644 --- a/web/app/components/app/switch-app-modal/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/index.spec.tsx @@ -5,10 +5,11 @@ import * as React from 'react' import { useStore as useAppStore } from '@/app/components/app/store' import { ToastContext } from '@/app/components/base/toast' import { Plan } from '@/app/components/billing/type' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { AppModeEnum } from '@/types/app' import SwitchAppModal from './index' +const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList' + const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('next/navigation', () => ({ @@ -257,7 +258,7 @@ describe('SwitchAppModal', () => { expect(onSuccess).toHaveBeenCalledTimes(1) expect(onClose).toHaveBeenCalledTimes(1) expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) - expect(localStorage.setItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1') + expect(localStorage.setItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY_PREFIXED, '1') expect(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow') expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 30d7877ed0..6c216b732b 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -17,13 +17,14 @@ import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast' import AppsFull from '@/app/components/billing/apps-full-in-dialog' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { deleteApp, switchApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import AppIconPicker from '../../base/app-icon-picker' type SwitchAppModalProps = { @@ -73,7 +74,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo setAppDetail() if (removeOriginal) await deleteApp(appDetail.id) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') getRedirection( isCurrentWorkspaceEditor, { diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index f1eadb9d05..df18b98ba3 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -20,7 +20,7 @@ import CustomPopover from '@/app/components/base/popover' import TagSelector from '@/app/components/base/tag-management/selector' import Toast, { ToastContext } from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' @@ -33,6 +33,7 @@ import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import { formatTime } from '@/utils/time' import { basePath } from '@/utils/var' @@ -144,7 +145,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { type: 'success', message: t('newApp.appCreated', { ns: 'app' }), }) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') if (onRefresh) onRefresh() onPlanInfoChanged() diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index 32bf5929fd..847d552a73 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -434,13 +434,15 @@ describe('List', () => { }) describe('Local Storage Refresh', () => { - it('should call refetch when refresh key is set in localStorage', () => { - localStorage.setItem('needRefreshAppList', '1') + it('should call refetch when refresh key is set in localStorage', async () => { + localStorage.setItem('v1:needRefreshAppList', '1') render() - expect(mockRefetch).toHaveBeenCalled() - expect(localStorage.getItem('needRefreshAppList')).toBeNull() + await vi.waitFor(() => { + expect(mockRefetch).toHaveBeenCalled() + }) + expect(localStorage.getItem('v1:needRefreshAppList')).toBeNull() }) }) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 6bf79b7338..f4bf80b716 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -22,13 +22,14 @@ import TabSliderNew from '@/app/components/base/tab-slider-new' import TagFilter from '@/app/components/base/tag-management/filter' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { CheckModal } from '@/hooks/use-pay' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' import Empty from './empty' @@ -134,8 +135,8 @@ const List: FC = ({ ] useEffect(() => { - if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { - localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) + if (storage.get(STORAGE_KEYS.APP.NEED_REFRESH_LIST) === '1') { + storage.remove(STORAGE_KEYS.APP.NEED_REFRESH_LIST) refetch() } }, [refetch]) diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx index 399f16716d..fa89e5c007 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -11,9 +11,10 @@ import { generationConversationName, } from '@/service/share' import { shareQueryKeys } from '@/service/use-share' -import { CONVERSATION_ID_INFO } from '../constants' import { useChatWithHistory } from './hooks' +const CONVERSATION_ID_INFO_KEY = 'v1:conversationIdInfo' + vi.mock('@/hooks/use-app-favicon', () => ({ useAppFavicon: vi.fn(), })) @@ -120,14 +121,14 @@ const setConversationIdInfo = (appId: string, conversationId: string) => { 'DEFAULT': conversationId, }, } - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify(value)) + localStorage.setItem(CONVERSATION_ID_INFO_KEY, JSON.stringify(value)) } // Scenario: useChatWithHistory integrates share queries for conversations and chat list. describe('useChatWithHistory', () => { beforeEach(() => { vi.clearAllMocks() - localStorage.removeItem(CONVERSATION_ID_INFO) + localStorage.removeItem(CONVERSATION_ID_INFO_KEY) mockStoreState.appInfo = { app_id: 'app-1', custom_config: null, @@ -144,7 +145,7 @@ describe('useChatWithHistory', () => { }) afterEach(() => { - localStorage.removeItem(CONVERSATION_ID_INFO) + localStorage.removeItem(CONVERSATION_ID_INFO_KEY) }) // Scenario: share query results populate conversation lists and trigger chat list fetch. @@ -268,7 +269,7 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) + const storedValue = localStorage.getItem(CONVERSATION_ID_INFO_KEY) const parsed = storedValue ? JSON.parse(storedValue) : {} const storedUserId = parsed['app-1']?.['user-1'] const storedDefaultId = parsed['app-1']?.DEFAULT diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index ad1de38d07..83280ae0f7 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { useToastContext } from '@/app/components/base/toast' import { InputVarType } from '@/app/components/workflow/types' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useWebAppStore } from '@/context/web-app-context' import { useAppFavicon } from '@/hooks/use-app-favicon' import { changeLanguage } from '@/i18n-config/client' @@ -41,6 +42,7 @@ import { useShareConversations, } from '@/service/use-share' import { TransferMethod } from '@/types/app' +import { storage } from '@/utils/storage' import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { CONVERSATION_ID_INFO } from '../constants' import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils' @@ -128,27 +130,15 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const [sidebarCollapseState, setSidebarCollapseState] = useState(() => { if (typeof window !== 'undefined') { - try { - const localState = localStorage.getItem('webappSidebarCollapse') - return localState === 'collapsed' - } - catch { - // localStorage may be disabled in private browsing mode or by security settings - // fallback to default value - return false - } + const localState = storage.get(STORAGE_KEYS.APP.SIDEBAR_COLLAPSE) + return localState === 'collapsed' } return false }) const handleSidebarCollapse = useCallback((state: boolean) => { if (appId) { setSidebarCollapseState(state) - try { - localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded') - } - catch { - // localStorage may be disabled, continue without persisting state - } + storage.set(STORAGE_KEYS.APP.SIDEBAR_COLLAPSE, state ? 'collapsed' : 'expanded') } }, [appId, setSidebarCollapseState]) const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState>>(CONVERSATION_ID_INFO, { diff --git a/web/app/components/base/chat/constants.ts b/web/app/components/base/chat/constants.ts index 309f0f04a7..ea48688494 100644 --- a/web/app/components/base/chat/constants.ts +++ b/web/app/components/base/chat/constants.ts @@ -1,2 +1,2 @@ -export const CONVERSATION_ID_INFO = 'conversationIdInfo' +export const CONVERSATION_ID_INFO = 'v1:conversationIdInfo' export const UUID_NIL = '00000000-0000-0000-0000-000000000000' diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx index 4088e709d1..977ecd7bd0 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx @@ -11,9 +11,10 @@ import { generationConversationName, } from '@/service/share' import { shareQueryKeys } from '@/service/use-share' -import { CONVERSATION_ID_INFO } from '../constants' import { useEmbeddedChatbot } from './hooks' +const CONVERSATION_ID_INFO_KEY = 'v1:conversationIdInfo' + vi.mock('@/i18n-config/client', () => ({ changeLanguage: vi.fn().mockResolvedValue(undefined), })) @@ -113,7 +114,7 @@ const createConversationData = (overrides: Partial = {}): A describe('useEmbeddedChatbot', () => { beforeEach(() => { vi.clearAllMocks() - localStorage.removeItem(CONVERSATION_ID_INFO) + localStorage.removeItem(CONVERSATION_ID_INFO_KEY) mockStoreState.appInfo = { app_id: 'app-1', custom_config: null, @@ -131,7 +132,7 @@ describe('useEmbeddedChatbot', () => { }) afterEach(() => { - localStorage.removeItem(CONVERSATION_ID_INFO) + localStorage.removeItem(CONVERSATION_ID_INFO_KEY) }) // Scenario: share query results populate conversation lists and trigger chat list fetch. @@ -251,7 +252,7 @@ describe('useEmbeddedChatbot', () => { // Assert await waitFor(() => { - const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) + const storedValue = localStorage.getItem(CONVERSATION_ID_INFO_KEY) const parsed = storedValue ? JSON.parse(storedValue) : {} const storedUserId = parsed['app-1']?.['embedded-user-1'] const storedDefaultId = parsed['app-1']?.DEFAULT diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/index.spec.tsx index 473f81f9f4..860b18a28d 100644 --- a/web/app/components/billing/plan/index.spec.tsx +++ b/web/app/components/billing/plan/index.spec.tsx @@ -105,7 +105,7 @@ describe('PlanComp', () => { await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled()) await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token')) - expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + expect(localStorage.removeItem).toHaveBeenCalledWith(`v1:${EDUCATION_VERIFYING_LOCALSTORAGE_ITEM}`) }) it('shows modal when education verify fails', async () => { diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 2f953c3a8e..bb8b41f2a9 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -14,12 +14,13 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow' import UsageInfo from '@/app/components/billing/usage-info' -import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' import VerifyStateModal from '@/app/education-apply/verify-state-modal' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useEducationVerify } from '@/service/use-education' +import { storage } from '@/utils/storage' import { getDaysUntilEndOfMonth } from '@/utils/time' import { Loading } from '../../base/icons/src/public/thought' import { NUM_INFINITE } from '../config' @@ -72,7 +73,7 @@ const PlanComp: FC = ({ if (isPending) return mutateAsync().then((res) => { - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING) if (unmountedRef.current) return router.push(`/education-apply?token=${res.token}`) diff --git a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts index 39210fe7ef..1568fce9ac 100644 --- a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts +++ b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts @@ -4,8 +4,9 @@ import { useBoolean } from 'ahooks' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useBuiltInMetaDataFields, useCreateMetaData, useDatasetMetaData, useDeleteMetaData, useRenameMeta, useUpdateBuiltInStatus } from '@/service/knowledge/use-metadata' -import { isShowManageMetadataLocalStorageKey } from '../types' +import { storage } from '@/utils/storage' import useCheckMetadataName from './use-check-metadata-name' const useEditDatasetMetadata = ({ @@ -24,10 +25,10 @@ const useEditDatasetMetadata = ({ }] = useBoolean(false) useEffect(() => { - const isShowManageMetadata = localStorage.getItem(isShowManageMetadataLocalStorageKey) + const isShowManageMetadata = storage.get(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA) if (isShowManageMetadata) { showEditModal() - localStorage.removeItem(isShowManageMetadataLocalStorageKey) + storage.remove(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA) } }, []) diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 6d172c92f4..b517b89a25 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -7,12 +7,14 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Tooltip from '@/app/components/base/tooltip' +import { STORAGE_KEYS } from '@/config/storage-keys' import useTimestamp from '@/hooks/use-timestamp' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import AddMetadataButton from '../add-metadata-button' import InputCombined from '../edit-metadata-batch/input-combined' import SelectMetadataModal from '../metadata-dataset/select-metadata-modal' -import { DataType, isShowManageMetadataLocalStorageKey } from '../types' +import { DataType } from '../types' import Field from './field' type Props = { @@ -53,7 +55,7 @@ const InfoGroup: FC = ({ const { formatTime: formatTimestamp } = useTimestamp() const handleMangeMetadata = () => { - localStorage.setItem(isShowManageMetadataLocalStorageKey, 'true') + storage.set(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA, 'true') router.push(`/datasets/${dataSetId}/documents`) } diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 07dd0fca3d..0d13ef77aa 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -23,6 +23,7 @@ import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { IS_CLOUD_EDITION } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' @@ -30,6 +31,7 @@ import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useLogout } from '@/service/use-common' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import AccountAbout from '../account-about' import GithubStar from '../github-star' import Indicator from '../indicator' @@ -55,13 +57,13 @@ export default function AppSelector() { const handleLogout = async () => { await logout() resetUser() - localStorage.removeItem('setup_status') + storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS) // Tokens are now stored in cookies and cleared by backend // To avoid use other account's education notice info - localStorage.removeItem('education-reverify-prev-expire-at') - localStorage.removeItem('education-reverify-has-noticed') - localStorage.removeItem('education-expired-has-noticed') + storage.remove(STORAGE_KEYS.EDUCATION.REVERIFY_PREV_EXPIRE_AT) + storage.remove(STORAGE_KEYS.EDUCATION.REVERIFY_HAS_NOTICED) + storage.remove(STORAGE_KEYS.EDUCATION.EXPIRED_HAS_NOTICED) router.push('/signin') } diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx index 4df7108177..42e9960543 100644 --- a/web/app/components/header/maintenance-notice.tsx +++ b/web/app/components/header/maintenance-notice.tsx @@ -1,18 +1,20 @@ import { useState } from 'react' import { X } from '@/app/components/base/icons/src/vender/line/general' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { STORAGE_KEYS } from '@/config/storage-keys' import { NOTICE_I18N } from '@/i18n-config/language' +import { storage } from '@/utils/storage' const MaintenanceNotice = () => { const locale = useLanguage() - const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1') + const [showNotice, setShowNotice] = useState(() => storage.get(STORAGE_KEYS.UI.HIDE_MAINTENANCE_NOTICE) !== '1') const handleJumpNotice = () => { window.open(NOTICE_I18N.href, '_blank') } const handleCloseNotice = () => { - localStorage.setItem('hide-maintenance-notice', '1') + storage.set(STORAGE_KEYS.UI.HIDE_MAINTENANCE_NOTICE, '1') setShowNotice(false) } diff --git a/web/app/components/signin/countdown.tsx b/web/app/components/signin/countdown.tsx index 8eefd1c945..e6d5b87fed 100644 --- a/web/app/components/signin/countdown.tsx +++ b/web/app/components/signin/countdown.tsx @@ -2,6 +2,8 @@ import { useCountDown } from 'ahooks' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { STORAGE_KEYS } from '@/config/storage-keys' +import { storage } from '@/utils/storage' export const COUNT_DOWN_TIME_MS = 59000 export const COUNT_DOWN_KEY = 'leftTime' @@ -12,23 +14,23 @@ type CountdownProps = { export default function Countdown({ onResend }: CountdownProps) { const { t } = useTranslation() - const [leftTime, setLeftTime] = useState(() => Number(localStorage.getItem(COUNT_DOWN_KEY) || COUNT_DOWN_TIME_MS)) + const [leftTime, setLeftTime] = useState(() => storage.getNumber(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)) const [time] = useCountDown({ leftTime, onEnd: () => { setLeftTime(0) - localStorage.removeItem(COUNT_DOWN_KEY) + storage.remove(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME) }, }) const resend = async function () { setLeftTime(COUNT_DOWN_TIME_MS) - localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS) onResend?.() } useEffect(() => { - localStorage.setItem(COUNT_DOWN_KEY, `${time}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, time) }, [time]) return ( diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index 4e66f08222..051a694f51 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -12,9 +12,11 @@ import Loading from '@/app/components/base/loading' import Tooltip from '@/app/components/base/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useGetLanguage } from '@/context/i18n' import { isServer } from '@/utils/client' import { formatNumber } from '@/utils/format' +import { storage } from '@/utils/storage' import { getMarketplaceUrl } from '@/utils/var' import BlockIcon from '../block-icon' import { BlockEnum } from '../types' @@ -34,8 +36,6 @@ type FeaturedToolsProps = { onInstallSuccess?: () => void } -const STORAGE_KEY = 'workflow_tools_featured_collapsed' - const FeaturedTools = ({ plugins, providerMap, @@ -50,14 +50,14 @@ const FeaturedTools = ({ const [isCollapsed, setIsCollapsed] = useState(() => { if (isServer) return false - const stored = window.localStorage.getItem(STORAGE_KEY) + const stored = storage.get(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED) return stored === 'true' }) useEffect(() => { if (isServer) return - const stored = window.localStorage.getItem(STORAGE_KEY) + const stored = storage.get(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED) if (stored !== null) setIsCollapsed(stored === 'true') }, []) @@ -65,7 +65,7 @@ const FeaturedTools = ({ useEffect(() => { if (isServer) return - window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + storage.set(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED, String(isCollapsed)) }, [isCollapsed]) useEffect(() => { diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index 01cb5d100f..2f5531f783 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -11,9 +11,11 @@ import Loading from '@/app/components/base/loading' import Tooltip from '@/app/components/base/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useGetLanguage } from '@/context/i18n' import { isServer } from '@/utils/client' import { formatNumber } from '@/utils/format' +import { storage } from '@/utils/storage' import { getMarketplaceUrl } from '@/utils/var' import BlockIcon from '../block-icon' import { BlockEnum } from '../types' @@ -30,8 +32,6 @@ type FeaturedTriggersProps = { onInstallSuccess?: () => void | Promise } -const STORAGE_KEY = 'workflow_triggers_featured_collapsed' - const FeaturedTriggers = ({ plugins, providerMap, @@ -45,14 +45,14 @@ const FeaturedTriggers = ({ const [isCollapsed, setIsCollapsed] = useState(() => { if (isServer) return false - const stored = window.localStorage.getItem(STORAGE_KEY) + const stored = storage.get(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED) return stored === 'true' }) useEffect(() => { if (isServer) return - const stored = window.localStorage.getItem(STORAGE_KEY) + const stored = storage.get(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED) if (stored !== null) setIsCollapsed(stored === 'true') }, []) @@ -60,7 +60,7 @@ const FeaturedTriggers = ({ useEffect(() => { if (isServer) return - window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + storage.set(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED, String(isCollapsed)) }, [isCollapsed]) useEffect(() => { diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index e934f27fd1..684e800684 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -10,8 +10,10 @@ import { Trans, useTranslation } from 'react-i18next' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows' import Loading from '@/app/components/base/loading' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useRAGRecommendedPlugins } from '@/service/use-tools' import { isServer } from '@/utils/client' +import { storage } from '@/utils/storage' import { getMarketplaceUrl } from '@/utils/var' import List from './list' @@ -21,8 +23,6 @@ type RAGToolRecommendationsProps = { onTagsChange: Dispatch> } -const STORAGE_KEY = 'workflow_rag_recommendations_collapsed' - const RAGToolRecommendations = ({ viewType, onSelect, @@ -32,14 +32,14 @@ const RAGToolRecommendations = ({ const [isCollapsed, setIsCollapsed] = useState(() => { if (isServer) return false - const stored = window.localStorage.getItem(STORAGE_KEY) + const stored = storage.get(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED) return stored === 'true' }) useEffect(() => { if (isServer) return - const stored = window.localStorage.getItem(STORAGE_KEY) + const stored = storage.get(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED) if (stored !== null) setIsCollapsed(stored === 'true') }, []) @@ -47,7 +47,7 @@ const RAGToolRecommendations = ({ useEffect(() => { if (isServer) return - window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + storage.set(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED, String(isCollapsed)) }, [isCollapsed]) const { diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx index 5f718153b5..9637643731 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx @@ -26,7 +26,7 @@ const createPanelWidthManager = (storageKey: string) => { describe('Workflow Panel Width Persistence', () => { describe('Node Panel Width Management', () => { - const storageKey = 'workflow-node-panel-width' + const storageKey = 'v1:workflow-node-panel-width' it('should save user resize to localStorage', () => { const manager = createPanelWidthManager(storageKey) @@ -74,7 +74,7 @@ describe('Workflow Panel Width Persistence', () => { describe('Bug Scenario Reproduction', () => { it('should reproduce original bug behavior (for comparison)', () => { - const storageKey = 'workflow-node-panel-width' + const storageKey = 'v1:workflow-node-panel-width' // Original buggy behavior - always saves regardless of source const buggyUpdate = (width: number) => { @@ -89,7 +89,7 @@ describe('Workflow Panel Width Persistence', () => { }) it('should verify fix prevents localStorage pollution', () => { - const storageKey = 'workflow-node-panel-width' + const storageKey = 'v1:workflow-node-panel-width' const manager = createPanelWidthManager(storageKey) localStorage.setItem(storageKey, '500') // User preference @@ -101,7 +101,7 @@ describe('Workflow Panel Width Persistence', () => { describe('Edge Cases', () => { it('should handle multiple rapid operations correctly', () => { - const manager = createPanelWidthManager('workflow-node-panel-width') + const manager = createPanelWidthManager('v1:workflow-node-panel-width') // Rapid system adjustments manager.updateWidth(300, 'system') @@ -112,12 +112,12 @@ describe('Workflow Panel Width Persistence', () => { manager.updateWidth(550, 'user') expect(localStorage.setItem).toHaveBeenCalledTimes(1) - expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550') + expect(localStorage.setItem).toHaveBeenCalledWith('v1:workflow-node-panel-width', '550') }) it('should handle corrupted localStorage gracefully', () => { - localStorage.setItem('workflow-node-panel-width', '150') // Below minimum - const manager = createPanelWidthManager('workflow-node-panel-width') + localStorage.setItem('v1:workflow-node-panel-width', '150') // Below minimum + const manager = createPanelWidthManager('v1:workflow-node-panel-width') const storedWidth = manager.getStoredWidth() expect(storedWidth).toBe(150) // Returns raw value @@ -125,13 +125,13 @@ describe('Workflow Panel Width Persistence', () => { // User can correct the preference const correctedWidth = manager.updateWidth(500, 'user') expect(correctedWidth).toBe(500) - expect(localStorage.getItem('workflow-node-panel-width')).toBe('500') + expect(localStorage.getItem('v1:workflow-node-panel-width')).toBe('500') }) }) describe('TypeScript Type Safety', () => { it('should enforce source parameter type', () => { - const manager = createPanelWidthManager('workflow-node-panel-width') + const manager = createPanelWidthManager('v1:workflow-node-panel-width') // Valid source values manager.updateWidth(500, 'user') diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx index 6a34925275..771a70db93 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx @@ -12,10 +12,12 @@ import { import Toast from '@/app/components/base/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { STORAGE_KEYS } from '@/config/storage-keys' import useTheme from '@/hooks/use-theme' import { useGenerateStructuredOutputRules } from '@/service/use-common' import { ModelModeType, Theme } from '@/types/app' import { cn } from '@/utils/classnames' +import { storage } from '@/utils/storage' import { useMittContext } from '../visual-editor/context' import { useVisualEditorStore } from '../visual-editor/store' import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets' @@ -36,9 +38,7 @@ const JsonSchemaGenerator: FC = ({ onApply, crossAxisOffset, }) => { - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model - : null + const localModel = storage.get(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL) const [open, setOpen] = useState(false) const [view, setView] = useState(GeneratorView.promptEditor) const [model, setModel] = useState(localModel || { @@ -60,9 +60,7 @@ const JsonSchemaGenerator: FC = ({ useEffect(() => { if (defaultModel) { - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') || '') - : null + const localModel = storage.get(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL) if (localModel) { setModel(localModel) } @@ -95,7 +93,7 @@ const JsonSchemaGenerator: FC = ({ mode: newValue.mode as ModelModeType, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel) }, [model, setModel]) const handleCompletionParamsChange = useCallback((newParams: FormValue) => { @@ -104,7 +102,7 @@ const JsonSchemaGenerator: FC = ({ completion_params: newParams as CompletionParams, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel) }, [model, setModel]) const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules() diff --git a/web/app/components/workflow/panel/debug-and-preview/index.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/index.spec.tsx index 4554dc36c4..ccfa2f2c67 100644 --- a/web/app/components/workflow/panel/debug-and-preview/index.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/index.spec.tsx @@ -27,7 +27,7 @@ const createMockLocalStorage = () => { // Preview panel width logic const createPreviewPanelManager = () => { - const storageKey = 'debug-and-preview-panel-width' + const storageKey = 'v1:debug-and-preview-panel-width' return { updateWidth: (width: number, source: PanelWidthSource = 'user') => { @@ -63,7 +63,7 @@ describe('Debug and Preview Panel Width Persistence', () => { const result = manager.updateWidth(450, 'user') expect(result).toBe(450) - expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '450') + expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '450') }) it('should not save system compression to localStorage', () => { @@ -80,17 +80,17 @@ describe('Debug and Preview Panel Width Persistence', () => { // Both user and system operations should behave consistently manager.updateWidth(500, 'user') - expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500') + expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '500') manager.updateWidth(200, 'system') - expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500') + expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('500') }) }) describe('Dual Panel Scenario', () => { it('should maintain independence from Node Panel', () => { - localStorage.setItem('workflow-node-panel-width', '600') - localStorage.setItem('debug-and-preview-panel-width', '450') + localStorage.setItem('v1:workflow-node-panel-width', '600') + localStorage.setItem('v1:debug-and-preview-panel-width', '450') const manager = createPreviewPanelManager() @@ -98,8 +98,8 @@ describe('Debug and Preview Panel Width Persistence', () => { manager.updateWidth(200, 'system') // Only preview panel storage key should be unaffected - expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450') - expect(localStorage.getItem('workflow-node-panel-width')).toBe('600') + expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('450') + expect(localStorage.getItem('v1:workflow-node-panel-width')).toBe('600') }) it('should handle F12 scenario consistently', () => { @@ -107,13 +107,13 @@ describe('Debug and Preview Panel Width Persistence', () => { // User sets preference manager.updateWidth(500, 'user') - expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500') + expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('500') // F12 opens causing viewport compression manager.updateWidth(180, 'system') // User preference preserved - expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500') + expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('500') }) }) @@ -124,7 +124,7 @@ describe('Debug and Preview Panel Width Persistence', () => { // Same 400px minimum as Node Panel const result = manager.updateWidth(300, 'user') expect(result).toBe(400) - expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '400') + expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '400') }) it('should use same source parameter pattern', () => { @@ -132,7 +132,7 @@ describe('Debug and Preview Panel Width Persistence', () => { // Default to 'user' when source not specified manager.updateWidth(500) - expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500') + expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '500') // Explicit 'system' source manager.updateWidth(300, 'system') diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 3f8d80a67e..4adb74ca87 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -13,13 +13,14 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Checkbox from '@/app/components/base/checkbox' import { useToastContext } from '@/app/components/base/toast' -import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { useEducationAdd, useInvalidateEducationStatus, } from '@/service/use-education' +import { storage } from '@/utils/storage' import DifyLogo from '../components/base/logo/dify-logo' import RoleSelector from './role-selector' import SearchInput from './search-input' @@ -47,7 +48,7 @@ const EducationApplyAge = () => { setShowModal(undefined) onPlanInfoChanged() updateEducationStatus() - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING) router.replace('/') } diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index 52acde2975..c68c0122b2 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -10,14 +10,15 @@ import { useState, } from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education' +import { storage } from '@/utils/storage' import { EDUCATION_RE_VERIFY_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, - EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from './constants' dayjs.extend(utc) @@ -133,7 +134,7 @@ const useEducationReverifyNotice = ({ export const useEducationInit = () => { const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal) - const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + const educationVerifying = storage.get(STORAGE_KEYS.EDUCATION.VERIFYING) const searchParams = useSearchParams() const educationVerifyAction = searchParams.get('action') @@ -156,7 +157,7 @@ export const useEducationInit = () => { setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) - localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') + storage.set(STORAGE_KEYS.EDUCATION.VERIFYING, 'yes') } if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION) handleVerify() diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx index cc7f0bb63e..fee845235c 100644 --- a/web/app/education-apply/user-info.tsx +++ b/web/app/education-apply/user-info.tsx @@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next' import Avatar from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import { Triangle } from '@/app/components/base/icons/src/public/education' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useLogout } from '@/service/use-common' +import { storage } from '@/utils/storage' const UserInfo = () => { const router = useRouter() @@ -15,7 +17,7 @@ const UserInfo = () => { const handleLogout = async () => { await logout() - localStorage.removeItem('setup_status') + storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS) // Tokens are now stored in cookies and cleared by backend router.push('/signin') diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx index 17ce35d6a1..49e46308b6 100644 --- a/web/app/install/installForm.spec.tsx +++ b/web/app/install/installForm.spec.tsx @@ -158,7 +158,7 @@ describe('InstallForm', () => { render() await waitFor(() => { - expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished') + expect(localStorage.setItem).toHaveBeenCalledWith('v1:setup_status', 'finished') expect(mockPush).toHaveBeenCalledWith('/signin') }) }) diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 1cd5dce19a..7328503341 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -13,12 +13,14 @@ import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' import Input from '@/app/components/base/input' import { validPassword } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { LICENSE_LINK } from '@/constants/link' import useDocumentTitle from '@/hooks/use-document-title' import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common' import { cn } from '@/utils/classnames' import { encryptPassword as encodePassword } from '@/utils/encryption' +import { storage } from '@/utils/storage' import Loading from '../components/base/loading' const accountFormSchema = z.object({ @@ -85,7 +87,7 @@ const InstallForm = () => { useEffect(() => { fetchSetupStatus().then((res: SetupStatusResponse) => { if (res.step === 'finished') { - localStorage.setItem('setup_status', 'finished') + storage.set(STORAGE_KEYS.CONFIG.SETUP_STATUS, 'finished') router.push('/signin') } else { diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index 9fdccdfd87..8f6d514232 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -9,10 +9,12 @@ import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { emailRegex } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { sendResetPasswordCode } from '@/service/common' -import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown' +import { storage } from '@/utils/storage' +import { COUNT_DOWN_TIME_MS } from '../components/signin/countdown' export default function CheckCode() { const { t } = useTranslation() @@ -40,7 +42,7 @@ export default function CheckCode() { setIsLoading(true) const res = await sendResetPasswordCode(email, locale) if (res.result === 'success') { - localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS) const params = new URLSearchParams(searchParams) params.set('token', encodeURIComponent(res.data)) params.set('email', encodeURIComponent(email)) diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index 4454fc821f..4a467405a2 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' -import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useLocale } from '@/context/i18n' import { sendEMailLoginCode } from '@/service/common' +import { storage } from '@/utils/storage' type MailAndCodeAuthProps = { isInvite: boolean @@ -40,7 +42,7 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { setIsLoading(true) const ret = await sendEMailLoginCode(email, locale) if (ret.result === 'success') { - localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS) const params = new URLSearchParams(searchParams) params.set('email', encodeURIComponent(email)) params.set('token', encodeURIComponent(ret.data)) diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index b548a1bac9..72dbf8a63b 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -1,15 +1,17 @@ import type { ReadonlyURLSearchParams } from 'next/navigation' import dayjs from 'dayjs' -import { OAUTH_AUTHORIZE_PENDING_KEY, REDIRECT_URL_KEY } from '@/app/account/oauth/authorize/constants' +import { REDIRECT_URL_KEY } from '@/app/account/oauth/authorize/constants' +import { STORAGE_KEYS } from '@/config/storage-keys' +import { storage } from '@/utils/storage' function getItemWithExpiry(key: string): string | null { - const itemStr = localStorage.getItem(key) + const itemStr = storage.get(key) if (!itemStr) return null try { const item = JSON.parse(itemStr) - localStorage.removeItem(key) + storage.remove(key) if (!item?.value) return null @@ -24,7 +26,7 @@ export const resolvePostLoginRedirect = (searchParams: ReadonlyURLSearchParams) const redirectUrl = searchParams.get(REDIRECT_URL_KEY) if (redirectUrl) { try { - localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY) + storage.remove(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING) return decodeURIComponent(redirectUrl) } catch (e) { @@ -33,5 +35,5 @@ export const resolvePostLoginRedirect = (searchParams: ReadonlyURLSearchParams) } } - return getItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY) + return getItemWithExpiry(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING) } diff --git a/web/config/storage-keys.ts b/web/config/storage-keys.ts index c203cb4057..21c95bccc6 100644 --- a/web/config/storage-keys.ts +++ b/web/config/storage-keys.ts @@ -5,6 +5,9 @@ export const STORAGE_KEYS = { VARIABLE_INSPECT_PANEL_HEIGHT: 'workflow-variable-inspect-panel-height', CANVAS_MAXIMIZE: 'workflow-canvas-maximize', OPERATION_MODE: 'workflow-operation-mode', + RAG_RECOMMENDATIONS_COLLAPSED: 'workflow_rag_recommendations_collapsed', + TOOLS_FEATURED_COLLAPSED: 'workflow_tools_featured_collapsed', + TRIGGERS_FEATURED_COLLAPSED: 'workflow_triggers_featured_collapsed', }, APP: { SIDEBAR_COLLAPSE: 'webappSidebarCollapse', @@ -18,6 +21,7 @@ export const STORAGE_KEYS = { ACCESS_TOKEN: 'access_token', REFRESH_LOCK: 'is_other_tab_refreshing', LAST_REFRESH_TIME: 'last_refresh_time', + OAUTH_AUTHORIZE_PENDING: 'oauth_authorize_pending', }, EDUCATION: { VERIFYING: 'educationVerifying', @@ -30,6 +34,44 @@ export const STORAGE_KEYS = { DEBUG_MODELS: 'app-debug-with-single-or-multiple-models', SETUP_STATUS: 'setup_status', }, + UI: { + THEME: 'theme', + ANTHROPIC_QUOTA_NOTICE: 'anthropic_quota_notice', + HIDE_MAINTENANCE_NOTICE: 'hide-maintenance-notice', + COUNTDOWN_LEFT_TIME: 'leftTime', + SHOW_MANAGE_METADATA: 'dify-isShowManageMetadata', + }, } as const export type StorageKeys = typeof STORAGE_KEYS + +export const LEGACY_KEY_MIGRATIONS: Array<{ old: string, new: string }> = [ + { old: 'workflow-node-panel-width', new: 'workflow-node-panel-width' }, + { old: 'debug-and-preview-panel-width', new: 'debug-and-preview-panel-width' }, + { old: 'workflow-variable-inspect-panel-height', new: 'workflow-variable-inspect-panel-height' }, + { old: 'workflow-canvas-maximize', new: 'workflow-canvas-maximize' }, + { old: 'workflow-operation-mode', new: 'workflow-operation-mode' }, + { old: 'workflow_rag_recommendations_collapsed', new: 'workflow_rag_recommendations_collapsed' }, + { old: 'workflow_tools_featured_collapsed', new: 'workflow_tools_featured_collapsed' }, + { old: 'workflow_triggers_featured_collapsed', new: 'workflow_triggers_featured_collapsed' }, + { old: 'webappSidebarCollapse', new: 'webappSidebarCollapse' }, + { old: 'needRefreshAppList', new: 'needRefreshAppList' }, + { old: 'app-detail-collapse-or-expand', new: 'app-detail-collapse-or-expand' }, + { old: 'conversationIdInfo', new: 'conversationIdInfo' }, + { old: 'access_token', new: 'access_token' }, + { old: 'is_other_tab_refreshing', new: 'is_other_tab_refreshing' }, + { old: 'last_refresh_time', new: 'last_refresh_time' }, + { old: 'oauth_authorize_pending', new: 'oauth_authorize_pending' }, + { old: 'educationVerifying', new: 'educationVerifying' }, + { old: 'education-reverify-prev-expire-at', new: 'education-reverify-prev-expire-at' }, + { old: 'education-reverify-has-noticed', new: 'education-reverify-has-noticed' }, + { old: 'education-expired-has-noticed', new: 'education-expired-has-noticed' }, + { old: 'auto-gen-model', new: 'auto-gen-model' }, + { old: 'app-debug-with-single-or-multiple-models', new: 'app-debug-with-single-or-multiple-models' }, + { old: 'setup_status', new: 'setup_status' }, + { old: 'theme', new: 'theme' }, + { old: 'anthropic_quota_notice', new: 'anthropic_quota_notice' }, + { old: 'hide-maintenance-notice', new: 'hide-maintenance-notice' }, + { old: 'leftTime', new: 'leftTime' }, + { old: 'dify-isShowManageMetadata', new: 'dify-isShowManageMetadata' }, +] diff --git a/web/context/hooks/use-trigger-events-limit-modal.ts b/web/context/hooks/use-trigger-events-limit-modal.ts index 72342cd0d3..1b860cc34f 100644 --- a/web/context/hooks/use-trigger-events-limit-modal.ts +++ b/web/context/hooks/use-trigger-events-limit-modal.ts @@ -6,6 +6,7 @@ import { NUM_INFINITE } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { IS_CLOUD_EDITION } from '@/config' import { isServer } from '@/utils/client' +import { storage } from '@/utils/storage' export type TriggerEventsLimitModalPayload = { usage: number @@ -80,15 +81,10 @@ export const useTriggerEventsLimitModal = ({ if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey]) return - let persistDismiss = true + const persistDismiss = storage.isAvailable() let hasDismissed = false - try { - if (localStorage.getItem(storageKey) === '1') - hasDismissed = true - } - catch { - persistDismiss = false - } + if (storage.get(storageKey) === '1') + hasDismissed = true if (hasDismissed) return @@ -110,16 +106,9 @@ export const useTriggerEventsLimitModal = ({ const storageKey = showTriggerEventsLimitModal?.payload.storageKey if (!storageKey) return - if (showTriggerEventsLimitModal?.payload.persistDismiss) { - try { - localStorage.setItem(storageKey, '1') - return - } - catch { - // ignore error and fall back to in-memory guard - } - } dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true + if (showTriggerEventsLimitModal?.payload.persistDismiss) + storage.set(storageKey, '1') }, [showTriggerEventsLimitModal]) return { diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index 2f2d09c6f0..3ad8c62e30 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -130,7 +130,7 @@ describe('ModalContextProvider trigger events limit modal', () => { expect(setItemSpy.mock.calls.length).toBeGreaterThan(0) }) const [key, value] = setItemSpy.mock.calls[0] - expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-') + expect(key).toContain('v1:trigger-events-limit-dismissed-workspace-1-professional-3000-') expect(value).toBe('1') }) diff --git a/web/context/modal-context.tsx b/web/context/modal-context.tsx index 293970259a..ee8618423d 100644 --- a/web/context/modal-context.tsx +++ b/web/context/modal-context.tsx @@ -30,15 +30,15 @@ import { DEFAULT_ACCOUNT_SETTING_TAB, isValidAccountSettingTab, } from '@/app/components/header/account-setting/constants' -import { - EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, -} from '@/app/education-apply/constants' + +import { STORAGE_KEYS } from '@/config/storage-keys' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { useAccountSettingModal, usePricingModal, } from '@/hooks/use-query-params' +import { storage } from '@/utils/storage' import { @@ -183,10 +183,10 @@ export const ModalContextProvider = ({ const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { - const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + const educationVerifying = storage.get(STORAGE_KEYS.EDUCATION.VERIFYING) if (educationVerifying === 'yes') - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING) accountSettingCallbacksRef.current?.onCancelCallback?.() accountSettingCallbacksRef.current = null diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index d350d08b4a..a13069dd82 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -19,6 +19,7 @@ import { ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ZENDESK_FIELD_IDS } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { fetchCurrentPlanInfo } from '@/service/billing' import { useModelListByType, @@ -28,6 +29,7 @@ import { import { useEducationStatus, } from '@/service/use-education' +import { storage } from '@/utils/storage' export type ProviderContextState = { modelProviders: ModelProvider[] @@ -200,7 +202,7 @@ export const ProviderContextProvider = ({ const { t } = useTranslation() useEffect(() => { - if (localStorage.getItem('anthropic_quota_notice') === 'true') + if (storage.get(STORAGE_KEYS.UI.ANTHROPIC_QUOTA_NOTICE) === 'true') return if (dayjs().isAfter(dayjs('2025-03-17'))) @@ -216,7 +218,7 @@ export const ProviderContextProvider = ({ message: t('provider.anthropicHosted.trialQuotaTip', { ns: 'common' }), duration: 60000, onClose: () => { - localStorage.setItem('anthropic_quota_notice', 'true') + storage.set(STORAGE_KEYS.UI.ANTHROPIC_QUOTA_NOTICE, 'true') }, }) } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 2569da85e5..0fc9e219c0 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -77,9 +77,6 @@ } }, "app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": { - "no-restricted-globals": { - "count": 1 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 }, @@ -87,11 +84,6 @@ "count": 1 } }, - "app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": { "no-console": { "count": 19 @@ -106,9 +98,6 @@ } }, "app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx": { - "no-restricted-globals": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -123,25 +112,12 @@ "count": 1 } }, - "app/(shareLayout)/webapp-reset-password/page.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, - "app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": { "ts/no-explicit-any": { "count": 2 } }, "app/account/(commonLayout)/account-page/email-change-modal.tsx": { - "no-restricted-globals": { - "count": 1 - }, "ts/no-explicit-any": { "count": 5 } @@ -151,43 +127,22 @@ "count": 1 } }, - "app/account/(commonLayout)/avatar.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/account/(commonLayout)/delete-account/components/verify-email.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 } }, - "app/account/(commonLayout)/delete-account/index.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/account/oauth/authorize/layout.tsx": { "ts/no-explicit-any": { "count": 1 } }, "app/account/oauth/authorize/page.tsx": { - "no-restricted-globals": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } }, - "app/components/app-initializer.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/components/app-sidebar/app-info.tsx": { - "no-restricted-globals": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -377,9 +332,6 @@ } }, "app/components/app/configuration/config/automatic/get-automatic-res.tsx": { - "no-restricted-globals": { - "count": 6 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 4 }, @@ -403,9 +355,6 @@ } }, "app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": { - "no-restricted-globals": { - "count": 6 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 4 }, @@ -505,9 +454,6 @@ } }, "app/components/app/configuration/debug/hooks.tsx": { - "no-restricted-globals": { - "count": 2 - }, "ts/no-explicit-any": { "count": 3 } @@ -561,11 +507,6 @@ "count": 1 } }, - "app/components/app/create-app-dialog/app-list/index.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/components/app/create-app-modal/index.spec.tsx": { "no-restricted-properties": { "count": 1 @@ -575,9 +516,6 @@ } }, "app/components/app/create-app-modal/index.tsx": { - "no-restricted-globals": { - "count": 1 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 }, @@ -586,9 +524,6 @@ } }, "app/components/app/create-from-dsl-modal/index.tsx": { - "no-restricted-globals": { - "count": 2 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 } @@ -656,9 +591,6 @@ } }, "app/components/app/switch-app-modal/index.tsx": { - "no-restricted-globals": { - "count": 1 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 } @@ -697,9 +629,6 @@ } }, "app/components/apps/app-card.tsx": { - "no-restricted-globals": { - "count": 1 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 }, @@ -719,9 +648,6 @@ } }, "app/components/apps/list.tsx": { - "no-restricted-globals": { - "count": 2 - }, "unused-imports/no-unused-vars": { "count": 1 } @@ -852,9 +778,6 @@ } }, "app/components/base/chat/chat-with-history/hooks.tsx": { - "no-restricted-globals": { - "count": 2 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 4 }, @@ -1633,9 +1556,6 @@ } }, "app/components/billing/plan/index.tsx": { - "no-restricted-globals": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -1993,9 +1913,6 @@ } }, "app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": { - "no-restricted-globals": { - "count": 2 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 } @@ -2015,11 +1932,6 @@ "count": 1 } }, - "app/components/datasets/metadata/metadata-document/info-group.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/components/datasets/settings/form/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 @@ -2125,11 +2037,6 @@ "count": 1 } }, - "app/components/header/account-dropdown/index.tsx": { - "no-restricted-globals": { - "count": 4 - } - }, "app/components/header/account-setting/data-source-page-new/card.tsx": { "ts/no-explicit-any": { "count": 2 @@ -2314,11 +2221,6 @@ "count": 1 } }, - "app/components/header/maintenance-notice.tsx": { - "no-restricted-globals": { - "count": 2 - } - }, "app/components/plugins/install-plugin/hooks.ts": { "ts/no-explicit-any": { "count": 4 @@ -2782,11 +2684,6 @@ "count": 2 } }, - "app/components/signin/countdown.tsx": { - "no-restricted-globals": { - "count": 4 - } - }, "app/components/tools/edit-custom-collection-modal/get-schema.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2947,9 +2844,6 @@ } }, "app/components/workflow/block-selector/featured-tools.tsx": { - "no-restricted-properties": { - "count": 3 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 }, @@ -2958,9 +2852,6 @@ } }, "app/components/workflow/block-selector/featured-triggers.tsx": { - "no-restricted-properties": { - "count": 3 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 }, @@ -2979,9 +2870,6 @@ } }, "app/components/workflow/block-selector/rag-tool-recommendations/index.tsx": { - "no-restricted-properties": { - "count": 3 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 } @@ -3601,9 +3489,6 @@ } }, "app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": { - "no-restricted-globals": { - "count": 6 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 } @@ -4230,15 +4115,7 @@ "count": 1 } }, - "app/education-apply/education-apply-page.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/education-apply/hooks.ts": { - "no-restricted-globals": { - "count": 2 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 5 } @@ -4248,11 +4125,6 @@ "count": 1 } }, - "app/education-apply/user-info.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/education-apply/verify-state-modal.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -4276,26 +4148,11 @@ "count": 7 } }, - "app/install/installForm.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/reset-password/layout.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "app/reset-password/page.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, - "app/signin/components/mail-and-code-auth.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "app/signin/components/mail-and-password-auth.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4311,11 +4168,6 @@ "count": 1 } }, - "app/signin/utils/post-login-redirect.ts": { - "no-restricted-globals": { - "count": 3 - } - }, "app/signup/layout.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4327,9 +4179,6 @@ } }, "context/hooks/use-trigger-events-limit-modal.ts": { - "no-restricted-globals": { - "count": 2 - }, "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 3 } @@ -4346,17 +4195,11 @@ } }, "context/modal-context.tsx": { - "no-restricted-globals": { - "count": 2 - }, "ts/no-explicit-any": { "count": 5 } }, "context/provider-context.tsx": { - "no-restricted-globals": { - "count": 2 - }, "ts/no-explicit-any": { "count": 1 } @@ -4374,11 +4217,6 @@ "count": 1 } }, - "hooks/use-import-dsl.ts": { - "no-restricted-globals": { - "count": 2 - } - }, "hooks/use-metadata.ts": { "ts/no-explicit-any": { "count": 1 @@ -4545,11 +4383,6 @@ "count": 2 } }, - "service/refresh-token.ts": { - "no-restricted-properties": { - "count": 7 - } - }, "service/share.ts": { "ts/no-explicit-any": { "count": 3 @@ -4614,11 +4447,6 @@ "count": 2 } }, - "service/webapp-auth.ts": { - "no-restricted-globals": { - "count": 6 - } - }, "service/workflow-payload.ts": { "ts/no-explicit-any": { "count": 10 @@ -4735,11 +4563,6 @@ "count": 11 } }, - "utils/setup-status.ts": { - "no-restricted-globals": { - "count": 3 - } - }, "utils/tool-call.spec.ts": { "ts/no-explicit-any": { "count": 1 @@ -4750,4 +4573,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index ba33db1e84..8721912ebc 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -12,7 +12,7 @@ import { import { useTranslation } from 'react-i18next' import { useToastContext } from '@/app/components/base/toast' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { useSelector } from '@/context/app-context' import { DSLImportStatus } from '@/models/app' import { @@ -20,6 +20,7 @@ import { importDSLConfirm, } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' +import { storage } from '@/utils/storage' type DSLPayload = { mode: DSLImportMode @@ -83,7 +84,7 @@ export const useImportDSL = () => { children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), }) onSuccess?.() - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') await handleCheckPluginDependencies(app_id) getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push) } @@ -137,7 +138,7 @@ export const useImportDSL = () => { message: t('newApp.appCreated', { ns: 'app' }), }) await handleCheckPluginDependencies(app_id) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1') getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push) } else if (status === DSLImportStatus.FAILED) { diff --git a/web/service/refresh-token.ts b/web/service/refresh-token.ts index 998b7f2461..78853597f2 100644 --- a/web/service/refresh-token.ts +++ b/web/service/refresh-token.ts @@ -1,13 +1,13 @@ import { API_PREFIX } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' import { fetchWithRetry } from '@/utils' - -const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing' +import { storage } from '@/utils/storage' let isRefreshing = false function waitUntilTokenRefreshed() { return new Promise((resolve) => { function _check() { - const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY) + const isRefreshingSign = storage.get(STORAGE_KEYS.AUTH.REFRESH_LOCK) if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) { setTimeout(() => { _check() @@ -23,35 +23,28 @@ function waitUntilTokenRefreshed() { const isRefreshingSignAvailable = function (delta: number) { const nowTime = new Date().getTime() - const lastTime = globalThis.localStorage.getItem('last_refresh_time') || '0' + const lastTime = storage.get(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME) || '0' return nowTime - Number.parseInt(lastTime) <= delta } -// only one request can send async function getNewAccessToken(timeout: number): Promise { try { - const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY) + const isRefreshingSign = storage.get(STORAGE_KEYS.AUTH.REFRESH_LOCK) 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()) + storage.set(STORAGE_KEYS.AUTH.REFRESH_LOCK, '1') + storage.set(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME, new Date().getTime().toString()) globalThis.addEventListener('beforeunload', releaseRefreshLock) - // 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', - credentials: 'include', // Important: include cookies in the request + credentials: 'include', headers: { 'Content-Type': 'application/json;utf-8', }, - // No body needed - refresh token is in cookie })) if (error) { return Promise.reject(error) @@ -72,11 +65,9 @@ async function getNewAccessToken(timeout: number): Promise { } function releaseRefreshLock() { - // Always clear the refresh lock to avoid cross-tab deadlocks. - // This is safe to call multiple times and from tabs that were only waiting. isRefreshing = false - globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) - globalThis.localStorage.removeItem('last_refresh_time') + storage.remove(STORAGE_KEYS.AUTH.REFRESH_LOCK) + storage.remove(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME) globalThis.removeEventListener('beforeunload', releaseRefreshLock) } diff --git a/web/service/webapp-auth.ts b/web/service/webapp-auth.ts index 7a9abd9599..89859b8a03 100644 --- a/web/service/webapp-auth.ts +++ b/web/service/webapp-auth.ts @@ -1,28 +1,29 @@ -import { ACCESS_TOKEN_LOCAL_STORAGE_NAME, PASSPORT_LOCAL_STORAGE_NAME } from '@/config' +import { STORAGE_KEYS } from '@/config/storage-keys' +import { storage } from '@/utils/storage' import { getPublic, postPublic } from './base' export function setWebAppAccessToken(token: string) { - localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME, token) + storage.set(STORAGE_KEYS.AUTH.ACCESS_TOKEN, token) } export function setWebAppPassport(shareCode: string, token: string) { - localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode), token) + storage.set(`passport-${shareCode}`, token) } export function getWebAppAccessToken() { - return localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) || '' + return storage.get(STORAGE_KEYS.AUTH.ACCESS_TOKEN) || '' } export function getWebAppPassport(shareCode: string) { - return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || '' + return storage.get(`passport-${shareCode}`) || '' } export function clearWebAppAccessToken() { - localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) + storage.remove(STORAGE_KEYS.AUTH.ACCESS_TOKEN) } export function clearWebAppPassport(shareCode: string) { - localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) + storage.remove(`passport-${shareCode}`) } type isWebAppLogin = { @@ -31,8 +32,6 @@ type isWebAppLogin = { } export async function webAppLoginStatus(shareCode: string, userId?: string) { - // always need to check login to prevent passport from being outdated - // check remotely, the access token could be in cookie (enterprise SSO redirected with https) const params = new URLSearchParams({ app_code: shareCode }) if (userId) params.append('user_id', userId) diff --git a/web/utils/setup-status.spec.ts b/web/utils/setup-status.spec.ts index be96b43eba..94f6fe03c3 100644 --- a/web/utils/setup-status.spec.ts +++ b/web/utils/setup-status.spec.ts @@ -19,7 +19,7 @@ describe('setup-status utilities', () => { describe('fetchSetupStatusWithCache', () => { describe('when cache exists', () => { it('should return cached finished status without API call', async () => { - localStorage.setItem('setup_status', 'finished') + localStorage.setItem('v1:setup_status', 'finished') const result = await fetchSetupStatusWithCache() @@ -28,11 +28,11 @@ describe('setup-status utilities', () => { }) it('should not modify localStorage when returning cached value', async () => { - localStorage.setItem('setup_status', 'finished') + localStorage.setItem('v1:setup_status', 'finished') await fetchSetupStatusWithCache() - expect(localStorage.getItem('setup_status')).toBe('finished') + expect(localStorage.getItem('v1:setup_status')).toBe('finished') }) }) @@ -45,7 +45,7 @@ describe('setup-status utilities', () => { expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1) expect(result).toEqual(apiResponse) - expect(localStorage.getItem('setup_status')).toBe('finished') + expect(localStorage.getItem('v1:setup_status')).toBe('finished') }) it('should call API and remove cache when not finished', async () => { @@ -56,24 +56,24 @@ describe('setup-status utilities', () => { expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1) expect(result).toEqual(apiResponse) - expect(localStorage.getItem('setup_status')).toBeNull() + expect(localStorage.getItem('v1:setup_status')).toBeNull() }) it('should clear stale cache when API returns not_started', async () => { - localStorage.setItem('setup_status', 'some_invalid_value') + localStorage.setItem('v1:setup_status', 'some_invalid_value') const apiResponse: SetupStatusResponse = { step: 'not_started' } mockFetchSetupStatus.mockResolvedValue(apiResponse) const result = await fetchSetupStatusWithCache() expect(result).toEqual(apiResponse) - expect(localStorage.getItem('setup_status')).toBeNull() + expect(localStorage.getItem('v1:setup_status')).toBeNull() }) }) describe('cache edge cases', () => { it('should call API when cache value is empty string', async () => { - localStorage.setItem('setup_status', '') + localStorage.setItem('v1:setup_status', '') const apiResponse: SetupStatusResponse = { step: 'finished' } mockFetchSetupStatus.mockResolvedValue(apiResponse) @@ -84,7 +84,7 @@ describe('setup-status utilities', () => { }) it('should call API when cache value is not "finished"', async () => { - localStorage.setItem('setup_status', 'not_started') + localStorage.setItem('v1:setup_status', 'not_started') const apiResponse: SetupStatusResponse = { step: 'finished' } mockFetchSetupStatus.mockResolvedValue(apiResponse) @@ -132,7 +132,7 @@ describe('setup-status utilities', () => { await expect(fetchSetupStatusWithCache()).rejects.toThrow() - expect(localStorage.getItem('setup_status')).toBeNull() + expect(localStorage.getItem('v1:setup_status')).toBeNull() }) }) }) diff --git a/web/utils/setup-status.ts b/web/utils/setup-status.ts index 7a2810bffd..4ae149405e 100644 --- a/web/utils/setup-status.ts +++ b/web/utils/setup-status.ts @@ -1,10 +1,10 @@ import type { SetupStatusResponse } from '@/models/common' +import { STORAGE_KEYS } from '@/config/storage-keys' import { fetchSetupStatus } from '@/service/common' - -const SETUP_STATUS_KEY = 'setup_status' +import { storage } from './storage' const isSetupStatusCached = (): boolean => - localStorage.getItem(SETUP_STATUS_KEY) === 'finished' + storage.get(STORAGE_KEYS.CONFIG.SETUP_STATUS) === 'finished' export const fetchSetupStatusWithCache = async (): Promise => { if (isSetupStatusCached()) @@ -13,9 +13,9 @@ export const fetchSetupStatusWithCache = async (): Promise const status = await fetchSetupStatus() if (status.step === 'finished') - localStorage.setItem(SETUP_STATUS_KEY, 'finished') + storage.set(STORAGE_KEYS.CONFIG.SETUP_STATUS, 'finished') else - localStorage.removeItem(SETUP_STATUS_KEY) + storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS) return status } diff --git a/web/utils/storage.ts b/web/utils/storage.ts index 640861fd4b..d4bedf171d 100644 --- a/web/utils/storage.ts +++ b/web/utils/storage.ts @@ -3,6 +3,9 @@ import { isClient } from './client' type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } +const STORAGE_VERSION = 'v1' +const MIGRATION_FLAG_KEY = '__storage_migrated__' + let _isAvailable: boolean | null = null function isLocalStorageAvailable(): boolean { @@ -27,12 +30,52 @@ function isLocalStorageAvailable(): boolean { } } +function versionedKey(key: string): string { + return `${STORAGE_VERSION}:${key}` +} + +function getRaw(key: string): string | null { + if (!isLocalStorageAvailable()) + return null + + try { + return localStorage.getItem(key) + } + catch { + return null + } +} + +function setRaw(key: string, value: string): void { + if (!isLocalStorageAvailable()) + return + + try { + localStorage.setItem(key, value) + } + catch { + // Silent fail - localStorage may be full or disabled + } +} + +function removeRaw(key: string): void { + if (!isLocalStorageAvailable()) + return + + try { + localStorage.removeItem(key) + } + catch { + // Silent fail + } +} + function get(key: string, defaultValue?: T): T | null { if (!isLocalStorageAvailable()) return defaultValue ?? null try { - const item = localStorage.getItem(key) + const item = localStorage.getItem(versionedKey(key)) if (item === null) return defaultValue ?? null @@ -54,7 +97,7 @@ function set(key: string, value: T): void { try { const stringValue = typeof value === 'string' ? value : JSON.stringify(value) - localStorage.setItem(key, stringValue) + localStorage.setItem(versionedKey(key), stringValue) } catch { // Silent fail - localStorage may be full or disabled @@ -66,7 +109,7 @@ function remove(key: string): void { return try { - localStorage.removeItem(key) + localStorage.removeItem(versionedKey(key)) } catch { // Silent fail @@ -97,6 +140,43 @@ function getBoolean(key: string, defaultValue?: boolean): boolean | null { return value === 'true' } +type MigrationEntry = { old: string, new: string } + +function migrate(oldKey: string, newKey: string): boolean { + if (!isLocalStorageAvailable()) + return false + + const oldValue = getRaw(oldKey) + if (oldValue === null) + return false + + const newVersionedKey = versionedKey(newKey) + if (getRaw(newVersionedKey) !== null) + return false + + setRaw(newVersionedKey, oldValue) + removeRaw(oldKey) + return true +} + +function runMigrations(migrations: MigrationEntry[]): void { + if (!isLocalStorageAvailable()) + return + + const migrationFlagValue = getRaw(MIGRATION_FLAG_KEY) + if (migrationFlagValue === STORAGE_VERSION) + return + + for (const { old: oldKey, new: newKey } of migrations) + migrate(oldKey, newKey) + + setRaw(MIGRATION_FLAG_KEY, STORAGE_VERSION) +} + +function resetCache(): void { + _isAvailable = null +} + export const storage = { get, set, @@ -104,4 +184,7 @@ export const storage = { getNumber, getBoolean, isAvailable: isLocalStorageAvailable, + migrate, + runMigrations, + resetCache, } diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index 597ded9559..d7270f483e 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -134,7 +134,7 @@ const createMockLocalStorage = () => { let mockLocalStorage: ReturnType -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks() mockLocalStorage = createMockLocalStorage() Object.defineProperty(globalThis, 'localStorage', { @@ -142,4 +142,7 @@ beforeEach(() => { writable: true, configurable: true, }) + // Reset storage module cache to ensure fresh state for each test + const { storage } = await import('./utils/storage') + storage.resetCache() })