refactor(skill): move skill editor slice to core workflow store

Move SkillEditorSlice from injection pattern to core workflow store,
making it available to all workflow contexts (workflow-app, chatflow,
and future rag-pipeline).

- Add createSkillEditorSlice to core createWorkflowStore
- Remove complex type conversion logic from workflow-app/index.tsx
- Remove optional chaining (?.) and non-null assertions (!) from components
- Simplify slice composition with type assertions via unknown
This commit is contained in:
yyh
2026-01-16 13:22:10 +08:00
parent 7022e4b9ca
commit 7093962f30
10 changed files with 46 additions and 59 deletions

View File

@ -1,8 +1,7 @@
'use client' 'use client'
import type { WorkflowSliceShape } from './store/workflow/workflow-slice'
import type { Features as FeaturesData } from '@/app/components/base/features/types' import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { SkillEditorSliceShape } from '@/app/components/workflow/skill/store' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useQueryState } from 'nuqs' import { useQueryState } from 'nuqs'
import { import {
@ -19,7 +18,6 @@ import {
WorkflowContextProvider, WorkflowContextProvider,
} from '@/app/components/workflow/context' } from '@/app/components/workflow/context'
import SkillMain from '@/app/components/workflow/skill/main' import SkillMain from '@/app/components/workflow/skill/main'
import { createSkillEditorSlice } from '@/app/components/workflow/skill/store'
import { useWorkflowStore } from '@/app/components/workflow/store' import { useWorkflowStore } from '@/app/components/workflow/store'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { import {
@ -248,29 +246,10 @@ 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 = () => { const WorkflowAppWrapper = () => {
return ( return (
<WorkflowContextProvider <WorkflowContextProvider
injectWorkflowStoreSliceFn={injectWorkflowStoreSliceFn} injectWorkflowStoreSliceFn={createWorkflowSlice as InjectWorkflowStoreSliceFn}
> >
<WorkflowAppWithAdditionalContext /> <WorkflowAppWithAdditionalContext />
</WorkflowContextProvider> </WorkflowContextProvider>

View File

@ -12,26 +12,26 @@ import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
const EditorTabs: FC = () => { const EditorTabs: FC = () => {
const { t } = useTranslation('workflow') const { t } = useTranslation('workflow')
const openTabIds = useStore(s => s.openTabIds!) const openTabIds = useStore(s => s.openTabIds)
const activeTabId = useStore(s => s.activeTabId!) const activeTabId = useStore(s => s.activeTabId)
const previewTabId = useStore(s => s.previewTabId!) const previewTabId = useStore(s => s.previewTabId)
const dirtyContents = useStore(s => s.dirtyContents!) const dirtyContents = useStore(s => s.dirtyContents)
const storeApi = useWorkflowStore() const storeApi = useWorkflowStore()
const { data: nodeMap } = useSkillAssetNodeMap() const { data: nodeMap } = useSkillAssetNodeMap()
const [pendingCloseId, setPendingCloseId] = useState<string | null>(null) const [pendingCloseId, setPendingCloseId] = useState<string | null>(null)
const handleTabClick = useCallback((fileId: string) => { const handleTabClick = useCallback((fileId: string) => {
storeApi.getState().activateTab?.(fileId) storeApi.getState().activateTab(fileId)
}, [storeApi]) }, [storeApi])
const handleTabDoubleClick = useCallback((fileId: string) => { const handleTabDoubleClick = useCallback((fileId: string) => {
storeApi.getState().pinTab?.(fileId) storeApi.getState().pinTab(fileId)
}, [storeApi]) }, [storeApi])
const closeTab = useCallback((fileId: string) => { const closeTab = useCallback((fileId: string) => {
storeApi.getState().closeTab?.(fileId) storeApi.getState().closeTab(fileId)
storeApi.getState().clearDraftContent?.(fileId) storeApi.getState().clearDraftContent(fileId)
}, [storeApi]) }, [storeApi])
const handleTabClose = useCallback((fileId: string) => { const handleTabClose = useCallback((fileId: string) => {

View File

@ -49,8 +49,8 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
const { data: treeData, isLoading, error } = useSkillAssetTreeData() const { data: treeData, isLoading, error } = useSkillAssetTreeData()
const isMutating = useIsMutating() > 0 const isMutating = useIsMutating() > 0
const expandedFolderIds = useStore(s => s.expandedFolderIds!) const expandedFolderIds = useStore(s => s.expandedFolderIds)
const activeTabId = useStore(s => s.activeTabId!) const activeTabId = useStore(s => s.activeTabId)
const storeApi = useWorkflowStore() const storeApi = useWorkflowStore()
const renameNode = useRenameAppAssetNode() const renameNode = useRenameAppAssetNode()
@ -62,12 +62,12 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
}, [expandedFolderIds]) }, [expandedFolderIds])
const handleToggle = useCallback((id: string) => { const handleToggle = useCallback((id: string) => {
storeApi.getState().toggleFolder?.(id) storeApi.getState().toggleFolder(id)
}, [storeApi]) }, [storeApi])
const handleActivate = useCallback((node: NodeApi<TreeNodeData>) => { const handleActivate = useCallback((node: NodeApi<TreeNodeData>) => {
if (node.data.node_type === 'file') if (node.data.node_type === 'file')
storeApi.getState().openTab?.(node.data.id, { pinned: true }) storeApi.getState().openTab(node.data.id, { pinned: true })
else else
node.toggle() node.toggle()
}, [storeApi]) }, [storeApi])
@ -95,7 +95,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
const ancestors = getAncestorIds(activeTabId, treeData.children) const ancestors = getAncestorIds(activeTabId, treeData.children)
if (ancestors.length > 0) if (ancestors.length > 0)
storeApi.getState().revealFile?.(ancestors) storeApi.getState().revealFile(ancestors)
requestAnimationFrame(() => { requestAnimationFrame(() => {
const node = tree.get(activeTabId) const node = tree.get(activeTabId)
if (node) { if (node) {

View File

@ -22,7 +22,7 @@ const TreeContextMenu: FC<TreeContextMenuProps> = ({ treeRef }) => {
const { data: treeData } = useSkillAssetTreeData() const { data: treeData } = useSkillAssetTreeData()
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
storeApi.getState().setContextMenu?.(null) storeApi.getState().setContextMenu(null)
}, [storeApi]) }, [storeApi])
useClickAway(() => { useClickAway(() => {

View File

@ -26,7 +26,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
const { t } = useTranslation('workflow') const { t } = useTranslation('workflow')
const isFolder = node.data.node_type === 'folder' const isFolder = node.data.node_type === 'folder'
const isSelected = node.isSelected const isSelected = node.isSelected
const isDirty = useStore(s => s.dirtyContents?.has(node.data.id) ?? false) const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId) const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
const hasContextMenu = contextMenuNodeId === node.data.id const hasContextMenu = contextMenuNodeId === node.data.id
const storeApi = useWorkflowStore() const storeApi = useWorkflowStore()
@ -41,11 +41,11 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
) )
const openFilePreview = useCallback(() => { const openFilePreview = useCallback(() => {
storeApi.getState().openTab?.(node.data.id, { pinned: false }) storeApi.getState().openTab(node.data.id, { pinned: false })
}, [node.data.id, storeApi]) }, [node.data.id, storeApi])
const openFilePinned = useCallback(() => { const openFilePinned = useCallback(() => {
storeApi.getState().openTab?.(node.data.id, { pinned: true }) storeApi.getState().openTab(node.data.id, { pinned: true })
}, [node.data.id, storeApi]) }, [node.data.id, storeApi])
const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({ const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({
@ -79,7 +79,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
storeApi.getState().setContextMenu?.({ storeApi.getState().setContextMenu({
top: e.clientY, top: e.clientY,
left: e.clientX, left: e.clientX,
nodeId: node.data.id, nodeId: node.data.id,
@ -97,7 +97,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
if (isFolder) if (isFolder)
node.toggle() node.toggle()
else else
storeApi.getState().openTab?.(node.data.id, { pinned: true }) storeApi.getState().openTab(node.data.id, { pinned: true })
} }
}, [isFolder, node, storeApi]) }, [isFolder, node, storeApi])

View File

@ -250,14 +250,14 @@ export function useFileOperations({
await deleteNode.mutateAsync({ appId, nodeId }) await deleteNode.mutateAsync({ appId, nodeId })
descendantFileIds.forEach((fileId) => { descendantFileIds.forEach((fileId) => {
storeApi.getState().closeTab?.(fileId) storeApi.getState().closeTab(fileId)
storeApi.getState().clearDraftContent?.(fileId) storeApi.getState().clearDraftContent(fileId)
}) })
// Also close and clear the node itself if it's a file // Also close and clear the node itself if it's a file
if (!isFolder) { if (!isFolder) {
storeApi.getState().closeTab?.(nodeId) storeApi.getState().closeTab(nodeId)
storeApi.getState().clearDraftContent?.(nodeId) storeApi.getState().clearDraftContent(nodeId)
} }
Toast.notify({ Toast.notify({

View File

@ -55,7 +55,7 @@ const SidebarSearchAdd: FC = () => {
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
const { data: treeData } = useSkillAssetTreeData() const { data: treeData } = useSkillAssetTreeData()
const activeTabId = useStore(s => s.activeTabId!) const activeTabId = useStore(s => s.activeTabId)
const targetFolderId = useMemo(() => { const targetFolderId = useMemo(() => {
if (!treeData?.children) if (!treeData?.children)

View File

@ -34,8 +34,8 @@ const SkillDocEditor: FC = () => {
const appDetail = useAppStore(s => s.appDetail) const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || '' const appId = appDetail?.id || ''
const activeTabId = useStore(s => s.activeTabId!) const activeTabId = useStore(s => s.activeTabId)
const dirtyContents = useStore(s => s.dirtyContents!) const dirtyContents = useStore(s => s.dirtyContents)
const storeApi = useWorkflowStore() const storeApi = useWorkflowStore()
const { data: nodeMap } = useSkillAssetNodeMap() const { data: nodeMap } = useSkillAssetNodeMap()
@ -68,8 +68,8 @@ const SkillDocEditor: FC = () => {
const handleEditorChange = useCallback((value: string | undefined) => { const handleEditorChange = useCallback((value: string | undefined) => {
if (!activeTabId || !isEditable) if (!activeTabId || !isEditable)
return return
storeApi.getState().setDraftContent?.(activeTabId, value ?? '') storeApi.getState().setDraftContent(activeTabId, value ?? '')
storeApi.getState().pinTab?.(activeTabId) storeApi.getState().pinTab(activeTabId)
}, [activeTabId, isEditable, storeApi]) }, [activeTabId, isEditable, storeApi])
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
@ -86,7 +86,7 @@ const SkillDocEditor: FC = () => {
nodeId: activeTabId, nodeId: activeTabId,
payload: { content }, payload: { content },
}) })
storeApi.getState().clearDraftContent?.(activeTabId) storeApi.getState().clearDraftContent(activeTabId)
Toast.notify({ Toast.notify({
type: 'success', type: 'success',
message: t('api.saved', { ns: 'common' }), message: t('api.saved', { ns: 'common' }),

View File

@ -216,13 +216,19 @@ export type SkillEditorSliceShape
resetSkillEditor: () => void resetSkillEditor: () => void
} }
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape, [], [], SkillEditorSliceShape> = (set, get, store) => { export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (set, get, store) => {
const args = [set, get, store] as Parameters<StateCreator<SkillEditorSliceShape>> // Type assertion via unknown to allow composition with other slices in a larger store
// This is safe because all slice creators only use set/get for their own properties
const tabArgs = [set, get, store] as unknown as Parameters<StateCreator<TabSliceShape>>
const fileTreeArgs = [set, get, store] as unknown as Parameters<StateCreator<FileTreeSliceShape>>
const dirtyArgs = [set, get, store] as unknown as Parameters<StateCreator<DirtySliceShape>>
const menuArgs = [set, get, store] as unknown as Parameters<StateCreator<FileOperationsMenuSliceShape>>
return { return {
...createTabSlice(...args), ...createTabSlice(...tabArgs),
...createFileTreeSlice(...args), ...createFileTreeSlice(...fileTreeArgs),
...createDirtySlice(...args), ...createDirtySlice(...dirtyArgs),
...createFileOperationsMenuSlice(...args), ...createFileOperationsMenuSlice(...menuArgs),
resetSkillEditor: () => { resetSkillEditor: () => {
set({ set({

View File

@ -23,6 +23,7 @@ import {
} from 'zustand' } from 'zustand'
import { createStore } from 'zustand/vanilla' import { createStore } from 'zustand/vanilla'
import { WorkflowContext } from '@/app/components/workflow/context' import { WorkflowContext } from '@/app/components/workflow/context'
import { createSkillEditorSlice } from '@/app/components/workflow/skill/store'
import { createChatVariableSlice } from './chat-variable-slice' import { createChatVariableSlice } from './chat-variable-slice'
import { createInspectVarsSlice } from './debug/inspect-vars-slice' import { createInspectVarsSlice } from './debug/inspect-vars-slice'
import { createEnvVariableSlice } from './env-variable-slice' import { createEnvVariableSlice } from './env-variable-slice'
@ -41,7 +42,6 @@ import { createWorkflowSlice } from './workflow-slice'
export type SliceFromInjection export type SliceFromInjection
= Partial<WorkflowAppSliceShape> = Partial<WorkflowAppSliceShape>
& Partial<RagPipelineSliceShape> & Partial<RagPipelineSliceShape>
& Partial<SkillEditorSliceShape>
export type Shape export type Shape
= ChatVariableSliceShape = ChatVariableSliceShape
@ -57,6 +57,7 @@ export type Shape
& WorkflowSliceShape & WorkflowSliceShape
& InspectVarsSliceShape & InspectVarsSliceShape
& LayoutSliceShape & LayoutSliceShape
& SkillEditorSliceShape
& SliceFromInjection & SliceFromInjection
export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection> export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection>
@ -82,6 +83,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
...createWorkflowSlice(...args), ...createWorkflowSlice(...args),
...createInspectVarsSlice(...args), ...createInspectVarsSlice(...args),
...createLayoutSlice(...args), ...createLayoutSlice(...args),
...createSkillEditorSlice(...args),
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection), ...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
})) }))
} }