refactor(skill): migrate skill editor store to workflow store slice injection

Refactor the skill editor state management from a standalone Zustand store
with Context provider pattern to a slice injection pattern that integrates
with the existing workflow store. This aligns with how rag-pipeline already
injects its slice.

- Remove SkillEditorProvider and SkillEditorContext
- Export createSkillEditorSlice for injection into workflow store
- Update all components to use useStore/useWorkflowStore from workflow store
- Add SkillEditorSliceShape to SliceFromInjection union type
- Use type-safe slice creator args without any types
This commit is contained in:
yyh
2026-01-16 12:53:13 +08:00
parent 106cb8e373
commit b8d67a42bd
11 changed files with 78 additions and 104 deletions

View File

@ -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<import('zustand').StateCreator<WorkflowAppInjectedSlice>>
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 (
<WorkflowContextProvider
injectWorkflowStoreSliceFn={createWorkflowSlice as InjectWorkflowStoreSliceFn}
injectWorkflowStoreSliceFn={injectWorkflowStoreSliceFn}
>
<SkillEditorProvider>
<WorkflowAppWithAdditionalContext />
</SkillEditorProvider>
<WorkflowAppWithAdditionalContext />
</WorkflowContextProvider>
)
}

View File

@ -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<SkillEditorStore | undefined>(undefined)
if (!storeRef.current)
storeRef.current = createSkillEditorStore()
return (
<SkillEditorContext.Provider value={storeRef.current}>
{children}
</SkillEditorContext.Provider>
)
}

View File

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

View File

@ -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<FileTreeProps> = ({ 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<FileTreeProps> = ({ className }) => {
}, [expandedFolderIds])
const handleToggle = useCallback((id: string) => {
storeApi.getState().toggleFolder(id)
storeApi.getState().toggleFolder?.(id)
}, [storeApi])
const handleActivate = useCallback((node: NodeApi<TreeNodeData>) => {
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<FileTreeProps> = ({ 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) {

View File

@ -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<TreeContextMenuProps> = ({ treeRef }) => {
const ref = useRef<HTMLDivElement>(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(() => {

View File

@ -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<TreeNodeData>)
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<TreeNodeData>)
)
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<TreeNodeData>)
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<TreeNodeData>)
if (isFolder)
node.toggle()
else
storeApi.getState().openTab(node.data.id, { pinned: true })
storeApi.getState().openTab?.(node.data.id, { pinned: true })
}
}, [isFolder, node, storeApi])

View File

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

View File

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

View File

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

View File

@ -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<FileOperationsMenuSlice
},
})
export type SkillEditorShape
export type SkillEditorSliceShape
= TabSliceShape
& FileTreeSliceShape
& DirtySliceShape
& FileOperationsMenuSliceShape
& {
reset: () => void
resetSkillEditor: () => void
}
export const createSkillEditorStore = (): StoreApi<SkillEditorShape> => {
return createStore<SkillEditorShape>((...args) => ({
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape, [], [], SkillEditorSliceShape> = (set, get, store) => {
const args = [set, get, store] as Parameters<StateCreator<SkillEditorSliceShape>>
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<SkillEditorShape> => {
contextMenu: null,
})
},
}))
}
export type SkillEditorStore = StoreApi<SkillEditorShape>
export const SkillEditorContext = React.createContext<SkillEditorStore | null>(null)
export function useSkillEditorStore<T>(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
}
}

View File

@ -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<WorkflowAppSliceShape>
& Partial<RagPipelineSliceShape>
& Partial<SkillEditorSliceShape>
export type Shape
= ChatVariableSliceShape