Compare commits

...

8 Commits

Author SHA1 Message Date
yyh
3ca098eaa5 migrate 2026-01-22 22:34:07 +08:00
yyh
03faf8e91b Merge remote-tracking branch 'origin/main' into refactor/local-storage
# Conflicts:
#	web/eslint-suppressions.json
2026-01-22 21:45:47 +08:00
yyh
1cf3e599df fix: remove panelWidth prop in panel slice, use default value 420 in layout slice(single source) 2026-01-18 16:33:59 +08:00
yyh
a85946d3e7 refactor(web): remove redundant nullish coalescing from storage calls
Function overloads in storage utility already guarantee non-null returns
when defaultValue is provided, making ?? fallbacks unnecessary.
2026-01-18 16:21:29 +08:00
yyh
9e4d3c75ae fix 2026-01-18 16:15:30 +08:00
yyh
70bea85624 refactor(web): improve storage utility type safety with function overloads
Add function overloads to getNumber and getBoolean so they return
non-nullable types when a defaultValue is provided. This eliminates
the need for non-null assertions at call sites.

- Remove unused persist-config.ts (zustand adapter not needed)
- Remove ! assertions from layout-slice.ts
2026-01-18 16:10:04 +08:00
yyh
b75b7d6c61 fix: unused 2026-01-18 16:05:10 +08:00
yyh
e819b804ba refactor(web): add SSR-safe localStorage utility and ESLint rules
Introduce centralized storage utilities to address SSR issues with direct
localStorage access in zustand slices and components. This adds ESLint
rules to prevent future regressions while preserving existing usages
via bulk suppressions.

- Add config/storage-keys.ts for centralized storage key definitions
- Add utils/storage.ts with SSR-safe get/set/remove operations
- Add workflow/store/persist-config.ts for zustand storage adapter
- Add no-restricted-globals and no-restricted-properties ESLint rules
- Migrate workflow slices and related components to use new utilities
2026-01-18 16:01:04 +08:00
73 changed files with 658 additions and 269 deletions

View File

@@ -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<IAppDetailLayoutProps> = (props) => {
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = storage.get<string>(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode

View File

@@ -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<ICardViewProps> = ({ appId, isInPanel, className }) => {
}) as Promise<App>,
)
if (!err)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
handleCallbackResult(err)
}

View File

@@ -18,6 +18,7 @@ import { useStore } from '@/app/components/app/store'
import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import Loading from '@/app/components/base/loading'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@@ -25,6 +26,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
export type IAppDetailLayoutProps = {
children: React.ReactNode
@@ -40,7 +42,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false)
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@@ -110,7 +112,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = storage.get<string>(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])

View File

@@ -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))

View File

@@ -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))

View File

@@ -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')

View File

@@ -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

View File

@@ -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) }
}, [])

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -5,9 +5,11 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Divider from '../base/divider'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import AppInfo from './app-info'
@@ -53,7 +55,7 @@ const AppDetailNav = ({
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false)
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@@ -64,7 +66,7 @@ const AppDetailNav = ({
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
storage.set(STORAGE_KEYS.APP.DETAIL_COLLAPSE, appSidebarExpand)
setAppSidebarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSidebarExpand])

View File

@@ -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<IGetAutomaticResProps> = ({
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<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
@@ -178,9 +178,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel(localModel)
}
@@ -209,7 +207,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
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<IGetAutomaticResProps> = ({
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 () => {

View File

@@ -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<IGetCodeGeneratorResProps> = (
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<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
@@ -115,7 +115,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
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<IGetCodeGeneratorResProps> = (
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<IGetCodeGeneratorResProps> = (
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel({
...localModel,

View File

@@ -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<DebugWithSingleOrMultipleModelConfigs>(STORAGE_KEYS.CONFIG.DEBUG_MODELS)
const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>({})
if (localeDebugWithSingleOrMultipleModelConfigs) {
try {
debugWithSingleOrMultipleModelConfigs.current = JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {}
}
catch (e) {
console.error(e)
}
}
const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>(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])

View File

@@ -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 {

View File

@@ -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))
})

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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()
})

View File

@@ -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,
{

View File

@@ -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()

View File

@@ -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(<List />)
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
await vi.waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
expect(localStorage.getItem('v1:needRefreshAppList')).toBeNull()
})
})

View File

@@ -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<Props> = ({
]
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
if (storage.get<string>(STORAGE_KEYS.APP.NEED_REFRESH_LIST) === '1') {
storage.remove(STORAGE_KEYS.APP.NEED_REFRESH_LIST)
refetch()
}
}, [refetch])

View File

@@ -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

View File

