refactor: migrate appDetail from Zustand to TanStack Query

- Remove appDetail and setAppDetail from Zustand store
- Use useAppDetail hook for server state management
- Child components now call useAppDetail(appId) directly via useParams()
- Replace setAppDetail calls with useInvalidateAppDetail for cache invalidation
- Keep only client UI state in Zustand (sidebar, modals)
- Split sidebar initialization useEffect for clearer separation of concerns
- Update test mocks to use TanStack Query pattern
- Fix missing dependencies in use-checklist.ts useMemo/useCallback hooks
This commit is contained in:
yyh
2026-01-18 23:07:33 +08:00
parent 7b66bbc35a
commit 91856b09ca
43 changed files with 439 additions and 382 deletions

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import type { App } from '@/types/app'
import {
RiDashboard2Fill,
RiDashboard2Line,
@ -12,13 +11,11 @@ import {
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import dynamic from 'next/dynamic'
import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
@ -26,7 +23,7 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
@ -41,47 +38,41 @@ export type IAppDetailLayoutProps = {
}
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
appId, // get appId in path
} = props
const { children, appId } = props
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
const [navigation, setNavigation] = useState<Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace } = useAppContext()
const setAppSidebarExpand = useStore(s => s.setAppSidebarExpand)
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const { data: appDetail, isPending, error } = useAppDetail(appId)
const navigation = useMemo(() => {
if (!appDetail)
return []
const mode = appDetail.mode
const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT
return [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
href: `/app/${appId}/${isWorkflowMode ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine as NavIcon,
selectedIcon: RiTerminalWindowFill as NavIcon,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
icon: RiTerminalBoxLine as NavIcon,
selectedIcon: RiTerminalBoxFill as NavIcon,
},
...(isCurrentWorkspaceEditor
? [{
@ -89,74 +80,64 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
icon: RiFileList3Line as NavIcon,
selectedIcon: RiFileList3Fill as NavIcon,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
icon: RiDashboard2Line as NavIcon,
selectedIcon: RiDashboard2Fill as NavIcon,
},
]
return navConfig
}, [t])
}, [appDetail, appId, isCurrentWorkspaceEditor, t])
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode
// if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow'))
// setAppSidebarExpand('collapse')
}
}, [appDetail, isMobile])
if (!appDetail)
return
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
setAppSidebarExpand(localeMode)
}, [appDetail, setAppSidebarExpand])
useEffect(() => {
setAppDetail()
setIsLoadingAppDetail(true)
fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => {
setAppDetailRes(res)
}).catch((e: any) => {
if (e.status === 404)
router.replace('/apps')
}).finally(() => {
setIsLoadingAppDetail(false)
})
}, [appId, pathname])
if (isMobile)
setAppSidebarExpand('collapse')
}, [isMobile, setAppSidebarExpand])
useEffect(() => {
if (!appDetailRes || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail)
return
const res = appDetailRes
// redirection
const canIEditApp = isCurrentWorkspaceEditor
if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) {
router.replace(`/app/${appId}/overview`)
if (!appDetail || isLoadingCurrentWorkspace)
return
const mode = appDetail.mode
const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT
if (!isCurrentWorkspaceEditor) {
const restrictedPaths = ['configuration', 'workflow', 'logs']
if (restrictedPaths.some(p => pathname.endsWith(p))) {
router.replace(`/app/${appId}/overview`)
return
}
}
if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) {
if (isWorkflowMode && pathname.endsWith('configuration'))
router.replace(`/app/${appId}/workflow`)
}
else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) {
else if (!isWorkflowMode && pathname.endsWith('workflow'))
router.replace(`/app/${appId}/configuration`)
}
else {
setAppDetail({ ...res, enable_sso: false })
setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode))
}
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
}, [appDetail, isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, pathname, appId, router])
useUnmount(() => {
setAppDetail()
})
useEffect(() => {
if (error) {
const httpError = error as { status?: number }
if (httpError.status === 404)
router.replace('/apps')
}
}, [error, router])
if (!appDetail) {
if (isPending) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
@ -164,13 +145,12 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
)
}
if (!appDetail)
return null
return (
<div className={cn(s.app, 'relative flex', 'overflow-hidden')}>
{appDetail && (
<AppSideBar
navigation={navigation}
/>
)}
<AppSideBar navigation={navigation} />
<div className="grow overflow-hidden bg-components-panel-bg">
{children}
</div>
@ -180,4 +160,5 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
</div>
)
}
export default React.memo(AppDetailLayout)

View File

@ -5,13 +5,13 @@ import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
@ -19,11 +19,11 @@ import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useDocLink } from '@/context/i18n'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
@ -38,8 +38,8 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const queryClient = useQueryClient()
const { data: appDetail } = useAppDetail(appId)
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
@ -90,11 +90,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
: null
const updateAppDetail = async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
}
catch (error) { console.error(error) }
await queryClient.invalidateQueries({ queryKey: ['apps', 'detail', appId] })
}
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {

View File

@ -8,8 +8,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppDetail } from '@/service/use-apps'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
@ -34,7 +34,7 @@ export type IChartViewProps = {
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { t } = useTranslation()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION

View File

@ -11,14 +11,14 @@ import {
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
@ -26,7 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { useAppDetail, useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@ -64,9 +64,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { appId } = useParams()
const queryClient = useQueryClient()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appId as string)
const invalidateAppList = useInvalidateAppList()
const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false)
@ -77,6 +78,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [showExportWarning, setShowExportWarning] = useState(false)
const invalidateAppDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['apps', 'detail', appId] })
}, [queryClient, appId])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
@ -89,7 +94,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
if (!appDetail)
return
try {
const app = await updateAppInfo({
await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
@ -104,12 +109,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
type: 'success',
message: t('editDone', { ns: 'app' }),
})
setAppDetail(app)
invalidateAppDetail()
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
}
}, [appDetail, notify, setAppDetail, t])
}, [appDetail, notify, invalidateAppDetail, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@ -195,7 +200,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: any) {
@ -205,7 +209,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
})
}
setShowConfirmDelete(false)
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, t])
const { isCurrentWorkspaceEditor } = useAppContext()
@ -242,7 +246,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
]
const secondaryOperations: Operation[] = [
// Import DSL (conditional)
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [{
id: 'import',
@ -255,7 +258,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
},
}]
: [],
// Divider
{
id: 'divider-1',
title: '',
@ -263,7 +265,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
onClick: () => { /* divider has no action */ },
type: 'divider' as const,
},
// Delete operation
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
@ -276,7 +277,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
},
]
// Keep the switch operation separate as it's not part of the main operations
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
? {
id: 'switch',
@ -370,11 +370,9 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
<div className="system-2xs-medium-uppercase text-text-tertiary">{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}</div>
</div>
</div>
{/* description */}
{appDetail.description && (
<div className="system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary">{appDetail.description}</div>
)}
{/* operations */}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
@ -386,7 +384,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{/* Switch operation (if available) */}
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button

View File

@ -3,16 +3,17 @@ import {
RiEqualizer2Line,
RiMenuLine,
} from '@remixicon/react'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAppContext } from '@/context/app-context'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import AppIcon from '../base/app-icon'
@ -31,8 +32,9 @@ type Props = {
const AppSidebarDropdown = ({ navigation }: Props) => {
const { t } = useTranslation()
const { appId } = useParams()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId as string)
const [detailExpand, setDetailExpand] = useState(false)
const [open, doSetOpen] = useState(false)

View File

@ -15,6 +15,7 @@ import {
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
@ -41,8 +41,8 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
@ -139,6 +139,7 @@ const AppPublisher = ({
startNodeLimitExceeded = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
const { appId } = useParams()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
@ -146,8 +147,8 @@ const AppPublisher = ({
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: appDetail } = useAppDetail(appId as string)
const invalidateAppDetail = useInvalidateAppDetail()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
@ -239,16 +240,15 @@ const AppPublisher = ({
}, [appDetail?.id, openAsyncWindow])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)
if (!appId)
return
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(res)
invalidateAppDetail(appId as string)
}
finally {
setShowAppAccessControl(false)
}
}, [appDetail, setAppDetail])
}, [appId, invalidateAppDetail])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()

View File

@ -4,11 +4,11 @@ import type { Item as SelectItem } from './type-select'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
@ -22,6 +22,7 @@ import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum, TransferMethod } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ConfigSelect from '../config-select'
@ -72,10 +73,11 @@ const ConfigModal: FC<IConfigModalProps> = ({
}) => {
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
const { type, label, variable, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject

View File

@ -71,9 +71,10 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps'
import { updateAppModelConfig } from '@/service/apps'
import { fetchDatasets } from '@/service/datasets'
import { fetchCollectionList } from '@/service/tools'
import { useAppDetail } from '@/service/use-apps'
import { useFileUploadConfig } from '@/service/use-common'
import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
import {
@ -95,22 +96,22 @@ const Configuration: FC = () => {
const { notify } = useContext(ToastContext)
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
const { showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
setAppSidebarExpand: state.setAppSidebarExpand,
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const { data: appDetail } = useAppDetail(appId)
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const [formattingChanged, setFormattingChanged] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
const isLoading = !hasFetchedDetail
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
@ -548,7 +549,10 @@ const Configuration: FC = () => {
}, [modelConfig])
useEffect(() => {
(async () => {
if (!appDetail)
return
const initConfig = async () => {
const collectionList = await fetchCollectionList()
if (basePath) {
collectionList.forEach((item) => {
@ -557,9 +561,8 @@ const Configuration: FC = () => {
})
}
setCollectionList(collectionList)
const res = await fetchAppDetailDirect({ url: '/apps', id: appId })
setMode(res.mode as AppModeEnum)
const modelConfig = res.model_config as BackendModelConfig
setMode(appDetail.mode as AppModeEnum)
const modelConfig = appDetail.model_config as BackendModelConfig
const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple
doSetPromptMode(promptMode)
if (promptMode === PromptMode.advanced) {
@ -669,7 +672,7 @@ const Configuration: FC = () => {
external_data_tools: modelConfig.external_data_tools ?? [],
system_parameters: modelConfig.system_parameters,
dataSets: datasets || [],
agentConfig: res.mode === AppModeEnum.AGENT_CHAT ? {
agentConfig: appDetail.mode === AppModeEnum.AGENT_CHAT ? {
max_iteration: DEFAULT_AGENT_SETTING.max_iteration,
...modelConfig.agent_mode,
// remove dataset
@ -680,7 +683,7 @@ const Configuration: FC = () => {
const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id)
return {
...tool,
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
isDeleted: appDetail.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
? {
@ -726,8 +729,10 @@ const Configuration: FC = () => {
datasetConfigsToSet.retrieval_model = datasetConfigsToSet.retrieval_model ?? RETRIEVE_TYPE.multiWay
setDatasetConfigs(datasetConfigsToSet)
setHasFetchedDetail(true)
})()
}, [appId])
}
initConfig()
}, [appDetail])
const promptEmpty = (() => {
if (mode !== AppModeEnum.COMPLETION)

View File

@ -1,16 +1,22 @@
import type { App, AppIconType } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import LogAnnotation from './index'
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'app-123',
}),
}))
vi.mock('@/app/components/app/annotation', () => ({
@ -61,17 +67,25 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
function mockAppDetailReturn(app: App | undefined) {
mockUseAppDetail.mockReturnValue({
data: app,
isLoading: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
describe('LogAnnotation', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
mockAppDetailReturn(createMockApp())
})
// Rendering behavior
describe('Rendering', () => {
it('should render loading state when app detail is missing', () => {
// Arrange
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -82,7 +96,7 @@ describe('LogAnnotation', () => {
it('should render log and annotation tabs for non-completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -94,7 +108,7 @@ describe('LogAnnotation', () => {
it('should render only log tab for completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.COMPLETION }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -106,7 +120,7 @@ describe('LogAnnotation', () => {
it('should hide tabs and render workflow log in workflow mode', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.WORKFLOW }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -121,7 +135,7 @@ describe('LogAnnotation', () => {
describe('Props', () => {
it('should render log content when page type is log', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -133,7 +147,7 @@ describe('LogAnnotation', () => {
it('should render annotation content when page type is annotation', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.annotation} />)

View File

@ -1,16 +1,16 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Annotation from '@/app/components/app/annotation'
import Log from '@/app/components/app/log'
import { useStore as useAppStore } from '@/app/components/app/store'
import WorkflowLog from '@/app/components/app/workflow-log'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import Loading from '@/app/components/base/loading'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
@ -23,7 +23,8 @@ const LogAnnotation: FC<Props> = ({
}) => {
const { t } = useTranslation()
const router = useRouter()
const appDetail = useAppStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const options = useMemo(() => {
if (appDetail?.mode === AppModeEnum.COMPLETION)

View File

@ -14,12 +14,12 @@ import {
RiVerifiedBadgeLine,
RiWindowLine,
} from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
@ -35,7 +35,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
@ -73,11 +73,11 @@ function AppCard({
}: IAppCardProps) {
const router = useRouter()
const pathname = usePathname()
const queryClient = useQueryClient()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appInfo.id)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
@ -179,15 +179,9 @@ function AppCard({
setShowAccessControl(true)
}, [appDetail])
const handleAccessControlUpdate = useCallback(async () => {
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id })
setAppDetail(res)
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, setAppDetail])
await queryClient.invalidateQueries({ queryKey: ['apps', 'detail', appInfo.id] })
setShowAccessControl(false)
}, [queryClient, appInfo.id])
return (
<div

View File

@ -1,9 +1,7 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { App, AppSSO } from '@/types/app'
import { create } from 'zustand'
type State = {
appDetail?: App & Partial<AppSSO>
appSidebarExpand: string
currentLogItem?: IChatItem
currentLogModalActiveTab: string
@ -14,7 +12,6 @@ type State = {
}
type Action = {
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
setAppSidebarExpand: (state: string) => void
setCurrentLogItem: (item?: IChatItem) => void
setCurrentLogModalActiveTab: (tab: string) => void
@ -25,8 +22,6 @@ type Action = {
}
export const useStore = create<State & Action>(set => ({
appDetail: undefined,
setAppDetail: appDetail => set(() => ({ appDetail })),
appSidebarExpand: '',
setAppSidebarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
currentLogItem: undefined,

View File

@ -3,11 +3,10 @@
import type { App } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@ -21,6 +20,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { deleteApp, switchApp } from '@/service/apps'
import { useInvalidateAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
@ -38,7 +38,8 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
const { push, replace } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { appId } = useParams()
const invalidateAppDetail = useInvalidateAppDetail()
const { isCurrentWorkspaceEditor } = useAppContext()
const { plan, enableBilling } = useProviderContext()
@ -70,7 +71,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
onClose()
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
if (inAppDetail)
setAppDetail()
invalidateAppDetail(appId as string)
if (removeOriginal)
await deleteApp(appDetail.id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')

View File

@ -11,18 +11,24 @@
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppDetail } from '@/service/use-apps'
import DetailPanel from './detail'
// ============================================================================
// Mocks
// ============================================================================
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
// Mock the Run component as it has complex dependencies
@ -88,6 +94,18 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
// ============================================================================
// Helper Functions
// ============================================================================
function mockAppDetailReturn(app: App | undefined) {
mockUseAppDetail.mockReturnValue({
data: app,
isLoading: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
// ============================================================================
// Tests
// ============================================================================
@ -97,7 +115,7 @@ describe('DetailPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
mockAppDetailReturn(createMockApp())
})
// --------------------------------------------------------------------------
@ -125,7 +143,7 @@ describe('DetailPanel', () => {
})
it('should render Run component with correct URLs', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'app-456' }) })
mockAppDetailReturn(createMockApp({ id: 'app-456' }))
render(<DetailPanel runID="run-789" onClose={defaultOnClose} />)
@ -185,7 +203,7 @@ describe('DetailPanel', () => {
it('should navigate to workflow page with replayRunId when replay button is clicked', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay-test' }) })
mockAppDetailReturn(createMockApp({ id: 'app-replay-test' }))
render(<DetailPanel runID="run-to-replay" onClose={defaultOnClose} canReplay={true} />)
@ -197,7 +215,7 @@ describe('DetailPanel', () => {
it('should not navigate when replay clicked but appDetail is missing', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
@ -213,7 +231,7 @@ describe('DetailPanel', () => {
// --------------------------------------------------------------------------
describe('URL Generation', () => {
it('should generate correct run detail URL', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
mockAppDetailReturn(createMockApp({ id: 'my-app' }))
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
@ -221,7 +239,7 @@ describe('DetailPanel', () => {
})
it('should generate correct tracing list URL', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
mockAppDetailReturn(createMockApp({ id: 'my-app' }))
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
@ -229,7 +247,7 @@ describe('DetailPanel', () => {
})
it('should handle special characters in runID', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'app-id' }))
render(<DetailPanel runID="run-with-special-123" onClose={defaultOnClose} />)
@ -242,7 +260,7 @@ describe('DetailPanel', () => {
// --------------------------------------------------------------------------
describe('Store Integration', () => {
it('should read appDetail from store', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'store-app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'store-app-id' }))
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
@ -250,7 +268,7 @@ describe('DetailPanel', () => {
})
it('should handle undefined appDetail from store gracefully', () => {
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
@ -272,7 +290,7 @@ describe('DetailPanel', () => {
it('should handle very long runID', () => {
const longRunId = 'a'.repeat(100)
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'app-id' }))
render(<DetailPanel runID={longRunId} onClose={defaultOnClose} />)

View File

@ -1,12 +1,12 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import TooltipPlus from '@/app/components/base/tooltip'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import Run from '@/app/components/workflow/run'
import { useAppDetail } from '@/service/use-apps'
type ILogDetail = {
runID: string
@ -16,7 +16,8 @@ type ILogDetail = {
const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => {
const { t } = useTranslation()
const appDetail = useStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const router = useRouter()
const handleReplay = () => {

View File

@ -13,7 +13,6 @@ import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } fr
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import WorkflowAppLogList from './list'
@ -27,6 +26,9 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
// Mock useTimestamp hook
@ -86,6 +88,40 @@ vi.mock('ahooks', () => ({
},
}))
// Mock app detail data for useAppDetail hook
const mockAppDetail: App = {
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: '',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: 'workflow' as AppModeEnum,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
}
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({ data: mockAppDetail, isPending: false }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
@ -168,7 +204,6 @@ describe('WorkflowAppLogList', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
})
// --------------------------------------------------------------------------
@ -426,7 +461,6 @@ describe('WorkflowAppLogList', () => {
describe('Drawer', () => {
it('should open drawer when clicking on a log row', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-123' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
id: 'log-1',
@ -449,7 +483,6 @@ describe('WorkflowAppLogList', () => {
it('should close drawer and call onRefresh when closing', async () => {
const user = userEvent.setup()
const onRefresh = vi.fn()
useAppStore.setState({ appDetail: createMockApp() })
const logs = createMockLogsResponse([createMockWorkflowLog()])
render(
@ -498,7 +531,6 @@ describe('WorkflowAppLogList', () => {
describe('Replay Functionality', () => {
it('should allow replay when triggered from app-run', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
@ -521,12 +553,11 @@ describe('WorkflowAppLogList', () => {
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
await user.click(replayButton)
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay/workflow?replayRunId=run-to-replay')
expect(mockRouterPush).toHaveBeenCalledWith('/app/test-app-id/workflow?replayRunId=run-to-replay')
})
it('should allow replay when triggered from debugging', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-debug' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
@ -552,7 +583,6 @@ describe('WorkflowAppLogList', () => {
it('should not show replay for webhook triggers', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-webhook' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({

View File

@ -4,14 +4,15 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { uniq } from 'es-toolkit/array'
import { flatten } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import { fetchAgentLogDetail } from '@/service/log'
import { useAppDetail } from '@/service/use-apps'
import { cn } from '@/utils/classnames'
import ResultPanel from './result'
import TracingPanel from './tracing'
@ -32,7 +33,8 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentTab, setCurrentTab] = useState<string>(activeTab)
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [loading, setLoading] = useState<boolean>(true)
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
const [list, setList] = useState<AgentIteration[]>([])

View File

@ -1,8 +1,8 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
import { useEffect, useRef } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { ToastProvider } from '@/app/components/base/toast'
import AgentLogModal from '.'
@ -64,21 +64,34 @@ const MOCK_CHAT_ITEM: IChatItem = {
conversationId: 'conv-123',
}
const MOCK_APP_DETAIL = {
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
}
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
})
client.setQueryData(['apps', 'detail', 'app-1'], MOCK_APP_DETAIL)
return client
}
const AgentLogModalDemo = ({
width = 960,
}: {
width?: number
}) => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [queryClient] = useState(() => createQueryClient())
useEffect(() => {
setAppDetail({
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
} as any)
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
@ -104,22 +117,23 @@ const AgentLogModalDemo = ({
return () => {
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
setAppDetail(undefined)
}
}, [setAppDetail])
}, [])
return (
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
</QueryClientProvider>
)
}

View File

@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowRunDetailResponse } from '@/models/log'
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
import { useEffect } from 'react'
import { useStore } from '@/app/components/app/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { BlockEnum } from '@/app/components/workflow/types'
import MessageLogModal from '.'
@ -94,12 +94,24 @@ const mockCurrentLogItem: IChatItem = {
workflow_run_id: 'run-demo-1',
}
const useMessageLogMocks = () => {
useEffect(() => {
const store = useStore.getState()
store.setAppDetail(SAMPLE_APP_DETAIL)
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
})
client.setQueryData(['apps', 'detail', 'app-demo-1'], SAMPLE_APP_DETAIL)
return client
}
const originalFetch = globalThis.fetch?.bind(globalThis) ?? null
const useMessageLogMocks = () => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
useEffect(() => {
originalFetchRef.current = globalThis.fetch?.bind(globalThis) ?? null
const handle = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
@ -122,8 +134,8 @@ const useMessageLogMocks = () => {
)
}
if (originalFetch)
return originalFetch(input, init)
if (originalFetchRef.current)
return originalFetchRef.current(input, init)
throw new Error(`Unmocked fetch call for ${url}`)
}
@ -131,8 +143,8 @@ const useMessageLogMocks = () => {
globalThis.fetch = handle as typeof globalThis.fetch
return () => {
globalThis.fetch = originalFetch || globalThis.fetch
useStore.getState().setAppDetail(undefined)
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
}
}, [])
}
@ -140,17 +152,21 @@ const useMessageLogMocks = () => {
type MessageLogModalProps = React.ComponentProps<typeof MessageLogModal>
const MessageLogPreview = (props: MessageLogModalProps) => {
const [queryClient] = useState(() => createQueryClient())
useMessageLogMocks()
return (
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
<QueryClientProvider client={queryClient}>
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
</QueryClientProvider>
)
}

View File

@ -2,10 +2,11 @@ import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import Run from '@/app/components/workflow/run'
import { useAppDetail } from '@/service/use-apps'
import { cn } from '@/utils/classnames'
type MessageLogModalProps = {
@ -25,7 +26,8 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
const { t } = useTranslation()
const ref = useRef(null)
const [mounted, setMounted] = useState(false)
const appDetail = useStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
useClickAway(() => {
if (mounted)

View File

@ -1,17 +1,17 @@
'use client'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import ApiServer from '@/app/components/develop/ApiServer'
import Doc from '@/app/components/develop/doc'
import { useAppDetail } from '@/service/use-apps'
type IDevelopMainProps = {
appId: string
}
const DevelopMain = ({ appId }: IDevelopMainProps) => {
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail, isPending } = useAppDetail(appId)
if (!appDetail) {
if (isPending || !appDetail) {
return (
<div className="flex h-full items-center justify-center bg-background-default">
<Loading />

View File

@ -6,16 +6,14 @@ import {
RiRobot2Line,
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { useInfiniteAppList } from '@/service/use-apps'
import { useAppDetail, useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import Nav from '../nav'
@ -23,11 +21,10 @@ const AppNav = () => {
const { t } = useTranslation()
const { appId } = useParams()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId as string)
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [navItems, setNavItems] = useState<NavItem[]>([])
const {
data: appsData,
@ -55,48 +52,34 @@ const AppNav = () => {
setShowCreateFromDSLModal(true)
}
useEffect(() => {
if (appsData) {
const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
const navItems = appItems.map((app) => {
const link = ((isCurrentWorkspaceEditor, app) => {
if (!isCurrentWorkspaceEditor) {
return `/app/${app.id}/overview`
}
else {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
return `/app/${app.id}/workflow`
else
return `/app/${app.id}/configuration`
}
})(isCurrentWorkspaceEditor, app)
return {
id: app.id,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
name: app.name,
mode: app.mode,
link,
}
})
setNavItems(navItems as any)
}
}, [appsData, isCurrentWorkspaceEditor, setNavItems])
const navItems = useMemo(() => {
if (!appsData)
return []
// update current app name
useEffect(() => {
if (appDetail) {
const newNavItems = produce(navItems, (draft: NavItem[]) => {
navItems.forEach((app, index) => {
if (app.id === appDetail.id)
draft[index].name = appDetail.name
})
})
setNavItems(newNavItems)
}
}, [appDetail, navItems])
const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
return appItems.map((app) => {
const link = (() => {
if (!isCurrentWorkspaceEditor)
return `/app/${app.id}/overview`
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
return `/app/${app.id}/workflow`
return `/app/${app.id}/configuration`
})()
return {
id: app.id,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
name: app.id === appDetail?.id ? appDetail.name : app.name,
mode: app.mode,
link,
}
}) as NavItem[]
}, [appsData, isCurrentWorkspaceEditor, appDetail])
return (
<>

View File

@ -5,7 +5,6 @@ import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import { cn } from '@/utils/classnames'
import NavSelector from './nav-selector'
@ -33,7 +32,6 @@ const Nav = ({
isLoadingMore,
isApp,
}: INavProps) => {
const setAppDetail = useAppStore(state => state.setAppDetail)
const [hovered, setHovered] = useState(false)
const segment = useSelectedLayoutSegment()
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
@ -47,12 +45,6 @@ const Nav = ({
>
<Link href={link}>
<div
onClick={(e) => {
// Don't clear state if opening in new tab/window
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
return
setAppDetail()
}}
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}

View File

@ -10,7 +10,6 @@ import { debounce } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { Fragment, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
@ -42,7 +41,6 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
const { t } = useTranslation()
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const setAppDetail = useAppStore(state => state.setAppDetail)
const handleScroll = useCallback(debounce((e) => {
if (typeof onLoadMore === 'function') {
@ -84,7 +82,6 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
onClick={() => {
if (curNav?.id === nav.id)
return
setAppDetail()
router.push(nav.link)
}}
title={nav.name}

View File

@ -6,6 +6,7 @@ import type {
} from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { RiApps2AddLine } from '@remixicon/react'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@ -14,7 +15,6 @@ import {
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import AppPublisher from '@/app/components/app/app-publisher'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import { useFeatures } from '@/app/components/base/features/hooks'
import { useToastContext } from '@/app/components/base/toast'
@ -39,7 +39,7 @@ import {
} from '@/app/components/workflow/types'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { fetchAppDetail } from '@/service/apps'
import { useInvalidateAppDetail } from '@/service/use-apps'
import { useInvalidateAppTriggers } from '@/service/use-tools'
import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { cn } from '@/utils/classnames'
@ -49,9 +49,9 @@ const FeaturesTrigger = () => {
const { theme } = useTheme()
const isChatMode = useIsChatMode()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appID = appDetail?.id
const setAppDetail = useAppStore(s => s.setAppDetail)
const { appId } = useParams()
const appID = appId as string
const invalidateAppDetail = useInvalidateAppDetail()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { plan, isFetchedPlan } = useProviderContext()
const publishedAt = useStore(s => s.publishedAt)
@ -125,15 +125,9 @@ const FeaturesTrigger = () => {
setShowFeaturesPanel(!showFeaturesPanel)
}, [workflowStore, getNodesReadOnly])
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appID! })
setAppDetail({ ...res })
}
catch (error) {
console.error(error)
}
}, [appID, setAppDetail])
const updateAppDetail = useCallback(() => {
invalidateAppDetail(appID)
}, [appID, invalidateAppDetail])
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
// const { validateBeforeRun } = useWorkflowRunValidation()

View File

@ -1,23 +1,23 @@
import type { HeaderProps } from '@/app/components/workflow/header'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Header from '@/app/components/workflow/header'
import { useAppDetail } from '@/service/use-apps'
import { useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useIsChatMode } from '../../hooks'
import ChatVariableTrigger from './chat-variable-trigger'
import FeaturesTrigger from './features-trigger'
const WorkflowHeader = () => {
const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const setCurrentLogItem = useAppStore(state => state.setCurrentLogItem)
const setShowMessageLogModal = useAppStore(state => state.setShowMessageLogModal)
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const isChatMode = useIsChatMode()

View File

@ -1,5 +1,6 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
import {
memo,
useMemo,
@ -8,6 +9,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Panel from '@/app/components/workflow/panel'
import { useStore } from '@/app/components/workflow/store'
import { useAppDetail } from '@/service/use-apps'
import {
useIsChatMode,
} from '../hooks'
@ -104,16 +106,16 @@ const WorkflowPanelOnRight = () => {
)
}
const WorkflowPanel = () => {
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const versionHistoryPanelProps = useMemo(() => {
const appId = appDetail?.id
return {
getVersionListUrl: `/apps/${appId}/workflows`,
deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
latestVersionId: appDetail?.workflow?.id,
}
}, [appDetail?.id, appDetail?.workflow?.id])
}, [appId, appDetail?.workflow?.id])
const panelProps: PanelProps = useMemo(() => {
return {

View File

@ -1,15 +1,16 @@
import { useParams } from 'next/navigation'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useToastContext } from '@/app/components/base/toast'
import {
DSL_EXPORT_CHECK,
} from '@/app/components/workflow/constants'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { exportAppConfig } from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
@ -20,7 +21,8 @@ export const useDSL = () => {
const [exporting, setExporting] = useState(false)
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const handleExportDSL = useCallback(async (include = false, workflowId?: string) => {
if (!appDetail)

View File

@ -1,8 +1,10 @@
import { useStore as useAppStore } from '@/app/components/app/store'
import { useParams } from 'next/navigation'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@ -1,17 +1,18 @@
import type { Edge, Node } from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useParams } from 'next/navigation'
import {
useCallback,
useEffect,
useState,
} from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppDetail } from '@/service/use-apps'
import { useWorkflowConfig } from '@/service/use-workflow'
import {
fetchNodesDefaultConfigs,
@ -38,13 +39,16 @@ export const useWorkflowInit = () => {
nodes: nodesTemplate,
edges: edgesTemplate,
} = useWorkflowTemplate()
const appDetail = useAppStore(state => state.appDetail)!
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const setSyncWorkflowDraftHash = useStore(s => s.setSyncWorkflowDraftHash)
const [data, setData] = useState<FetchWorkflowDraftResponse>()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
workflowStore.setState({ appId: appDetail.id, appName: appDetail.name })
}, [appDetail.id, workflowStore])
if (appDetail) {
workflowStore.setState({ appId: appDetail.id, appName: appDetail.name })
}
}, [appDetail, workflowStore])
const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => {
const { setFileUploadConfig } = workflowStore.getState()
@ -56,6 +60,8 @@ export const useWorkflowInit = () => {
} = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig)
const handleGetInitialWorkflowData = useCallback(async () => {
if (!appDetail)
return
try {
const res = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
setData(res)
@ -109,8 +115,9 @@ export const useWorkflowInit = () => {
}, [appDetail, nodesTemplate, edgesTemplate, workflowStore, setSyncWorkflowDraftHash])
useEffect(() => {
handleGetInitialWorkflowData()
}, [])
if (appDetail)
handleGetInitialWorkflowData()
}, [appDetail, handleGetInitialWorkflowData])
const handleFetchPreloadData = useCallback(async () => {
try {

View File

@ -4,14 +4,13 @@ import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import { usePathname } from 'next/navigation'
import { useParams, usePathname } from 'next/navigation'
import { useCallback, useRef } from 'react'
import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import { v4 as uuidV4 } from 'uuid'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
@ -23,6 +22,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { handleStream, post, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { useAppDetail } from '@/service/use-apps'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
@ -63,6 +63,8 @@ export const useWorkflowRun = () => {
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const pathname = usePathname()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const configsMap = useConfigsMap()
const { flowId, flowType } = configsMap
const invalidAllLastRun = useInvalidAllLastRun(flowType, flowId)
@ -180,7 +182,6 @@ export const useWorkflowRun = () => {
...restCallback
} = callback || {}
workflowStore.setState({ historyWorkflowData: undefined })
const appDetail = useAppStore.getState().appDetail
const workflowContainer = document.getElementById('workflow-container')
const {
@ -667,7 +668,7 @@ export const useWorkflowRun = () => {
},
},
)
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
}, [store, doSyncWorkflowDraft, workflowStore, pathname, appDetail, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {
@ -696,7 +697,6 @@ export const useWorkflowRun = () => {
}
if (taskId) {
const appId = useAppStore.getState().appDetail?.id
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
setStoppedState()
return
@ -725,7 +725,7 @@ export const useWorkflowRun = () => {
abortControllerRef.current = null
setStoppedState()
}, [workflowStore])
}, [workflowStore, appId])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))

View File

@ -2,12 +2,11 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import { useSearchParams } from 'next/navigation'
import { useParams, useSearchParams } from 'next/navigation'
import {
useEffect,
useMemo,
} from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
@ -26,6 +25,7 @@ import {
} from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { fetchRunDetail } from '@/service/log'
import { useAppDetail } from '@/service/use-apps'
import { useAppTriggers } from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
import WorkflowAppMain from './components/workflow-main'
@ -47,10 +47,10 @@ const WorkflowAppWithAdditionalContext = () => {
// Initialize trigger status at application level
const { setTriggerStatuses } = useTriggerStatusStore()
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW
const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, {
const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId as string : undefined, {
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
})

View File

@ -44,7 +44,6 @@ const ViewWorkflowHistory = () => {
const { nodesReadOnly } = useNodesReadOnly()
const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))

View File

@ -1,15 +1,15 @@
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { fetchWebhookUrl } from '@/service/apps'
export const useAutoGenerateWebhookUrl = () => {
const reactFlowStore = useStoreApi()
const { appId } = useParams()
return useCallback(async (nodeId: string) => {
const appId = useAppStore.getState().appDetail?.id
if (!appId)
return
@ -22,7 +22,7 @@ export const useAutoGenerateWebhookUrl = () => {
return
try {
const response = await fetchWebhookUrl({ appId, nodeId })
const response = await fetchWebhookUrl({ appId: appId as string, nodeId })
const { getNodes: getLatestNodes, setNodes } = reactFlowStore.getState()
let hasUpdated = false
const updatedNodes = produce(getLatestNodes(), (draft) => {
@ -44,5 +44,5 @@ export const useAutoGenerateWebhookUrl = () => {
catch (error: unknown) {
console.error('Failed to auto-generate webhook URL:', error)
}
}, [reactFlowStore])
}, [reactFlowStore, appId])
}

View File

@ -14,6 +14,7 @@ import type {
import type { Emoji } from '@/app/components/tools/types'
import type { DataSet } from '@/models/datasets'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { useParams } from 'next/navigation'
import {
useCallback,
useMemo,
@ -21,7 +22,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges, useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useToastContext } from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -29,6 +29,7 @@ import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { MAX_TREE_DEPTH } from '@/config'
import { useGetLanguage } from '@/context/i18n'
import { fetchDatasets } from '@/service/datasets'
import { useAppDetail } from '@/service/use-apps'
import { useStrategyProviders } from '@/service/use-strategy'
import {
useAllBuiltInTools,
@ -96,7 +97,9 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { data: triggerPlugins } = useAllTriggerPlugins()
const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail)
const getToolIcon = useGetToolIcon()
const appMode = useAppStore.getState().appDetail?.mode
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const appMode = appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const map = useNodesAvailableVarList(nodes)
@ -249,7 +252,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
})
return list
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, triggerPlugins, getCheckData, t, map, shouldCheckStartNode])
return needWarningNodes
}
@ -270,7 +273,9 @@ export const useChecklistBeforePublish = () => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const appMode = useAppStore.getState().appDetail?.mode
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const appMode = appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => {
@ -419,7 +424,7 @@ export const useChecklistBeforePublish = () => {
}
return true
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode])
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode, getNodesAvailableVarList])
return {
handleCheckBeforePublish,

View File

@ -10,6 +10,7 @@ import type {
ValueSelector,
} from '../types'
import { uniqBy } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import {
useCallback,
} from 'react'
@ -18,9 +19,9 @@ import {
getOutgoers,
useStoreApi,
} from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { useNodesMetaData } from '.'
import {
@ -43,7 +44,8 @@ import {
import { useAvailableBlocks } from './use-available-blocks'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@ -7,6 +7,7 @@ import {
RiPlayLargeLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import * as React from 'react'
import {
cloneElement,
@ -60,6 +61,7 @@ import {
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { useModalContext } from '@/context/modal-context'
import { useAppDetail } from '@/service/use-apps'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
@ -109,6 +111,8 @@ const BasePanel: FC<BasePanelProps> = ({
}) => {
const { t } = useTranslation()
const language = useLanguage()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const { showMessageLogModal } = useAppStore(useShallow(state => ({
showMessageLogModal: state.showMessageLogModal,
})))
@ -196,7 +200,6 @@ const BasePanel: FC<BasePanelProps> = ({
const isChildNode = !!(data.isInIteration || data.isInLoop)
const isSupportSingleRun = canRunBySingle(data.type, isChildNode)
const appDetail = useAppStore(state => state.appDetail)
const hasClickRunning = useRef(false)
const [isPaused, setIsPaused] = useState(false)

View File

@ -1,10 +1,10 @@
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import type { Variable } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
@ -17,7 +17,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const { t } = useTranslation()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
const appId = useAppStore.getState().appDetail?.id
const { appId } = useParams()
const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
const handleMethodChange = useCallback((method: HttpMethod) => {
@ -217,7 +217,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
try {
// Call backend to generate or fetch webhook url for this node
const response = await fetchWebhookUrl({ appId, nodeId: id })
const response = await fetchWebhookUrl({ appId: appId as string, nodeId: id })
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = response.webhook_url

View File

@ -1,18 +1,19 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { ChatItem, ChatItemInTree } from '@/app/components/base/chat/types'
import { RiCloseLine } from '@remixicon/react'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
useEffect,
useState,
} from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from '@/app/components/base/chat/chat'
import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import Loading from '@/app/components/base/loading'
import { fetchConversationMessages } from '@/service/debug'
import { useAppDetail } from '@/service/use-apps'
import { useWorkflowRun } from '../../hooks'
import {
useStore,
@ -51,7 +52,8 @@ const ChatRecord = () => {
const [fetched, setFetched] = useState(false)
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const workflowStore = useWorkflowStore()
const { handleLoadBackupDraft } = useWorkflowRun()
const historyWorkflowData = useStore(s => s.historyWorkflowData)

View File

@ -2,9 +2,9 @@ import type { StartNodeType } from '../../nodes/start/types'
import type { ChatWrapperRefType } from './index'
import type { ChatItem, OnSend } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { useParams } from 'next/navigation'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from '@/app/components/base/chat/chat'
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
import { useFeatures } from '@/app/components/base/features/hooks'
@ -14,6 +14,7 @@ import {
fetchSuggestedQuestions,
stopChatMessageResponding,
} from '@/service/debug'
import { useAppDetail } from '@/service/use-apps'
import {
useStore,
useWorkflowStore,
@ -45,7 +46,8 @@ const ChatWrapper = (
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = startNode?.data.variables
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const workflowStore = useWorkflowStore()
const inputs = useStore(s => s.inputs)
const setInputs = useStore(s => s.setInputs)

View File

@ -11,6 +11,7 @@ import {
RiFileDownloadLine,
} from '@remixicon/react'
import { load as yamlLoad } from 'js-yaml'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@ -20,7 +21,6 @@ import {
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
@ -35,6 +35,7 @@ import {
importDSL,
importDSLConfirm,
} from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { WORKFLOW_DATA_UPDATE } from './constants'
@ -60,7 +61,8 @@ const UpdateDSLModal = ({
}: UpdateDSLModalProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [currentFile, setDSLFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [loading, setLoading] = useState(false)

View File

@ -73,14 +73,6 @@
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
@ -701,9 +693,6 @@
"app/components/base/agent-log-modal/index.stories.tsx": {
"no-console": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/agent-log-modal/index.tsx": {
@ -2441,14 +2430,6 @@
"count": 4
}
},
"app/components/header/app-nav/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/header/dataset-nav/index.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 6

View File

@ -94,6 +94,17 @@ export const useAppDetail = (appID: string) => {
})
}
export const useInvalidateAppDetail = () => {
const queryClient = useQueryClient()
return (appId?: string) => {
if (!appId)
return
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'detail', appId],
})
}
}
export const useAppList = (params: AppListParams, options?: { enabled?: boolean }) => {
const normalizedParams = normalizeAppListParams(params)
return useQuery<AppListResponse>({