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 { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store' 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 { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps' import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import s from './style.module.css' import s from './style.module.css'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
@@ -108,7 +110,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
useEffect(() => { useEffect(() => {
if (appDetail) { 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' const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode) setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode // 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 { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { isTriggerNode } from '@/app/components/workflow/types' import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { STORAGE_KEYS } from '@/config/storage-keys'
import { import {
fetchAppDetail, fetchAppDetail,
updateAppSiteAccessToken, updateAppSiteAccessToken,
@@ -25,6 +25,7 @@ import {
import { useAppWorkflow } from '@/service/use-workflow' import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils' import { asyncRunSafe } from '@/utils'
import { storage } from '@/utils/storage'
export type ICardViewProps = { export type ICardViewProps = {
appId: string appId: string
@@ -126,7 +127,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
}) as Promise<App>, }) as Promise<App>,
) )
if (!err) if (!err)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
handleCallbackResult(err) 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 { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import ExtraInfo from '@/app/components/datasets/extra-info' import ExtraInfo from '@/app/components/datasets/extra-info'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail' import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter' 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 useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
export type IAppDetailLayoutProps = { export type IAppDetailLayoutProps = {
children: React.ReactNode children: React.ReactNode
@@ -40,7 +42,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname() const pathname = usePathname()
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline') const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
const isPipelineCanvas = pathname.endsWith('/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 [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext() const { eventEmitter } = useEventEmitterContextContext()
@@ -110,7 +112,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand) const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => { 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' const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode) setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand]) }, [isMobile, setAppSidebarExpand])

View File

@@ -8,12 +8,14 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' 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 { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common' import { sendResetPasswordCode } from '@/service/common'
import { storage } from '@/utils/storage'
export default function CheckCode() { export default function CheckCode() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -41,7 +43,7 @@ export default function CheckCode() {
setIsLoading(true) setIsLoading(true)
const res = await sendResetPasswordCode(email, locale) const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') { 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) const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data)) params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email)) params.set('email', encodeURIComponent(email))

View File

@@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' 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 { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { sendWebAppEMailLoginCode } from '@/service/common' import { sendWebAppEMailLoginCode } from '@/service/common'
import { storage } from '@/utils/storage'
export default function MailAndCodeAuth() { export default function MailAndCodeAuth() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -36,7 +38,7 @@ export default function MailAndCodeAuth() {
setIsLoading(true) setIsLoading(true)
const ret = await sendWebAppEMailLoginCode(email, locale) const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') { 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) const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email)) params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data)) 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 Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { import {
checkEmailExisted, checkEmailExisted,
resetEmail, resetEmail,
@@ -18,6 +19,7 @@ import {
} from '@/service/common' } from '@/service/common'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { asyncRunSafe } from '@/utils' import { asyncRunSafe } from '@/utils'
import { storage } from '@/utils/storage'
type Props = { type Props = {
show: boolean show: boolean
@@ -172,7 +174,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
localStorage.removeItem('setup_status') storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend
router.push('/signin') 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 Avatar from '@/app/components/base/avatar'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { storage } from '@/utils/storage'
export type IAppSelector = { export type IAppSelector = {
isMobile: boolean isMobile: boolean
@@ -28,7 +30,7 @@ export default function AppSelector() {
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
localStorage.removeItem('setup_status') storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
resetUser() resetUser()
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend

View File

@@ -2,7 +2,9 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CustomDialog from '@/app/components/base/dialog' 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 CheckEmail from './components/check-email'
import FeedBack from './components/feed-back' import FeedBack from './components/feed-back'
import VerifyEmail from './components/verify-email' import VerifyEmail from './components/verify-email'
@@ -21,7 +23,7 @@ export default function DeleteAccount(props: DeleteAccountProps) {
const handleEmailCheckSuccess = useCallback(async () => { const handleEmailCheckSuccess = useCallback(async () => {
try { try {
setShowVerifyEmail(true) 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) } 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 Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' 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 { useAppContext } from '@/context/app-context'
import { useIsLogin } from '@/service/use-common' import { useIsLogin } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
import { storage } from '@/utils/storage'
import { import {
OAUTH_AUTHORIZE_PENDING_KEY,
OAUTH_AUTHORIZE_PENDING_TTL, OAUTH_AUTHORIZE_PENDING_TTL,
REDIRECT_URL_KEY, REDIRECT_URL_KEY,
} from './constants' } from './constants'
@@ -31,7 +32,7 @@ function setItemWithExpiry(key: string, value: string, ttl: number) {
value, value,
expiry: dayjs().add(ttl, 'seconds').unix(), expiry: dayjs().add(ttl, 'seconds').unix(),
} }
localStorage.setItem(key, JSON.stringify(item)) storage.set(key, JSON.stringify(item))
} }
function buildReturnUrl(pathname: string, search: string) { function buildReturnUrl(pathname: string, search: string) {
@@ -86,7 +87,7 @@ export default function OAuthAuthorize() {
const onLoginSwitchClick = () => { const onLoginSwitchClick = () => {
try { try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) 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)}`) router.push(`/signin?${REDIRECT_URL_KEY}=${encodeURIComponent(returnUrl)}`)
} }
catch { catch {

View File

@@ -7,13 +7,16 @@ import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants' } from '@/app/education-apply/constants'
import { LEGACY_KEY_MIGRATIONS, STORAGE_KEYS } from '@/config/storage-keys'
import { sendGAEvent } from '@/utils/gtag' import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status' import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { storage } from '@/utils/storage'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude' import { trackEvent } from './base/amplitude'
storage.runMigrations(LEGACY_KEY_MIGRATIONS)
type AppInitializerProps = { type AppInitializerProps = {
children: ReactNode children: ReactNode
} }
@@ -75,7 +78,7 @@ export const AppInitializer = ({
} }
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') storage.set(STORAGE_KEYS.EDUCATION.VERIFYING, 'yes')
try { try {
const isFinished = await isSetupFinished() 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 Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog' import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast' 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 { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
@@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppIcon from '../base/app-icon' import AppIcon from '../base/app-icon'
import AppOperations from './app-operations' import AppOperations from './app-operations'
@@ -128,7 +129,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
type: 'success', type: 'success',
message: t('newApp.appCreated', { ns: 'app' }), message: t('newApp.appCreated', { ns: 'app' }),
}) })
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
onPlanInfoChanged() onPlanInfoChanged()
getRedirection(true, newApp, replace) getRedirection(true, newApp, replace)
} }

View File

@@ -5,9 +5,11 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Divider from '../base/divider' import Divider from '../base/divider'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils' import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import AppInfo from './app-info' import AppInfo from './app-info'
@@ -53,7 +55,7 @@ const AppDetailNav = ({
const pathname = usePathname() const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow') const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/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 [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext() const { eventEmitter } = useEventEmitterContextContext()
@@ -64,7 +66,7 @@ const AppDetailNav = ({
useEffect(() => { useEffect(() => {
if (appSidebarExpand) { if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand) storage.set(STORAGE_KEYS.APP.DETAIL_COLLAPSE, appSidebarExpand)
setAppSidebarExpand(appSidebarExpand) setAppSidebarExpand(appSidebarExpand)
} }
}, [appSidebarExpand, setAppSidebarExpand]) }, [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 { 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 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 { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps' import { useGenerateRuleTemplate } from '@/service/use-apps'
import { storage } from '@/utils/storage'
import IdeaOutput from './idea-output' import IdeaOutput from './idea-output'
import InstructionEditorInBasic from './instruction-editor' import InstructionEditorInBasic from './instruction-editor'
import InstructionEditorInWorkflow from './instruction-editor-in-workflow' import InstructionEditorInWorkflow from './instruction-editor-in-workflow'
@@ -83,9 +85,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
onFinished, onFinished,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const localModel = localStorage.getItem('auto-gen-model') const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const [model, setModel] = React.useState<Model>(localModel || { const [model, setModel] = React.useState<Model>(localModel || {
name: '', name: '',
provider: '', provider: '',
@@ -178,9 +178,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
useEffect(() => { useEffect(() => {
if (defaultModel) { if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model') const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
if (localModel) { if (localModel) {
setModel(localModel) setModel(localModel)
} }
@@ -209,7 +207,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
mode: newValue.mode as ModelModeType, mode: newValue.mode as ModelModeType,
} }
setModel(newModel) setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel]) }, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => { const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -218,7 +216,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
completion_params: newParams as CompletionParams, completion_params: newParams as CompletionParams,
} }
setModel(newModel) setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel]) }, [model, setModel])
const onGenerate = async () => { 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 { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' 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 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 { generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps' import { useGenerateRuleTemplate } from '@/service/use-apps'
import { storage } from '@/utils/storage'
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index' import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'
import IdeaOutput from '../automatic/idea-output' import IdeaOutput from '../automatic/idea-output'
import InstructionEditor from '../automatic/instruction-editor-in-workflow' import InstructionEditor from '../automatic/instruction-editor-in-workflow'
@@ -62,9 +64,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
presence_penalty: 0, presence_penalty: 0,
frequency_penalty: 0, frequency_penalty: 0,
} }
const localModel = localStorage.getItem('auto-gen-model') const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const [model, setModel] = React.useState<Model>(localModel || { const [model, setModel] = React.useState<Model>(localModel || {
name: '', name: '',
provider: '', provider: '',
@@ -115,7 +115,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
mode: newValue.mode as ModelModeType, mode: newValue.mode as ModelModeType,
} }
setModel(newModel) setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel]) }, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => { const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -124,7 +124,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
completion_params: newParams as CompletionParams, completion_params: newParams as CompletionParams,
} }
setModel(newModel) setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel]) }, [model, setModel])
const onGenerate = async () => { const onGenerate = async () => {
@@ -168,9 +168,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
useEffect(() => { useEffect(() => {
if (defaultModel) { if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model') const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
if (localModel) { if (localModel) {
setModel({ setModel({
...localModel, ...localModel,

View File

@@ -14,27 +14,20 @@ import {
} from 'react' } from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' 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 { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { import {
AgentStrategy, AgentStrategy,
} from '@/types/app' } from '@/types/app'
import { promptVariablesToUserInputsForm } from '@/utils/model-config' import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { storage } from '@/utils/storage'
import { ORCHESTRATE_CHANGED } from './types' import { ORCHESTRATE_CHANGED } from './types'
export const useDebugWithSingleOrMultipleModel = (appId: string) => { 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>({}) const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>(localeDebugWithSingleOrMultipleModelConfigs || {})
if (localeDebugWithSingleOrMultipleModelConfigs) {
try {
debugWithSingleOrMultipleModelConfigs.current = JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {}
}
catch (e) {
console.error(e)
}
}
const [ const [
debugWithMultipleModel, debugWithMultipleModel,
@@ -55,7 +48,7 @@ export const useDebugWithSingleOrMultipleModel = (appId: string) => {
configs: modelConfigs, configs: modelConfigs,
} }
debugWithSingleOrMultipleModelConfigs.current[appId] = value 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) setDebugWithMultipleModel(value.multiple)
setMultipleModelConfigs(value.configs) setMultipleModelConfigs(value.configs)
}, [appId]) }, [appId])

View File

@@ -16,7 +16,7 @@ import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import CreateAppModal from '@/app/components/explore/create-app-modal' import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' 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 { useAppContext } from '@/context/app-context'
import { DSLImportMode } from '@/models/app' import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps' import { importDSL } from '@/service/apps'
@@ -25,6 +25,7 @@ import { useExploreAppList } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppCard from '../app-card' import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar' import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
@@ -145,7 +146,7 @@ const Apps = ({
onSuccess() onSuccess()
if (app.app_id) if (app.app_id)
await handleCheckPluginDependencies(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) getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
} }
catch { catch {

View File

@@ -4,7 +4,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { createApp } from '@/service/apps' import { createApp } from '@/service/apps'
@@ -12,6 +11,8 @@ import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index' import CreateAppModal from './index'
const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList'
vi.mock('ahooks', () => ({ vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: any[]) => any) => { useDebounceFn: (fn: (...args: any[]) => any) => {
const run = (...args: any[]) => fn(...args) const run = (...args: any[]) => fn(...args)
@@ -142,7 +143,7 @@ describe('CreateAppModal', () => {
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(onSuccess).toHaveBeenCalled() expect(onSuccess).toHaveBeenCalled()
expect(onClose).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)) 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 Textarea from '@/app/components/base/textarea'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog' 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 { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
@@ -27,6 +27,7 @@ import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker' 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' }) }) notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
onSuccess() onSuccess()
onClose() onClose()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, app, push) getRedirection(isCurrentWorkspaceEditor, app, push)
} }
catch (e: any) { catch (e: any) {

View File

@@ -15,7 +15,7 @@ import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog' import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' 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 { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { import {
@@ -28,6 +28,7 @@ import {
} from '@/service/apps' } from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Uploader from './uploader' import Uploader from './uploader'
type CreateFromDSLModalProps = { type CreateFromDSLModalProps = {
@@ -130,7 +131,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }), message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { 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) if (app_id)
await handleCheckPluginDependencies(app_id) await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push) getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
@@ -190,7 +191,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
}) })
if (app_id) if (app_id)
await handleCheckPluginDependencies(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) getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
} }
else if (status === DSLImportStatus.FAILED) { 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 { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import SwitchAppModal from './index' import SwitchAppModal from './index'
const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList'
const mockPush = vi.fn() const mockPush = vi.fn()
const mockReplace = vi.fn() const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
@@ -257,7 +258,7 @@ describe('SwitchAppModal', () => {
expect(onSuccess).toHaveBeenCalledTimes(1) expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1) expect(onClose).toHaveBeenCalledTimes(1)
expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) 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(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow')
expect(mockReplace).not.toHaveBeenCalled() 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 Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog' 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 { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { deleteApp, switchApp } from '@/service/apps' import { deleteApp, switchApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppIconPicker from '../../base/app-icon-picker' import AppIconPicker from '../../base/app-icon-picker'
type SwitchAppModalProps = { type SwitchAppModalProps = {
@@ -73,7 +74,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
setAppDetail() setAppDetail()
if (removeOriginal) if (removeOriginal)
await deleteApp(appDetail.id) await deleteApp(appDetail.id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection( getRedirection(
isCurrentWorkspaceEditor, isCurrentWorkspaceEditor,
{ {

View File

@@ -20,7 +20,7 @@ import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector' import TagSelector from '@/app/components/base/tag-management/selector'
import Toast, { ToastContext } from '@/app/components/base/toast' import Toast, { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip' 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 { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
@@ -33,6 +33,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { formatTime } from '@/utils/time' import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
@@ -144,7 +145,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
type: 'success', type: 'success',
message: t('newApp.appCreated', { ns: 'app' }), 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) if (onRefresh)
onRefresh() onRefresh()
onPlanInfoChanged() onPlanInfoChanged()

View File

@@ -434,13 +434,15 @@ describe('List', () => {
}) })
describe('Local Storage Refresh', () => { describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => { it('should call refetch when refresh key is set in localStorage', async () => {
localStorage.setItem('needRefreshAppList', '1') localStorage.setItem('v1:needRefreshAppList', '1')
render(<List />) render(<List />)
expect(mockRefetch).toHaveBeenCalled() await vi.waitFor(() => {
expect(localStorage.getItem('needRefreshAppList')).toBeNull() 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 TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' 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 { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay' import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps' import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppCard from './app-card' import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton' import { AppCardSkeleton } from './app-card-skeleton'
import Empty from './empty' import Empty from './empty'
@@ -134,8 +135,8 @@ const List: FC<Props> = ({
] ]
useEffect(() => { useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { if (storage.get<string>(STORAGE_KEYS.APP.NEED_REFRESH_LIST) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) storage.remove(STORAGE_KEYS.APP.NEED_REFRESH_LIST)
refetch() refetch()
} }
}, [refetch]) }, [refetch])

View File

@@ -11,9 +11,10 @@ import {
generationConversationName, generationConversationName,
} from '@/service/share' } from '@/service/share'
import { shareQueryKeys } from '@/service/use-share' import { shareQueryKeys } from '@/service/use-share'
import { CONVERSATION_ID_INFO } from '../constants'
import { useChatWithHistory } from './hooks' import { useChatWithHistory } from './hooks'
const CONVERSATION_ID_INFO_KEY = 'v1:conversationIdInfo'
vi.mock('@/hooks/use-app-favicon', () => ({ vi.mock('@/hooks/use-app-favicon', () => ({
useAppFavicon: vi.fn(), useAppFavicon: vi.fn(),
})) }))
@@ -120,14 +121,14 @@ const setConversationIdInfo = (appId: string, conversationId: string) => {
'DEFAULT': conversationId, '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. // Scenario: useChatWithHistory integrates share queries for conversations and chat list.
describe('useChatWithHistory', () => { describe('useChatWithHistory', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
localStorage.removeItem(CONVERSATION_ID_INFO) localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
mockStoreState.appInfo = { mockStoreState.appInfo = {
app_id: 'app-1', app_id: 'app-1',
custom_config: null, custom_config: null,
@@ -144,7 +145,7 @@ describe('useChatWithHistory', () => {
}) })
afterEach(() => { afterEach(() => {
localStorage.removeItem(CONVERSATION_ID_INFO) localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
}) })
// Scenario: share query results populate conversation lists and trigger chat list fetch. // Scenario: share query results populate conversation lists and trigger chat list fetch.
@@ -268,7 +269,7 @@ describe('useChatWithHistory', () => {
// Assert // Assert
await waitFor(() => { await waitFor(() => {
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) const storedValue = localStorage.getItem(CONVERSATION_ID_INFO_KEY)
const parsed = storedValue ? JSON.parse(storedValue) : {} const parsed = storedValue ? JSON.parse(storedValue) : {}
const storedUserId = parsed['app-1']?.['user-1'] const storedUserId = parsed['app-1']?.['user-1']
const storedDefaultId = parsed['app-1']?.DEFAULT 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 { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon' import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client' import { changeLanguage } from '@/i18n-config/client'
@@ -41,6 +42,7 @@ import {
useShareConversations, useShareConversations,
} from '@/service/use-share' } from '@/service/use-share'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import { storage } from '@/utils/storage'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { CONVERSATION_ID_INFO } from '../constants' import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils' import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
@@ -128,27 +130,15 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => { const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { const localState = storage.get<string>(STORAGE_KEYS.APP.SIDEBAR_COLLAPSE)
const localState = localStorage.getItem('webappSidebarCollapse') return localState === 'collapsed'
return localState === 'collapsed'
}
catch {
// localStorage may be disabled in private browsing mode or by security settings
// fallback to default value
return false
}
} }
return false return false
}) })
const handleSidebarCollapse = useCallback((state: boolean) => { const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) { if (appId) {
setSidebarCollapseState(state) setSidebarCollapseState(state)
try { storage.set(STORAGE_KEYS.APP.SIDEBAR_COLLAPSE, state ? 'collapsed' : 'expanded')
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
catch {
// localStorage may be disabled, continue without persisting state
}
} }
}, [appId, setSidebarCollapseState]) }, [appId, setSidebarCollapseState])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, { 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' export const UUID_NIL = '00000000-0000-0000-0000-000000000000'

View File

@@ -11,9 +11,10 @@ import {
generationConversationName, generationConversationName,
} from '@/service/share' } from '@/service/share'
import { shareQueryKeys } from '@/service/use-share' import { shareQueryKeys } from '@/service/use-share'
import { CONVERSATION_ID_INFO } from '../constants'
import { useEmbeddedChatbot } from './hooks' import { useEmbeddedChatbot } from './hooks'
const CONVERSATION_ID_INFO_KEY = 'v1:conversationIdInfo'
vi.mock('@/i18n-config/client', () => ({ vi.mock('@/i18n-config/client', () => ({
changeLanguage: vi.fn().mockResolvedValue(undefined), changeLanguage: vi.fn().mockResolvedValue(undefined),
})) }))
@@ -113,7 +114,7 @@ const createConversationData = (overrides: Partial<AppConversationData> = {}): A
describe('useEmbeddedChatbot', () => { describe('useEmbeddedChatbot', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
localStorage.removeItem(CONVERSATION_ID_INFO) localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
mockStoreState.appInfo = { mockStoreState.appInfo = {
app_id: 'app-1', app_id: 'app-1',
custom_config: null, custom_config: null,
@@ -131,7 +132,7 @@ describe('useEmbeddedChatbot', () => {
}) })
afterEach(() => { afterEach(() => {
localStorage.removeItem(CONVERSATION_ID_INFO) localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
}) })
// Scenario: share query results populate conversation lists and trigger chat list fetch. // Scenario: share query results populate conversation lists and trigger chat list fetch.
@@ -251,7 +252,7 @@ describe('useEmbeddedChatbot', () => {
// Assert // Assert
await waitFor(() => { await waitFor(() => {
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) const storedValue = localStorage.getItem(CONVERSATION_ID_INFO_KEY)
const parsed = storedValue ? JSON.parse(storedValue) : {} const parsed = storedValue ? JSON.parse(storedValue) : {}
const storedUserId = parsed['app-1']?.['embedded-user-1'] const storedUserId = parsed['app-1']?.['embedded-user-1']
const storedDefaultId = parsed['app-1']?.DEFAULT const storedDefaultId = parsed['app-1']?.DEFAULT

View File

@@ -105,7 +105,7 @@ describe('PlanComp', () => {
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled()) await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token')) 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 () => { 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 Button from '@/app/components/base/button'
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow' import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info' 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 VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context' import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useEducationVerify } from '@/service/use-education' import { useEducationVerify } from '@/service/use-education'
import { storage } from '@/utils/storage'
import { getDaysUntilEndOfMonth } from '@/utils/time' import { getDaysUntilEndOfMonth } from '@/utils/time'
import { Loading } from '../../base/icons/src/public/thought' import { Loading } from '../../base/icons/src/public/thought'
import { NUM_INFINITE } from '../config' import { NUM_INFINITE } from '../config'
@@ -72,7 +73,7 @@ const PlanComp: FC<Props> = ({
if (isPending) if (isPending)
return return
mutateAsync().then((res) => { mutateAsync().then((res) => {
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING)
if (unmountedRef.current) if (unmountedRef.current)
return return
router.push(`/education-apply?token=${res.token}`) router.push(`/education-apply?token=${res.token}`)

View File

@@ -4,8 +4,9 @@ import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast' 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 { 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' import useCheckMetadataName from './use-check-metadata-name'
const useEditDatasetMetadata = ({ const useEditDatasetMetadata = ({
@@ -24,10 +25,10 @@ const useEditDatasetMetadata = ({
}] = useBoolean(false) }] = useBoolean(false)
useEffect(() => { useEffect(() => {
const isShowManageMetadata = localStorage.getItem(isShowManageMetadataLocalStorageKey) const isShowManageMetadata = storage.get<string>(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA)
if (isShowManageMetadata) { if (isShowManageMetadata) {
showEditModal() 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 { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { STORAGE_KEYS } from '@/config/storage-keys'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AddMetadataButton from '../add-metadata-button' import AddMetadataButton from '../add-metadata-button'
import InputCombined from '../edit-metadata-batch/input-combined' import InputCombined from '../edit-metadata-batch/input-combined'
import SelectMetadataModal from '../metadata-dataset/select-metadata-modal' import SelectMetadataModal from '../metadata-dataset/select-metadata-modal'
import { DataType, isShowManageMetadataLocalStorageKey } from '../types' import { DataType } from '../types'
import Field from './field' import Field from './field'
type Props = { type Props = {
@@ -53,7 +55,7 @@ const InfoGroup: FC<Props> = ({
const { formatTime: formatTimestamp } = useTimestamp() const { formatTime: formatTimestamp } = useTimestamp()
const handleMangeMetadata = () => { const handleMangeMetadata = () => {
localStorage.setItem(isShowManageMetadataLocalStorageKey, 'true') storage.set(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA, 'true')
router.push(`/datasets/${dataSetId}/documents`) 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 ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
@@ -30,6 +31,7 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AccountAbout from '../account-about' import AccountAbout from '../account-about'
import GithubStar from '../github-star' import GithubStar from '../github-star'
import Indicator from '../indicator' import Indicator from '../indicator'
@@ -55,13 +57,13 @@ export default function AppSelector() {
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
resetUser() resetUser()
localStorage.removeItem('setup_status') storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend
// To avoid use other account's education notice info // To avoid use other account's education notice info
localStorage.removeItem('education-reverify-prev-expire-at') storage.remove(STORAGE_KEYS.EDUCATION.REVERIFY_PREV_EXPIRE_AT)
localStorage.removeItem('education-reverify-has-noticed') storage.remove(STORAGE_KEYS.EDUCATION.REVERIFY_HAS_NOTICED)
localStorage.removeItem('education-expired-has-noticed') storage.remove(STORAGE_KEYS.EDUCATION.EXPIRED_HAS_NOTICED)
router.push('/signin') router.push('/signin')
} }

View File

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

View File

@@ -1,18 +1,20 @@
import { useState } from 'react' import { useState } from 'react'
import { X } from '@/app/components/base/icons/src/vender/line/general' import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' 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 { NOTICE_I18N } from '@/i18n-config/language'
import { storage } from '@/utils/storage'
const MaintenanceNotice = () => { const MaintenanceNotice = () => {
const locale = useLanguage() 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 = () => { const handleJumpNotice = () => {
window.open(NOTICE_I18N.href, '_blank') window.open(NOTICE_I18N.href, '_blank')
} }
const handleCloseNotice = () => { const handleCloseNotice = () => {
localStorage.setItem('hide-maintenance-notice', '1') storage.set(STORAGE_KEYS.UI.HIDE_MAINTENANCE_NOTICE, '1')
setShowNotice(false) setShowNotice(false)
} }

View File

@@ -2,6 +2,8 @@
import { useCountDown } from 'ahooks' import { useCountDown } from 'ahooks'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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_TIME_MS = 59000
export const COUNT_DOWN_KEY = 'leftTime' export const COUNT_DOWN_KEY = 'leftTime'
@@ -12,23 +14,23 @@ type CountdownProps = {
export default function Countdown({ onResend }: CountdownProps) { export default function Countdown({ onResend }: CountdownProps) {
const { t } = useTranslation() 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({ const [time] = useCountDown({
leftTime, leftTime,
onEnd: () => { onEnd: () => {
setLeftTime(0) setLeftTime(0)
localStorage.removeItem(COUNT_DOWN_KEY) storage.remove(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME)
}, },
}) })
const resend = async function () { const resend = async function () {
setLeftTime(COUNT_DOWN_TIME_MS) 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?.() onResend?.()
} }
useEffect(() => { useEffect(() => {
localStorage.setItem(COUNT_DOWN_KEY, `${time}`) storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, time)
}, [time]) }, [time])
return ( return (

View File

@@ -12,9 +12,11 @@ import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' 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 { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client' import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { storage } from '@/utils/storage'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
import { BlockEnum } from '../types' import { BlockEnum } from '../types'
@@ -34,8 +36,6 @@ type FeaturedToolsProps = {
onInstallSuccess?: () => void onInstallSuccess?: () => void
} }
const STORAGE_KEY = 'workflow_tools_featured_collapsed'
const FeaturedTools = ({ const FeaturedTools = ({
plugins, plugins,
providerMap, providerMap,
@@ -50,14 +50,14 @@ const FeaturedTools = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer) if (isServer)
return false return false
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED)
return stored === 'true' return stored === 'true'
}) })
useEffect(() => { useEffect(() => {
if (isServer) if (isServer)
return return
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED)
if (stored !== null) if (stored !== null)
setIsCollapsed(stored === 'true') setIsCollapsed(stored === 'true')
}, []) }, [])
@@ -65,7 +65,7 @@ const FeaturedTools = ({
useEffect(() => { useEffect(() => {
if (isServer) if (isServer)
return return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) storage.set(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED, String(isCollapsed))
}, [isCollapsed]) }, [isCollapsed])
useEffect(() => { useEffect(() => {

View File

@@ -11,9 +11,11 @@ import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' 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 { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client' import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { storage } from '@/utils/storage'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
import { BlockEnum } from '../types' import { BlockEnum } from '../types'
@@ -30,8 +32,6 @@ type FeaturedTriggersProps = {
onInstallSuccess?: () => void | Promise<void> onInstallSuccess?: () => void | Promise<void>
} }
const STORAGE_KEY = 'workflow_triggers_featured_collapsed'
const FeaturedTriggers = ({ const FeaturedTriggers = ({
plugins, plugins,
providerMap, providerMap,
@@ -45,14 +45,14 @@ const FeaturedTriggers = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer) if (isServer)
return false return false
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED)
return stored === 'true' return stored === 'true'
}) })
useEffect(() => { useEffect(() => {
if (isServer) if (isServer)
return return
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED)
if (stored !== null) if (stored !== null)
setIsCollapsed(stored === 'true') setIsCollapsed(stored === 'true')
}, []) }, [])
@@ -60,7 +60,7 @@ const FeaturedTriggers = ({
useEffect(() => { useEffect(() => {
if (isServer) if (isServer)
return return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) storage.set(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED, String(isCollapsed))
}, [isCollapsed]) }, [isCollapsed])
useEffect(() => { 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 { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useRAGRecommendedPlugins } from '@/service/use-tools' import { useRAGRecommendedPlugins } from '@/service/use-tools'
import { isServer } from '@/utils/client' import { isServer } from '@/utils/client'
import { storage } from '@/utils/storage'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import List from './list' import List from './list'
@@ -21,8 +23,6 @@ type RAGToolRecommendationsProps = {
onTagsChange: Dispatch<SetStateAction<string[]>> onTagsChange: Dispatch<SetStateAction<string[]>>
} }
const STORAGE_KEY = 'workflow_rag_recommendations_collapsed'
const RAGToolRecommendations = ({ const RAGToolRecommendations = ({
viewType, viewType,
onSelect, onSelect,
@@ -32,14 +32,14 @@ const RAGToolRecommendations = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer) if (isServer)
return false return false
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED)
return stored === 'true' return stored === 'true'
}) })
useEffect(() => { useEffect(() => {
if (isServer) if (isServer)
return return
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED)
if (stored !== null) if (stored !== null)
setIsCollapsed(stored === 'true') setIsCollapsed(stored === 'true')
}, []) }, [])
@@ -47,7 +47,7 @@ const RAGToolRecommendations = ({
useEffect(() => { useEffect(() => {
if (isServer) if (isServer)
return return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) storage.set(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED, String(isCollapsed))
}, [isCollapsed]) }, [isCollapsed])
const { const {

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ const createPanelWidthManager = (storageKey: string) => {
describe('Workflow Panel Width Persistence', () => { describe('Workflow Panel Width Persistence', () => {
describe('Node Panel Width Management', () => { 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', () => { it('should save user resize to localStorage', () => {
const manager = createPanelWidthManager(storageKey) const manager = createPanelWidthManager(storageKey)
@@ -74,7 +74,7 @@ describe('Workflow Panel Width Persistence', () => {
describe('Bug Scenario Reproduction', () => { describe('Bug Scenario Reproduction', () => {
it('should reproduce original bug behavior (for comparison)', () => { 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 // Original buggy behavior - always saves regardless of source
const buggyUpdate = (width: number) => { const buggyUpdate = (width: number) => {
@@ -89,7 +89,7 @@ describe('Workflow Panel Width Persistence', () => {
}) })
it('should verify fix prevents localStorage pollution', () => { it('should verify fix prevents localStorage pollution', () => {
const storageKey = 'workflow-node-panel-width' const storageKey = 'v1:workflow-node-panel-width'
const manager = createPanelWidthManager(storageKey) const manager = createPanelWidthManager(storageKey)
localStorage.setItem(storageKey, '500') // User preference localStorage.setItem(storageKey, '500') // User preference
@@ -101,7 +101,7 @@ describe('Workflow Panel Width Persistence', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle multiple rapid operations correctly', () => { 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 // Rapid system adjustments
manager.updateWidth(300, 'system') manager.updateWidth(300, 'system')
@@ -112,12 +112,12 @@ describe('Workflow Panel Width Persistence', () => {
manager.updateWidth(550, 'user') manager.updateWidth(550, 'user')
expect(localStorage.setItem).toHaveBeenCalledTimes(1) 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', () => { it('should handle corrupted localStorage gracefully', () => {
localStorage.setItem('workflow-node-panel-width', '150') // Below minimum localStorage.setItem('v1:workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('workflow-node-panel-width') const manager = createPanelWidthManager('v1:workflow-node-panel-width')
const storedWidth = manager.getStoredWidth() const storedWidth = manager.getStoredWidth()
expect(storedWidth).toBe(150) // Returns raw value expect(storedWidth).toBe(150) // Returns raw value
@@ -125,13 +125,13 @@ describe('Workflow Panel Width Persistence', () => {
// User can correct the preference // User can correct the preference
const correctedWidth = manager.updateWidth(500, 'user') const correctedWidth = manager.updateWidth(500, 'user')
expect(correctedWidth).toBe(500) 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', () => { describe('TypeScript Type Safety', () => {
it('should enforce source parameter type', () => { it('should enforce source parameter type', () => {
const manager = createPanelWidthManager('workflow-node-panel-width') const manager = createPanelWidthManager('v1:workflow-node-panel-width')
// Valid source values // Valid source values
manager.updateWidth(500, 'user') manager.updateWidth(500, 'user')

View File

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

View File

@@ -12,10 +12,12 @@ import {
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' 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 { 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 useTheme from '@/hooks/use-theme'
import { useGenerateStructuredOutputRules } from '@/service/use-common' import { useGenerateStructuredOutputRules } from '@/service/use-common'
import { ModelModeType, Theme } from '@/types/app' import { ModelModeType, Theme } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { useMittContext } from '../visual-editor/context' import { useMittContext } from '../visual-editor/context'
import { useVisualEditorStore } from '../visual-editor/store' import { useVisualEditorStore } from '../visual-editor/store'
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets' import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
@@ -36,9 +38,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
onApply, onApply,
crossAxisOffset, crossAxisOffset,
}) => { }) => {
const localModel = localStorage.getItem('auto-gen-model') const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [view, setView] = useState(GeneratorView.promptEditor) const [view, setView] = useState(GeneratorView.promptEditor)
const [model, setModel] = useState<Model>(localModel || { const [model, setModel] = useState<Model>(localModel || {
@@ -60,9 +60,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
useEffect(() => { useEffect(() => {
if (defaultModel) { if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model') const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
if (localModel) { if (localModel) {
setModel(localModel) setModel(localModel)
} }
@@ -95,7 +93,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
mode: newValue.mode as ModelModeType, mode: newValue.mode as ModelModeType,
} }
setModel(newModel) setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel]) }, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => { const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -104,7 +102,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
completion_params: newParams as CompletionParams, completion_params: newParams as CompletionParams,
} }
setModel(newModel) setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel]) }, [model, setModel])
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules() const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()

View File

@@ -27,7 +27,7 @@ const createMockLocalStorage = () => {
// Preview panel width logic // Preview panel width logic
const createPreviewPanelManager = () => { const createPreviewPanelManager = () => {
const storageKey = 'debug-and-preview-panel-width' const storageKey = 'v1:debug-and-preview-panel-width'
return { return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => { updateWidth: (width: number, source: PanelWidthSource = 'user') => {
@@ -63,7 +63,7 @@ describe('Debug and Preview Panel Width Persistence', () => {
const result = manager.updateWidth(450, 'user') const result = manager.updateWidth(450, 'user')
expect(result).toBe(450) 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', () => { 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 // Both user and system operations should behave consistently
manager.updateWidth(500, 'user') 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') 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', () => { describe('Dual Panel Scenario', () => {
it('should maintain independence from Node Panel', () => { it('should maintain independence from Node Panel', () => {
localStorage.setItem('workflow-node-panel-width', '600') localStorage.setItem('v1:workflow-node-panel-width', '600')
localStorage.setItem('debug-and-preview-panel-width', '450') localStorage.setItem('v1:debug-and-preview-panel-width', '450')
const manager = createPreviewPanelManager() const manager = createPreviewPanelManager()
@@ -98,8 +98,8 @@ describe('Debug and Preview Panel Width Persistence', () => {
manager.updateWidth(200, 'system') manager.updateWidth(200, 'system')
// Only preview panel storage key should be unaffected // Only preview panel storage key should be unaffected
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450') expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('450')
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600') expect(localStorage.getItem('v1:workflow-node-panel-width')).toBe('600')
}) })
it('should handle F12 scenario consistently', () => { it('should handle F12 scenario consistently', () => {
@@ -107,13 +107,13 @@ describe('Debug and Preview Panel Width Persistence', () => {
// User sets preference // User sets preference
manager.updateWidth(500, 'user') 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 // F12 opens causing viewport compression
manager.updateWidth(180, 'system') manager.updateWidth(180, 'system')
// User preference preserved // 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 // Same 400px minimum as Node Panel
const result = manager.updateWidth(300, 'user') const result = manager.updateWidth(300, 'user')
expect(result).toBe(400) 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', () => { 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 // Default to 'user' when source not specified
manager.updateWidth(500) 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 // Explicit 'system' source
manager.updateWidth(300, 'system') 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 { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { import {
useWorkflowInteractions, useWorkflowInteractions,
} from '../../hooks' } from '../../hooks'
@@ -56,7 +58,7 @@ const DebugAndPreview = () => {
const setPanelWidth = useStore(s => s.setPreviewPanelWidth) const setPanelWidth = useStore(s => s.setPreviewPanelWidth)
const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => { const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => {
if (source === 'user') if (source === 'user')
localStorage.setItem('debug-and-preview-panel-width', `${width}`) storage.set(STORAGE_KEYS.WORKFLOW.PREVIEW_PANEL_WIDTH, width)
setPanelWidth(width) setPanelWidth(width)
}, [setPanelWidth]) }, [setPanelWidth])
const maxPanelWidth = useMemo(() => { const maxPanelWidth = useMemo(() => {

View File

@@ -1,11 +1,12 @@
import type { StateCreator } from 'zustand' import type { StateCreator } from 'zustand'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
export type LayoutSliceShape = { export type LayoutSliceShape = {
workflowCanvasWidth?: number workflowCanvasWidth?: number
workflowCanvasHeight?: number workflowCanvasHeight?: number
setWorkflowCanvasWidth: (width: number) => void setWorkflowCanvasWidth: (width: number) => void
setWorkflowCanvasHeight: (height: number) => void setWorkflowCanvasHeight: (height: number) => void
// rightPanelWidth - otherPanelWidth = nodePanelWidth
rightPanelWidth?: number rightPanelWidth?: number
setRightPanelWidth: (width: number) => void setRightPanelWidth: (width: number) => void
nodePanelWidth: number nodePanelWidth: number
@@ -14,11 +15,11 @@ export type LayoutSliceShape = {
setPreviewPanelWidth: (width: number) => void setPreviewPanelWidth: (width: number) => void
otherPanelWidth: number otherPanelWidth: number
setOtherPanelWidth: (width: number) => void setOtherPanelWidth: (width: number) => void
bottomPanelWidth: number // min-width = 400px; default-width = auto || 480px; bottomPanelWidth: number
setBottomPanelWidth: (width: number) => void setBottomPanelWidth: (width: number) => void
bottomPanelHeight: number bottomPanelHeight: number
setBottomPanelHeight: (height: number) => void setBottomPanelHeight: (height: number) => void
variableInspectPanelHeight: number // min-height = 120px; default-height = 320px; variableInspectPanelHeight: number
setVariableInspectPanelHeight: (height: number) => void setVariableInspectPanelHeight: (height: number) => void
maximizeCanvas: boolean maximizeCanvas: boolean
setMaximizeCanvas: (maximize: boolean) => void setMaximizeCanvas: (maximize: boolean) => void
@@ -31,9 +32,9 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
setWorkflowCanvasHeight: height => set(() => ({ workflowCanvasHeight: height })), setWorkflowCanvasHeight: height => set(() => ({ workflowCanvasHeight: height })),
rightPanelWidth: undefined, rightPanelWidth: undefined,
setRightPanelWidth: width => set(() => ({ rightPanelWidth: width })), 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 })), 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 })), setPreviewPanelWidth: width => set(() => ({ previewPanelWidth: width })),
otherPanelWidth: 400, otherPanelWidth: 400,
setOtherPanelWidth: width => set(() => ({ otherPanelWidth: width })), setOtherPanelWidth: width => set(() => ({ otherPanelWidth: width })),
@@ -41,8 +42,8 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
setBottomPanelWidth: width => set(() => ({ bottomPanelWidth: width })), setBottomPanelWidth: width => set(() => ({ bottomPanelWidth: width })),
bottomPanelHeight: 324, bottomPanelHeight: 324,
setBottomPanelHeight: height => set(() => ({ bottomPanelHeight: height })), 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 })), 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 })), setMaximizeCanvas: maximize => set(() => ({ maximizeCanvas: maximize })),
}) })

View File

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

View File

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

View File

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

View File

@@ -13,13 +13,14 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
import { useToastContext } from '@/app/components/base/toast' 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 { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { import {
useEducationAdd, useEducationAdd,
useInvalidateEducationStatus, useInvalidateEducationStatus,
} from '@/service/use-education' } from '@/service/use-education'
import { storage } from '@/utils/storage'
import DifyLogo from '../components/base/logo/dify-logo' import DifyLogo from '../components/base/logo/dify-logo'
import RoleSelector from './role-selector' import RoleSelector from './role-selector'
import SearchInput from './search-input' import SearchInput from './search-input'
@@ -47,7 +48,7 @@ const EducationApplyAge = () => {
setShowModal(undefined) setShowModal(undefined)
onPlanInfoChanged() onPlanInfoChanged()
updateEducationStatus() updateEducationStatus()
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING)
router.replace('/') router.replace('/')
} }

View File

@@ -10,14 +10,15 @@ import {
useState, useState,
} from 'react' } from 'react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' 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 { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context' import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education' import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
import { storage } from '@/utils/storage'
import { import {
EDUCATION_RE_VERIFY_ACTION, EDUCATION_RE_VERIFY_ACTION,
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from './constants' } from './constants'
dayjs.extend(utc) dayjs.extend(utc)
@@ -133,7 +134,7 @@ const useEducationReverifyNotice = ({
export const useEducationInit = () => { export const useEducationInit = () => {
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal) 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 searchParams = useSearchParams()
const educationVerifyAction = searchParams.get('action') const educationVerifyAction = searchParams.get('action')
@@ -156,7 +157,7 @@ export const useEducationInit = () => {
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) 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) if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION)
handleVerify() handleVerify()

View File

@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar' import Avatar from '@/app/components/base/avatar'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { Triangle } from '@/app/components/base/icons/src/public/education' import { Triangle } from '@/app/components/base/icons/src/public/education'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { storage } from '@/utils/storage'
const UserInfo = () => { const UserInfo = () => {
const router = useRouter() const router = useRouter()
@@ -15,7 +17,7 @@ const UserInfo = () => {
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
localStorage.removeItem('setup_status') storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend
router.push('/signin') router.push('/signin')

View File

@@ -158,7 +158,7 @@ describe('InstallForm', () => {
render(<InstallForm />) render(<InstallForm />)
await waitFor(() => { await waitFor(() => {
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished') expect(localStorage.setItem).toHaveBeenCalledWith('v1:setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin') 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 { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { validPassword } from '@/config' import { validPassword } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { LICENSE_LINK } from '@/constants/link' import { LICENSE_LINK } from '@/constants/link'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common' import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { encryptPassword as encodePassword } from '@/utils/encryption' import { encryptPassword as encodePassword } from '@/utils/encryption'
import { storage } from '@/utils/storage'
import Loading from '../components/base/loading' import Loading from '../components/base/loading'
const accountFormSchema = z.object({ const accountFormSchema = z.object({
@@ -85,7 +87,7 @@ const InstallForm = () => {
useEffect(() => { useEffect(() => {
fetchSetupStatus().then((res: SetupStatusResponse) => { fetchSetupStatus().then((res: SetupStatusResponse) => {
if (res.step === 'finished') { if (res.step === 'finished') {
localStorage.setItem('setup_status', 'finished') storage.set(STORAGE_KEYS.CONFIG.SETUP_STATUS, 'finished')
router.push('/signin') router.push('/signin')
} }
else { else {

View File

@@ -9,10 +9,12 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common' 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() { export default function CheckCode() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -40,7 +42,7 @@ export default function CheckCode() {
setIsLoading(true) setIsLoading(true)
const res = await sendResetPasswordCode(email, locale) const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') { 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) const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data)) params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email)) params.set('email', encodeURIComponent(email))

View File

@@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' 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 { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { sendEMailLoginCode } from '@/service/common' import { sendEMailLoginCode } from '@/service/common'
import { storage } from '@/utils/storage'
type MailAndCodeAuthProps = { type MailAndCodeAuthProps = {
isInvite: boolean isInvite: boolean
@@ -40,7 +42,7 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
setIsLoading(true) setIsLoading(true)
const ret = await sendEMailLoginCode(email, locale) const ret = await sendEMailLoginCode(email, locale)
if (ret.result === 'success') { 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) const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email)) params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data)) params.set('token', encodeURIComponent(ret.data))

View File

@@ -1,15 +1,17 @@
import type { ReadonlyURLSearchParams } from 'next/navigation' import type { ReadonlyURLSearchParams } from 'next/navigation'
import dayjs from 'dayjs' 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 { function getItemWithExpiry(key: string): string | null {
const itemStr = localStorage.getItem(key) const itemStr = storage.get<string>(key)
if (!itemStr) if (!itemStr)
return null return null
try { try {
const item = JSON.parse(itemStr) const item = JSON.parse(itemStr)
localStorage.removeItem(key) storage.remove(key)
if (!item?.value) if (!item?.value)
return null return null
@@ -24,7 +26,7 @@ export const resolvePostLoginRedirect = (searchParams: ReadonlyURLSearchParams)
const redirectUrl = searchParams.get(REDIRECT_URL_KEY) const redirectUrl = searchParams.get(REDIRECT_URL_KEY)
if (redirectUrl) { if (redirectUrl) {
try { try {
localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY) storage.remove(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING)
return decodeURIComponent(redirectUrl) return decodeURIComponent(redirectUrl)
} }
catch (e) { 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 type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { PromptRole } from '@/models/debug' import { PromptRole } from '@/models/debug'
import { PipelineInputVarType } from '@/models/pipeline' import { PipelineInputVarType } from '@/models/pipeline'
import { AgentStrategy } from '@/types/app' import { AgentStrategy } from '@/types/app'
@@ -179,7 +180,7 @@ export const CSRF_COOKIE_NAME = () => {
return isSecure ? '__Host-csrf_token' : 'csrf_token' return isSecure ? '__Host-csrf_token' : 'csrf_token'
} }
export const CSRF_HEADER_NAME = 'X-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_LOCAL_STORAGE_NAME = (appCode: string) => `passport-${appCode}`
export const PASSPORT_HEADER_NAME = 'X-App-Passport' 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 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 = { export const DATASET_DEFAULT = {
top_k: 4, 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 { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { isServer } from '@/utils/client' import { isServer } from '@/utils/client'
import { storage } from '@/utils/storage'
export type TriggerEventsLimitModalPayload = { export type TriggerEventsLimitModalPayload = {
usage: number usage: number
@@ -80,15 +81,10 @@ export const useTriggerEventsLimitModal = ({
if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey]) if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey])
return return
let persistDismiss = true const persistDismiss = storage.isAvailable()
let hasDismissed = false let hasDismissed = false
try { if (storage.get<string>(storageKey) === '1')
if (localStorage.getItem(storageKey) === '1') hasDismissed = true
hasDismissed = true
}
catch {
persistDismiss = false
}
if (hasDismissed) if (hasDismissed)
return return
@@ -110,16 +106,9 @@ export const useTriggerEventsLimitModal = ({
const storageKey = showTriggerEventsLimitModal?.payload.storageKey const storageKey = showTriggerEventsLimitModal?.payload.storageKey
if (!storageKey) if (!storageKey)
return 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 dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true
if (showTriggerEventsLimitModal?.payload.persistDismiss)
storage.set(storageKey, '1')
}, [showTriggerEventsLimitModal]) }, [showTriggerEventsLimitModal])
return { return {

View File

@@ -130,7 +130,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
expect(setItemSpy.mock.calls.length).toBeGreaterThan(0) expect(setItemSpy.mock.calls.length).toBeGreaterThan(0)
}) })
const [key, value] = setItemSpy.mock.calls[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') expect(value).toBe('1')
}) })

View File

@@ -30,15 +30,15 @@ import {
DEFAULT_ACCOUNT_SETTING_TAB, DEFAULT_ACCOUNT_SETTING_TAB,
isValidAccountSettingTab, isValidAccountSettingTab,
} from '@/app/components/header/account-setting/constants' } from '@/app/components/header/account-setting/constants'
import {
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, import { STORAGE_KEYS } from '@/config/storage-keys'
} from '@/app/education-apply/constants'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { import {
useAccountSettingModal, useAccountSettingModal,
usePricingModal, usePricingModal,
} from '@/hooks/use-query-params' } from '@/hooks/use-query-params'
import { storage } from '@/utils/storage'
import { import {
@@ -183,10 +183,10 @@ export const ModalContextProvider = ({
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
const handleCancelAccountSettingModal = () => { const handleCancelAccountSettingModal = () => {
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) const educationVerifying = storage.get<string>(STORAGE_KEYS.EDUCATION.VERIFYING)
if (educationVerifying === 'yes') if (educationVerifying === 'yes')
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING)
accountSettingCallbacksRef.current?.onCancelCallback?.() accountSettingCallbacksRef.current?.onCancelCallback?.()
accountSettingCallbacksRef.current = null accountSettingCallbacksRef.current = null

View File

@@ -19,6 +19,7 @@ import {
ModelTypeEnum, ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations' } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ZENDESK_FIELD_IDS } from '@/config' import { ZENDESK_FIELD_IDS } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { fetchCurrentPlanInfo } from '@/service/billing' import { fetchCurrentPlanInfo } from '@/service/billing'
import { import {
useModelListByType, useModelListByType,
@@ -28,6 +29,7 @@ import {
import { import {
useEducationStatus, useEducationStatus,
} from '@/service/use-education' } from '@/service/use-education'
import { storage } from '@/utils/storage'
export type ProviderContextState = { export type ProviderContextState = {
modelProviders: ModelProvider[] modelProviders: ModelProvider[]
@@ -200,7 +202,7 @@ export const ProviderContextProvider = ({
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
if (localStorage.getItem('anthropic_quota_notice') === 'true') if (storage.get<string>(STORAGE_KEYS.UI.ANTHROPIC_QUOTA_NOTICE) === 'true')
return return
if (dayjs().isAfter(dayjs('2025-03-17'))) if (dayjs().isAfter(dayjs('2025-03-17')))
@@ -216,7 +218,7 @@ export const ProviderContextProvider = ({
message: t('provider.anthropicHosted.trialQuotaTip', { ns: 'common' }), message: t('provider.anthropicHosted.trialQuotaTip', { ns: 'common' }),
duration: 60000, duration: 60000,
onClose: () => { onClose: () => {
localStorage.setItem('anthropic_quota_notice', 'true') storage.set(STORAGE_KEYS.UI.ANTHROPIC_QUOTA_NOTICE, 'true')
}, },
}) })
} }

View File

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

View File

@@ -44,6 +44,40 @@ export default antfu(
{ {
rules: { rules: {
'node/prefer-global/process': 'off', '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 { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' 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 { useSelector } from '@/context/app-context'
import { DSLImportStatus } from '@/models/app' import { DSLImportStatus } from '@/models/app'
import { import {
@@ -20,6 +20,7 @@ import {
importDSLConfirm, importDSLConfirm,
} from '@/service/apps' } from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { storage } from '@/utils/storage'
type DSLPayload = { type DSLPayload = {
mode: DSLImportMode mode: DSLImportMode
@@ -83,7 +84,7 @@ export const useImportDSL = () => {
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
}) })
onSuccess?.() onSuccess?.()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
await handleCheckPluginDependencies(app_id) await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push) getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
} }
@@ -137,7 +138,7 @@ export const useImportDSL = () => {
message: t('newApp.appCreated', { ns: 'app' }), message: t('newApp.appCreated', { ns: 'app' }),
}) })
await handleCheckPluginDependencies(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) getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
} }
else if (status === DSLImportStatus.FAILED) { else if (status === DSLImportStatus.FAILED) {

View File

@@ -1,13 +1,13 @@
import { API_PREFIX } from '@/config' import { API_PREFIX } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { fetchWithRetry } from '@/utils' import { fetchWithRetry } from '@/utils'
import { storage } from '@/utils/storage'
const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing'
let isRefreshing = false let isRefreshing = false
function waitUntilTokenRefreshed() { function waitUntilTokenRefreshed() {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
function _check() { 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) { if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
setTimeout(() => { setTimeout(() => {
_check() _check()
@@ -23,35 +23,28 @@ function waitUntilTokenRefreshed() {
const isRefreshingSignAvailable = function (delta: number) { const isRefreshingSignAvailable = function (delta: number) {
const nowTime = new Date().getTime() 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 return nowTime - Number.parseInt(lastTime) <= delta
} }
// only one request can send
async function getNewAccessToken(timeout: number): Promise<void> { async function getNewAccessToken(timeout: number): Promise<void> {
try { 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) { if ((isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) || isRefreshing) {
await waitUntilTokenRefreshed() await waitUntilTokenRefreshed()
} }
else { else {
isRefreshing = true isRefreshing = true
globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1') storage.set(STORAGE_KEYS.AUTH.REFRESH_LOCK, '1')
globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString()) storage.set(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME, new Date().getTime().toString())
globalThis.addEventListener('beforeunload', releaseRefreshLock) 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`, { const [error, ret] = await fetchWithRetry(globalThis.fetch(`${API_PREFIX}/refresh-token`, {
method: 'POST', method: 'POST',
credentials: 'include', // Important: include cookies in the request credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json;utf-8', 'Content-Type': 'application/json;utf-8',
}, },
// No body needed - refresh token is in cookie
})) }))
if (error) { if (error) {
return Promise.reject(error) return Promise.reject(error)
@@ -72,11 +65,9 @@ async function getNewAccessToken(timeout: number): Promise<void> {
} }
function releaseRefreshLock() { 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 isRefreshing = false
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) storage.remove(STORAGE_KEYS.AUTH.REFRESH_LOCK)
globalThis.localStorage.removeItem('last_refresh_time') storage.remove(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME)
globalThis.removeEventListener('beforeunload', releaseRefreshLock) 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' import { getPublic, postPublic } from './base'
export function setWebAppAccessToken(token: string) { 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) { export function setWebAppPassport(shareCode: string, token: string) {
localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode), token) storage.set(`passport-${shareCode}`, token)
} }
export function getWebAppAccessToken() { 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) { export function getWebAppPassport(shareCode: string) {
return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || '' return storage.get<string>(`passport-${shareCode}`) || ''
} }
export function clearWebAppAccessToken() { export function clearWebAppAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) storage.remove(STORAGE_KEYS.AUTH.ACCESS_TOKEN)
} }
export function clearWebAppPassport(shareCode: string) { export function clearWebAppPassport(shareCode: string) {
localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) storage.remove(`passport-${shareCode}`)
} }
type isWebAppLogin = { type isWebAppLogin = {
@@ -31,8 +32,6 @@ type isWebAppLogin = {
} }
export async function webAppLoginStatus(shareCode: string, userId?: string) { 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 }) const params = new URLSearchParams({ app_code: shareCode })
if (userId) if (userId)
params.append('user_id', userId) params.append('user_id', userId)

View File

@@ -19,7 +19,7 @@ describe('setup-status utilities', () => {
describe('fetchSetupStatusWithCache', () => { describe('fetchSetupStatusWithCache', () => {
describe('when cache exists', () => { describe('when cache exists', () => {
it('should return cached finished status without API call', async () => { 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() const result = await fetchSetupStatusWithCache()
@@ -28,11 +28,11 @@ describe('setup-status utilities', () => {
}) })
it('should not modify localStorage when returning cached value', async () => { it('should not modify localStorage when returning cached value', async () => {
localStorage.setItem('setup_status', 'finished') localStorage.setItem('v1:setup_status', 'finished')
await fetchSetupStatusWithCache() 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(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse) 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 () => { it('should call API and remove cache when not finished', async () => {
@@ -56,24 +56,24 @@ describe('setup-status utilities', () => {
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1) expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse) 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 () => { 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' } const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse) mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache() const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse) expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull() expect(localStorage.getItem('v1:setup_status')).toBeNull()
}) })
}) })
describe('cache edge cases', () => { describe('cache edge cases', () => {
it('should call API when cache value is empty string', async () => { 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' } const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse) mockFetchSetupStatus.mockResolvedValue(apiResponse)
@@ -84,7 +84,7 @@ describe('setup-status utilities', () => {
}) })
it('should call API when cache value is not "finished"', async () => { 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' } const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse) mockFetchSetupStatus.mockResolvedValue(apiResponse)
@@ -132,7 +132,7 @@ describe('setup-status utilities', () => {
await expect(fetchSetupStatusWithCache()).rejects.toThrow() 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 type { SetupStatusResponse } from '@/models/common'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { fetchSetupStatus } from '@/service/common' import { fetchSetupStatus } from '@/service/common'
import { storage } from './storage'
const SETUP_STATUS_KEY = 'setup_status'
const isSetupStatusCached = (): boolean => const isSetupStatusCached = (): boolean =>
localStorage.getItem(SETUP_STATUS_KEY) === 'finished' storage.get<string>(STORAGE_KEYS.CONFIG.SETUP_STATUS) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => { export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached()) if (isSetupStatusCached())
@@ -13,9 +13,9 @@ export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse>
const status = await fetchSetupStatus() const status = await fetchSetupStatus()
if (status.step === 'finished') if (status.step === 'finished')
localStorage.setItem(SETUP_STATUS_KEY, 'finished') storage.set(STORAGE_KEYS.CONFIG.SETUP_STATUS, 'finished')
else else
localStorage.removeItem(SETUP_STATUS_KEY) storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
return 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> let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => { beforeEach(async () => {
vi.clearAllMocks() vi.clearAllMocks()
mockLocalStorage = createMockLocalStorage() mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', { Object.defineProperty(globalThis, 'localStorage', {
@@ -142,4 +142,7 @@ beforeEach(() => {
writable: true, writable: true,
configurable: true, configurable: true,
}) })
// Reset storage module cache to ensure fresh state for each test
const { storage } = await import('./utils/storage')
storage.resetCache()
}) })