@@ -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<boolean>(() => {
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<string>(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<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {

View File

@@ -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'

View File

@@ -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<AppConversationData> = {}): 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

View File

@@ -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 () => {

View File

@@ -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<Props> = ({
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}`)

View File

@@ -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<string>(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA)
if (isShowManageMetadata) {
showEditModal()
localStorage.removeItem(isShowManageMetadataLocalStorageKey)
storage.remove(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA)
}
}, [])

View File

@@ -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<Props> = ({
const { formatTime: formatTimestamp } = useTimestamp()
const handleMangeMetadata = () => {
localStorage.setItem(isShowManageMetadataLocalStorageKey, 'true')
storage.set(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA, 'true')
router.push(`/datasets/${dataSetId}/documents`)
}

View File

@@ -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')
}

View File

@@ -2,8 +2,10 @@
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import s from './index.module.css'
type HeaderWrapperProps = {
@@ -18,7 +20,7 @@ const HeaderWrapper = ({
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false)
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()

View File

@@ -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<string>(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)
}

View File

@@ -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 (

View File

@@ -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<boolean>(() => {
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED)
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(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(() => {

View File

@@ -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<void>
}
const STORAGE_KEY = 'workflow_triggers_featured_collapsed'
const FeaturedTriggers = ({
plugins,
providerMap,
@@ -45,14 +45,14 @@ const FeaturedTriggers = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED)
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(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(() => {

View File

@@ -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<SetStateAction<string[]>>
}
const STORAGE_KEY = 'workflow_rag_recommendations_collapsed'
const RAGToolRecommendations = ({
viewType,
onSelect,
@@ -32,14 +32,14 @@ const RAGToolRecommendations = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED)
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(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 {

View File

@@ -5,7 +5,9 @@ import {
useCallback,
} from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { storage } from '@/utils/storage'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
@@ -342,7 +344,7 @@ export const useWorkflowCanvasMaximize = () => {
return
setMaximizeCanvas(!maximizeCanvas)
localStorage.setItem('workflow-canvas-maximize', String(!maximizeCanvas))
storage.set(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, !maximizeCanvas)
eventEmitter?.emit({
type: 'workflow-canvas-maximize',
payload: !maximizeCanvas,

View File

@@ -26,12 +26,12 @@ const Wrap = ({
isExpand,
children,
}: Props) => {
const panelWidth = useStore(state => state.panelWidth)
const nodePanelWidth = useStore(state => state.nodePanelWidth)
const wrapStyle = (() => {
if (isExpand) {
return {
...style,
width: panelWidth - 1,
width: nodePanelWidth - 1,
}
}
return style

View File

@@ -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')

View File

@@ -59,12 +59,14 @@ import {
hasRetryNode,
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useModalContext } from '@/context/modal-context'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
import { canFindTool } from '@/utils'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { useResizePanel } from '../../hooks/use-resize-panel'
import BeforeRunForm from '../before-run-form'
import PanelWrap from '../before-run-form/panel-wrap'
@@ -137,7 +139,7 @@ const BasePanel: FC<BasePanelProps> = ({
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
if (source === 'user')
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
storage.set(STORAGE_KEYS.WORKFLOW.NODE_PANEL_WIDTH, newValue)
setNodePanelWidth(newValue)
}, [maxNodePanelWidth, setNodePanelWidth])

View File

@@ -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<JsonSchemaGeneratorProps> = ({
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<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [open, setOpen] = useState(false)
const [view, setView] = useState(GeneratorView.promptEditor)
const [model, setModel] = useState<Model>(localModel || {
@@ -60,9 +60,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel(localModel)
}
@@ -95,7 +93,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
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<JsonSchemaGeneratorProps> = ({
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()

View File

@@ -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')

View File

@@ -18,7 +18,9 @@ import Tooltip from '@/app/components/base/tooltip'
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useStore } from '@/app/components/workflow/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import {
useWorkflowInteractions,
} from '../../hooks'
@@ -56,7 +58,7 @@ const DebugAndPreview = () => {
const setPanelWidth = useStore(s => s.setPreviewPanelWidth)
const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => {
if (source === 'user')
localStorage.setItem('debug-and-preview-panel-width', `${width}`)
storage.set(STORAGE_KEYS.WORKFLOW.PREVIEW_PANEL_WIDTH, width)
setPanelWidth(width)
}, [setPanelWidth])
const maxPanelWidth = useMemo(() => {

View File

@@ -1,11 +1,12 @@
import type { StateCreator } from 'zustand'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
export type LayoutSliceShape = {
workflowCanvasWidth?: number
workflowCanvasHeight?: number
setWorkflowCanvasWidth: (width: number) => void
setWorkflowCanvasHeight: (height: number) => void
// rightPanelWidth - otherPanelWidth = nodePanelWidth
rightPanelWidth?: number
setRightPanelWidth: (width: number) => void
nodePanelWidth: number
@@ -14,11 +15,11 @@ export type LayoutSliceShape = {
setPreviewPanelWidth: (width: number) => void
otherPanelWidth: number
setOtherPanelWidth: (width: number) => void
bottomPanelWidth: number // min-width = 400px; default-width = auto || 480px;
bottomPanelWidth: number
setBottomPanelWidth: (width: number) => void
bottomPanelHeight: number
setBottomPanelHeight: (height: number) => void
variableInspectPanelHeight: number // min-height = 120px; default-height = 320px;
variableInspectPanelHeight: number
setVariableInspectPanelHeight: (height: number) => void
maximizeCanvas: boolean
setMaximizeCanvas: (maximize: boolean) => void
@@ -31,9 +32,9 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
setWorkflowCanvasHeight: height => set(() => ({ workflowCanvasHeight: height })),
rightPanelWidth: undefined,
setRightPanelWidth: width => set(() => ({ rightPanelWidth: width })),
nodePanelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 400,
nodePanelWidth: storage.getNumber(STORAGE_KEYS.WORKFLOW.NODE_PANEL_WIDTH, 420),
setNodePanelWidth: width => set(() => ({ nodePanelWidth: width })),
previewPanelWidth: localStorage.getItem('debug-and-preview-panel-width') ? Number.parseFloat(localStorage.getItem('debug-and-preview-panel-width')!) : 400,
previewPanelWidth: storage.getNumber(STORAGE_KEYS.WORKFLOW.PREVIEW_PANEL_WIDTH, 400),
setPreviewPanelWidth: width => set(() => ({ previewPanelWidth: width })),
otherPanelWidth: 400,
setOtherPanelWidth: width => set(() => ({ otherPanelWidth: width })),
@@ -41,8 +42,8 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
setBottomPanelWidth: width => set(() => ({ bottomPanelWidth: width })),
bottomPanelHeight: 324,
setBottomPanelHeight: height => set(() => ({ bottomPanelHeight: height })),
variableInspectPanelHeight: localStorage.getItem('workflow-variable-inpsect-panel-height') ? Number.parseFloat(localStorage.getItem('workflow-variable-inpsect-panel-height')!) : 320,
variableInspectPanelHeight: storage.getNumber(STORAGE_KEYS.WORKFLOW.VARIABLE_INSPECT_PANEL_HEIGHT, 320),
setVariableInspectPanelHeight: height => set(() => ({ variableInspectPanelHeight: height })),
maximizeCanvas: localStorage.getItem('workflow-canvas-maximize') === 'true',
maximizeCanvas: storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false),
setMaximizeCanvas: maximize => set(() => ({ maximizeCanvas: maximize })),
})

View File

@@ -1,7 +1,6 @@
import type { StateCreator } from 'zustand'
export type PanelSliceShape = {
panelWidth: number
showFeaturesPanel: boolean
setShowFeaturesPanel: (showFeaturesPanel: boolean) => void
showWorkflowVersionHistoryPanel: boolean
@@ -27,7 +26,6 @@ export type PanelSliceShape = {
}
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
panelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420,
showFeaturesPanel: false,
setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })),
showWorkflowVersionHistoryPanel: false,

View File

@@ -5,6 +5,8 @@ import type {
WorkflowRunningData,
} from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
@@ -63,10 +65,10 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
setSelection: selection => set(() => ({ selection })),
bundleNodeSize: null,
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand',
controlMode: storage.get<'pointer' | 'hand'>(STORAGE_KEYS.WORKFLOW.OPERATION_MODE) === 'pointer' ? 'pointer' : 'hand',
setControlMode: (controlMode) => {
set(() => ({ controlMode }))
localStorage.setItem('workflow-operation-mode', controlMode)
storage.set(STORAGE_KEYS.WORKFLOW.OPERATION_MODE, controlMode)
},
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
setMousePosition: mousePosition => set(() => ({ mousePosition })),

View File

@@ -4,7 +4,9 @@ import {
useCallback,
useMemo,
} from 'react'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
import { useStore } from '../store'
import Panel from './panel'
@@ -21,8 +23,8 @@ const VariableInspectPanel: FC = () => {
return workflowCanvasHeight - 60
}, [workflowCanvasHeight])
const handleResize = useCallback((width: number, height: number) => {
localStorage.setItem('workflow-variable-inpsect-panel-height', `${height}`)
const handleResize = useCallback((_width: number, height: number) => {
storage.set(STORAGE_KEYS.WORKFLOW.VARIABLE_INSPECT_PANEL_HEIGHT, height)
setVariableInspectPanelHeight(height)
}, [setVariableInspectPanelHeight])

View File

@@ -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('/')
}

View File

@@ -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<string>(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()

View File

@@ -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')

View File

@@ -158,7 +158,7 @@ describe('InstallForm', () => {
render(<InstallForm />)
await waitFor(() => {
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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))

View File

@@ -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<string>(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)
}

View File

@@ -1,5 +1,6 @@
import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { InputVarType } from '@/app/components/workflow/types'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { PromptRole } from '@/models/debug'
import { PipelineInputVarType } from '@/models/pipeline'
import { AgentStrategy } from '@/types/app'
@@ -179,7 +180,7 @@ export const CSRF_COOKIE_NAME = () => {
return isSecure ? '__Host-csrf_token' : 'csrf_token'
}
export const CSRF_HEADER_NAME = 'X-CSRF-Token'
export const ACCESS_TOKEN_LOCAL_STORAGE_NAME = 'access_token'
export const ACCESS_TOKEN_LOCAL_STORAGE_NAME = STORAGE_KEYS.AUTH.ACCESS_TOKEN
export const PASSPORT_LOCAL_STORAGE_NAME = (appCode: string) => `passport-${appCode}`
export const PASSPORT_HEADER_NAME = 'X-App-Passport'
@@ -229,7 +230,7 @@ export const VAR_ITEM_TEMPLATE_IN_PIPELINE = {
export const appDefaultIconBackground = '#D5F5F6'
export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList'
export const NEED_REFRESH_APP_LIST_KEY = STORAGE_KEYS.APP.NEED_REFRESH_LIST
export const DATASET_DEFAULT = {
top_k: 4,

View File

@@ -0,0 +1,77 @@
export const STORAGE_KEYS = {
WORKFLOW: {
NODE_PANEL_WIDTH: 'workflow-node-panel-width',
PREVIEW_PANEL_WIDTH: 'debug-and-preview-panel-width',
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',
NEED_REFRESH_LIST: 'needRefreshAppList',
DETAIL_COLLAPSE: 'app-detail-collapse-or-expand',
},
CONVERSATION: {
ID_INFO: 'conversationIdInfo',
},
AUTH: {
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',
REVERIFY_PREV_EXPIRE_AT: 'education-reverify-prev-expire-at',
REVERIFY_HAS_NOTICED: 'education-reverify-has-noticed',
EXPIRED_HAS_NOTICED: 'education-expired-has-noticed',
},
CONFIG: {
AUTO_GEN_MODEL: 'auto-gen-model',
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' },
]

View File

@@ -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<string>(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 {

View File

@@ -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')
})

View File

@@ -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<string>(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

View File

@@ -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<string>(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')
},
})
}

View File

@@ -56,6 +56,9 @@
"no-console": {
"count": 16
},
"no-restricted-properties": {
"count": 5
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@@ -505,6 +508,9 @@
}
},
"app/components/app/create-app-modal/index.spec.tsx": {
"no-restricted-properties": {
"count": 1
},
"ts/no-explicit-any": {
"count": 7
}
@@ -579,6 +585,11 @@
"count": 1
}
},
"app/components/app/switch-app-modal/index.spec.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/app/switch-app-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -629,6 +640,9 @@
}
},
"app/components/apps/list.spec.tsx": {
"no-restricted-globals": {
"count": 3
},
"ts/no-explicit-any": {
"count": 5
}
@@ -758,6 +772,11 @@
"count": 2
}
},
"app/components/base/chat/chat-with-history/hooks.spec.tsx": {
"no-restricted-globals": {
"count": 4
}
},
"app/components/base/chat/chat-with-history/hooks.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
@@ -853,6 +872,11 @@
"count": 7
}
},
"app/components/base/chat/embedded-chatbot/hooks.spec.tsx": {
"no-restricted-globals": {
"count": 3
}
},
"app/components/base/chat/embedded-chatbot/hooks.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
@@ -1526,6 +1550,11 @@
"count": 4
}
},
"app/components/billing/plan/index.spec.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/billing/plan/index.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -1551,6 +1580,11 @@
"count": 3
}
},
"app/components/browser-initializer.tsx": {
"no-restricted-properties": {
"count": 2
}
},
"app/components/custom/custom-web-app-brand/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7
@@ -3093,6 +3127,11 @@
"count": 3
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx": {
"no-restricted-globals": {
"count": 18
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
@@ -3827,6 +3866,11 @@
"count": 7
}
},
"app/components/workflow/panel/debug-and-preview/index.spec.tsx": {
"no-restricted-globals": {
"count": 15
}
},
"app/components/workflow/panel/env-panel/variable-modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
@@ -4097,6 +4141,9 @@
}
},
"app/install/installForm.spec.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 7
}
@@ -4137,6 +4184,12 @@
}
},
"context/modal-context.test.tsx": {
"no-restricted-globals": {
"count": 4
},
"no-restricted-properties": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@@ -4505,6 +4558,11 @@
"count": 4
}
},
"utils/setup-status.spec.ts": {
"no-restricted-globals": {
"count": 11
}
},
"utils/tool-call.spec.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@@ -44,6 +44,40 @@ export default antfu(
{
rules: {
'node/prefer-global/process': 'off',
'no-restricted-globals': [
'error',
{
name: 'localStorage',
message: 'Use @/utils/storage instead. Direct localStorage access causes SSR issues.',
},
{
name: 'sessionStorage',
message: 'Use @/utils/storage instead. Direct sessionStorage access causes SSR issues.',
},
],
'no-restricted-properties': [
'error',
{
object: 'window',
property: 'localStorage',
message: 'Use @/utils/storage instead.',
},
{
object: 'window',
property: 'sessionStorage',
message: 'Use @/utils/storage instead.',
},
{
object: 'globalThis',
property: 'localStorage',
message: 'Use @/utils/storage instead.',
},
{
object: 'globalThis',
property: 'sessionStorage',
message: 'Use @/utils/storage instead.',
},
],
},
},
{

View File

@@ -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) {

View File

@@ -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<void>((resolve) => {
function _check() {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
const isRefreshingSign = storage.get<string>(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<string>(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME) || '0'
return nowTime - Number.parseInt(lastTime) <= delta
}
// only one request can send
async function getNewAccessToken(timeout: number): Promise<void> {
try {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
const isRefreshingSign = storage.get<string>(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<void> {
}
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)
}

View File

@@ -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<string>(STORAGE_KEYS.AUTH.ACCESS_TOKEN) || ''
}
export function getWebAppPassport(shareCode: string) {
return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || ''
return storage.get<string>(`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)

View File

@@ -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()
})
})
})

