diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 4642ba514d..9a13576a67 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -1,7 +1,8 @@ 'use client' +import type { WorkflowSliceShape } from './store/workflow/workflow-slice' import type { Features as FeaturesData } from '@/app/components/base/features/types' -import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' +import type { SkillEditorSliceShape } from '@/app/components/workflow/skill/store' import { useSearchParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { @@ -17,8 +18,8 @@ import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider, } from '@/app/components/workflow/context' -import { SkillEditorProvider } from '@/app/components/workflow/skill/context' import SkillMain from '@/app/components/workflow/skill/main' +import { createSkillEditorSlice } from '@/app/components/workflow/skill/store' import { useWorkflowStore } from '@/app/components/workflow/store' import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' import { @@ -247,14 +248,31 @@ const WorkflowAppWithAdditionalContext = () => { ) } +type WorkflowAppInjectedSlice = WorkflowSliceShape & SkillEditorSliceShape +type SliceCreatorArgs = Parameters> + +const injectWorkflowStoreSliceFn: import('@/app/components/workflow/store').InjectWorkflowStoreSliceFn = ( + set, + get, + store, +) => { + const args: SliceCreatorArgs = [ + set as SliceCreatorArgs[0], + get as SliceCreatorArgs[1], + store as SliceCreatorArgs[2], + ] + return { + ...createWorkflowSlice(...args), + ...createSkillEditorSlice(...args), + } +} + const WorkflowAppWrapper = () => { return ( - - - + ) } diff --git a/web/app/components/workflow/skill/context.tsx b/web/app/components/workflow/skill/context.tsx deleted file mode 100644 index 7e9291717f..0000000000 --- a/web/app/components/workflow/skill/context.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client' - -import type { SkillEditorStore } from './store' -import { useRef } from 'react' -import { createSkillEditorStore, SkillEditorContext } from './store' - -type SkillEditorProviderProps = { - children: React.ReactNode -} - -export function SkillEditorProvider({ children }: SkillEditorProviderProps): React.ReactElement { - const storeRef = useRef(undefined) - - if (!storeRef.current) - storeRef.current = createSkillEditorStore() - - return ( - - {children} - - ) -} diff --git a/web/app/components/workflow/skill/editor-tabs.tsx b/web/app/components/workflow/skill/editor-tabs.tsx index 3e4bbbf699..90507d6c34 100644 --- a/web/app/components/workflow/skill/editor-tabs.tsx +++ b/web/app/components/workflow/skill/editor-tabs.tsx @@ -5,33 +5,33 @@ import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Confirm from '@/app/components/base/confirm' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import EditorTabItem from './editor-tab-item' import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree' -import { useSkillEditorStore, useSkillEditorStoreApi } from './store' const EditorTabs: FC = () => { const { t } = useTranslation('workflow') - const openTabIds = useSkillEditorStore(s => s.openTabIds) - const activeTabId = useSkillEditorStore(s => s.activeTabId) - const previewTabId = useSkillEditorStore(s => s.previewTabId) - const dirtyContents = useSkillEditorStore(s => s.dirtyContents) - const storeApi = useSkillEditorStoreApi() + const openTabIds = useStore(s => s.openTabIds!) + const activeTabId = useStore(s => s.activeTabId!) + const previewTabId = useStore(s => s.previewTabId!) + const dirtyContents = useStore(s => s.dirtyContents!) + const storeApi = useWorkflowStore() const { data: nodeMap } = useSkillAssetNodeMap() const [pendingCloseId, setPendingCloseId] = useState(null) const handleTabClick = useCallback((fileId: string) => { - storeApi.getState().activateTab(fileId) + storeApi.getState().activateTab?.(fileId) }, [storeApi]) const handleTabDoubleClick = useCallback((fileId: string) => { - storeApi.getState().pinTab(fileId) + storeApi.getState().pinTab?.(fileId) }, [storeApi]) const closeTab = useCallback((fileId: string) => { - storeApi.getState().closeTab(fileId) - storeApi.getState().clearDraftContent(fileId) + storeApi.getState().closeTab?.(fileId) + storeApi.getState().clearDraftContent?.(fileId) }, [storeApi]) const handleTabClose = useCallback((fileId: string) => { diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index df2d280fc9..053a8ab494 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -13,10 +13,10 @@ import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useRenameAppAssetNode } from '@/service/use-app-asset' import { cn } from '@/utils/classnames' import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' -import { useSkillEditorStore, useSkillEditorStoreApi } from '../store' import { getAncestorIds } from '../utils/tree-utils' import TreeContextMenu from './tree-context-menu' import TreeNode from './tree-node' @@ -49,9 +49,9 @@ const FileTree: React.FC = ({ className }) => { const { data: treeData, isLoading, error } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 - const expandedFolderIds = useSkillEditorStore(s => s.expandedFolderIds) - const activeTabId = useSkillEditorStore(s => s.activeTabId) - const storeApi = useSkillEditorStoreApi() + const expandedFolderIds = useStore(s => s.expandedFolderIds!) + const activeTabId = useStore(s => s.activeTabId!) + const storeApi = useWorkflowStore() const renameNode = useRenameAppAssetNode() @@ -62,12 +62,12 @@ const FileTree: React.FC = ({ className }) => { }, [expandedFolderIds]) const handleToggle = useCallback((id: string) => { - storeApi.getState().toggleFolder(id) + storeApi.getState().toggleFolder?.(id) }, [storeApi]) const handleActivate = useCallback((node: NodeApi) => { if (node.data.node_type === 'file') - storeApi.getState().openTab(node.data.id, { pinned: true }) + storeApi.getState().openTab?.(node.data.id, { pinned: true }) else node.toggle() }, [storeApi]) @@ -95,7 +95,7 @@ const FileTree: React.FC = ({ className }) => { const ancestors = getAncestorIds(activeTabId, treeData.children) if (ancestors.length > 0) - storeApi.getState().revealFile(ancestors) + storeApi.getState().revealFile?.(ancestors) requestAnimationFrame(() => { const node = tree.get(activeTabId) if (node) { diff --git a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx index 9389d0c1aa..9db7fd5725 100644 --- a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx @@ -6,8 +6,8 @@ import type { TreeNodeData } from '../type' import { useClickAway } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useRef } from 'react' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' -import { useSkillEditorStore, useSkillEditorStoreApi } from '../store' import { findNodeById } from '../utils/tree-utils' import NodeMenu from './node-menu' @@ -17,12 +17,12 @@ type TreeContextMenuProps = { const TreeContextMenu: FC = ({ treeRef }) => { const ref = useRef(null) - const contextMenu = useSkillEditorStore(s => s.contextMenu) - const storeApi = useSkillEditorStoreApi() + const contextMenu = useStore(s => s.contextMenu) + const storeApi = useWorkflowStore() const { data: treeData } = useSkillAssetTreeData() const handleClose = useCallback(() => { - storeApi.getState().setContextMenu(null) + storeApi.getState().setContextMenu?.(null) }, [storeApi]) useClickAway(() => { diff --git a/web/app/components/workflow/skill/file-tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree-node.tsx index 9cb44bf91a..422ab0c6e6 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -14,9 +14,9 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { useDelayedClick } from '../hooks/use-delayed-click' -import { useSkillEditorStore, useSkillEditorStoreApi } from '../store' import { getFileIconType } from '../utils/file-utils' import NodeMenu from './node-menu' import TreeEditInput from './tree-edit-input' @@ -26,10 +26,10 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) const { t } = useTranslation('workflow') const isFolder = node.data.node_type === 'folder' const isSelected = node.isSelected - const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id)) - const contextMenuNodeId = useSkillEditorStore(s => s.contextMenu?.nodeId) + const isDirty = useStore(s => s.dirtyContents?.has(node.data.id) ?? false) + const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId) const hasContextMenu = contextMenuNodeId === node.data.id - const storeApi = useSkillEditorStoreApi() + const storeApi = useWorkflowStore() const [showDropdown, setShowDropdown] = useState(false) @@ -41,11 +41,11 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) ) const openFilePreview = useCallback(() => { - storeApi.getState().openTab(node.data.id, { pinned: false }) + storeApi.getState().openTab?.(node.data.id, { pinned: false }) }, [node.data.id, storeApi]) const openFilePinned = useCallback(() => { - storeApi.getState().openTab(node.data.id, { pinned: true }) + storeApi.getState().openTab?.(node.data.id, { pinned: true }) }, [node.data.id, storeApi]) const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({ @@ -79,7 +79,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) e.preventDefault() e.stopPropagation() - storeApi.getState().setContextMenu({ + storeApi.getState().setContextMenu?.({ top: e.clientY, left: e.clientX, nodeId: node.data.id, @@ -97,7 +97,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) if (isFolder) node.toggle() else - storeApi.getState().openTab(node.data.id, { pinned: true }) + storeApi.getState().openTab?.(node.data.id, { pinned: true }) } }, [isFolder, node, storeApi]) diff --git a/web/app/components/workflow/skill/hooks/use-file-operations.ts b/web/app/components/workflow/skill/hooks/use-file-operations.ts index 99b81b5396..f0593437ad 100644 --- a/web/app/components/workflow/skill/hooks/use-file-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-file-operations.ts @@ -6,12 +6,12 @@ import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Toast from '@/app/components/base/toast' +import { useWorkflowStore } from '@/app/components/workflow/store' import { useCreateAppAssetFile, useCreateAppAssetFolder, useDeleteAppAssetNode, } from '@/service/use-app-asset' -import { useSkillEditorStoreApi } from '../store' import { getAllDescendantFileIds } from '../utils/tree-utils' import { useSkillAssetTreeData } from './use-skill-asset-tree' @@ -36,7 +36,7 @@ export function useFileOperations({ const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' - const storeApi = useSkillEditorStoreApi() + const storeApi = useWorkflowStore() const createFolder = useCreateAppAssetFolder() const createFile = useCreateAppAssetFile() @@ -250,14 +250,14 @@ export function useFileOperations({ await deleteNode.mutateAsync({ appId, nodeId }) descendantFileIds.forEach((fileId) => { - storeApi.getState().closeTab(fileId) - storeApi.getState().clearDraftContent(fileId) + storeApi.getState().closeTab?.(fileId) + storeApi.getState().clearDraftContent?.(fileId) }) // Also close and clear the node itself if it's a file if (!isFolder) { - storeApi.getState().closeTab(nodeId) - storeApi.getState().clearDraftContent(nodeId) + storeApi.getState().closeTab?.(nodeId) + storeApi.getState().clearDraftContent?.(nodeId) } Toast.notify({ diff --git a/web/app/components/workflow/skill/sidebar-search-add.tsx b/web/app/components/workflow/skill/sidebar-search-add.tsx index ae02b23c75..365fe91dba 100644 --- a/web/app/components/workflow/skill/sidebar-search-add.tsx +++ b/web/app/components/workflow/skill/sidebar-search-add.tsx @@ -18,10 +18,10 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import SearchInput from '@/app/components/base/search-input' +import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { useFileOperations } from './hooks/use-file-operations' import { useSkillAssetTreeData } from './hooks/use-skill-asset-tree' -import { useSkillEditorStore } from './store' import { getTargetFolderIdFromSelection } from './utils/tree-utils' type MenuItemProps = { @@ -55,7 +55,7 @@ const SidebarSearchAdd: FC = () => { const [showMenu, setShowMenu] = useState(false) const { data: treeData } = useSkillAssetTreeData() - const activeTabId = useSkillEditorStore(s => s.activeTabId) + const activeTabId = useStore(s => s.activeTabId!) const targetFolderId = useMemo(() => { if (!treeData?.children) diff --git a/web/app/components/workflow/skill/skill-doc-editor.tsx b/web/app/components/workflow/skill/skill-doc-editor.tsx index 62c21ac746..e7b954cded 100644 --- a/web/app/components/workflow/skill/skill-doc-editor.tsx +++ b/web/app/components/workflow/skill/skill-doc-editor.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' import { useGetAppAssetFileContent, useUpdateAppAssetFileContent } from '@/service/use-app-asset' import { Theme } from '@/types/app' @@ -19,7 +20,6 @@ import MediaFilePreview from './editor/media-file-preview' import OfficeFilePlaceholder from './editor/office-file-placeholder' import UnsupportedFileDownload from './editor/unsupported-file-download' import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree' -import { useSkillEditorStore, useSkillEditorStoreApi } from './store' import { getFileExtension, getFileLanguage, isCodeOrTextFile, isImageFile, isMarkdownFile, isOfficeFile, isVideoFile } from './utils/file-utils' if (typeof window !== 'undefined') @@ -34,9 +34,9 @@ const SkillDocEditor: FC = () => { const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' - const activeTabId = useSkillEditorStore(s => s.activeTabId) - const dirtyContents = useSkillEditorStore(s => s.dirtyContents) - const storeApi = useSkillEditorStoreApi() + const activeTabId = useStore(s => s.activeTabId!) + const dirtyContents = useStore(s => s.dirtyContents!) + const storeApi = useWorkflowStore() const { data: nodeMap } = useSkillAssetNodeMap() const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined @@ -68,8 +68,8 @@ const SkillDocEditor: FC = () => { const handleEditorChange = useCallback((value: string | undefined) => { if (!activeTabId || !isEditable) return - storeApi.getState().setDraftContent(activeTabId, value ?? '') - storeApi.getState().pinTab(activeTabId) + storeApi.getState().setDraftContent?.(activeTabId, value ?? '') + storeApi.getState().pinTab?.(activeTabId) }, [activeTabId, isEditable, storeApi]) const handleSave = useCallback(async () => { @@ -86,7 +86,7 @@ const SkillDocEditor: FC = () => { nodeId: activeTabId, payload: { content }, }) - storeApi.getState().clearDraftContent(activeTabId) + storeApi.getState().clearDraftContent?.(activeTabId) Toast.notify({ type: 'success', message: t('api.saved', { ns: 'common' }), diff --git a/web/app/components/workflow/skill/store/index.ts b/web/app/components/workflow/skill/store/index.ts index 47b9367830..7eaf67768c 100644 --- a/web/app/components/workflow/skill/store/index.ts +++ b/web/app/components/workflow/skill/store/index.ts @@ -1,8 +1,4 @@ -import type { StateCreator, StoreApi } from 'zustand' -import * as React from 'react' -import { useContext } from 'react' -import { useStore as useZustandStore } from 'zustand' -import { createStore } from 'zustand/vanilla' +import type { StateCreator } from 'zustand' export type OpenTabOptions = { /** true = Pinned (permanent), false/undefined = Preview (temporary) */ @@ -211,24 +207,24 @@ export const createFileOperationsMenuSlice: StateCreator void + resetSkillEditor: () => void } -export const createSkillEditorStore = (): StoreApi => { - return createStore((...args) => ({ +export const createSkillEditorSlice: StateCreator = (set, get, store) => { + const args = [set, get, store] as Parameters> + return { ...createTabSlice(...args), ...createFileTreeSlice(...args), ...createDirtySlice(...args), ...createFileOperationsMenuSlice(...args), - reset: () => { - const [set] = args + resetSkillEditor: () => { set({ openTabIds: [], activeTabId: null, @@ -238,25 +234,5 @@ export const createSkillEditorStore = (): StoreApi => { contextMenu: null, }) }, - })) -} - -export type SkillEditorStore = StoreApi - -export const SkillEditorContext = React.createContext(null) - -export function useSkillEditorStore(selector: (state: SkillEditorShape) => T): T { - const store = useContext(SkillEditorContext) - if (!store) - throw new Error('Missing SkillEditorContext.Provider in the tree') - - return useZustandStore(store, selector) -} - -export const useSkillEditorStoreApi = (): SkillEditorStore => { - const store = useContext(SkillEditorContext) - if (!store) - throw new Error('Missing SkillEditorContext.Provider in the tree') - - return store + } } diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index c2c0c00201..b208792f6e 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -16,6 +16,7 @@ import type { WorkflowDraftSliceShape } from './workflow-draft-slice' import type { WorkflowSliceShape } from './workflow-slice' import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store' import type { WorkflowSliceShape as WorkflowAppSliceShape } from '@/app/components/workflow-app/store/workflow/workflow-slice' +import type { SkillEditorSliceShape } from '@/app/components/workflow/skill/store' import { useContext } from 'react' import { useStore as useZustandStore, @@ -40,6 +41,7 @@ import { createWorkflowSlice } from './workflow-slice' export type SliceFromInjection = Partial & Partial + & Partial export type Shape = ChatVariableSliceShape