View File

@@ -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<string>(STORAGE_KEYS.CONFIG.SETUP_STATUS) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached())
@@ -13,9 +13,9 @@ export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse>
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
}

190
web/utils/storage.ts Normal file
View File

@@ -0,0 +1,190 @@
/* eslint-disable no-restricted-globals */
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 {
if (_isAvailable !== null)
return _isAvailable
if (!isClient) {
_isAvailable = false
return false
}
try {
const testKey = '__storage_test__'
localStorage.setItem(testKey, 'test')
localStorage.removeItem(testKey)
_isAvailable = true
return true
}
catch {
_isAvailable = false
return false
}
}
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<T extends JsonValue>(key: string, defaultValue?: T): T | null {
if (!isLocalStorageAvailable())
return defaultValue ?? null
try {
const item = localStorage.getItem(versionedKey(key))
if (item === null)
return defaultValue ?? null
try {
return JSON.parse(item) as T
}
catch {
return item as T
}
}
catch {
return defaultValue ?? null
}
}
function set<T extends JsonValue>(key: string, value: T): void {
if (!isLocalStorageAvailable())
return
try {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
localStorage.setItem(versionedKey(key), stringValue)
}
catch {
// Silent fail - localStorage may be full or disabled
}
}
function remove(key: string): void {
if (!isLocalStorageAvailable())
return
try {
localStorage.removeItem(versionedKey(key))
}
catch {
// Silent fail
}
}
function getNumber(key: string): number | null
function getNumber(key: string, defaultValue: number): number
function getNumber(key: string, defaultValue?: number): number | null {
const value = get<string | number>(key)
if (value === null)
return defaultValue ?? null
const parsed = typeof value === 'number' ? value : Number.parseFloat(value as string)
return Number.isNaN(parsed) ? (defaultValue ?? null) : parsed
}
function getBoolean(key: string): boolean | null
function getBoolean(key: string, defaultValue: boolean): boolean
function getBoolean(key: string, defaultValue?: boolean): boolean | null {
const value = get<string | boolean>(key)
if (value === null)
return defaultValue ?? null
if (typeof value === 'boolean')
return value
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,
remove,
getNumber,
getBoolean,
isAvailable: isLocalStorageAvailable,
migrate,
runMigrations,
resetCache,
}

View File

@@ -134,7 +134,7 @@ const createMockLocalStorage = () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
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()
})