From 7093962f301d06afee8788b05ef514fc3bc079cd Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 16 Jan 2026 13:22:10 +0800 Subject: [PATCH] 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 --- web/app/components/workflow-app/index.tsx | 25 ++----------------- .../components/workflow/skill/editor-tabs.tsx | 16 ++++++------ .../workflow/skill/file-tree/index.tsx | 10 ++++---- .../skill/file-tree/tree-context-menu.tsx | 2 +- .../workflow/skill/file-tree/tree-node.tsx | 10 ++++---- .../skill/hooks/use-file-operations.ts | 8 +++--- .../workflow/skill/sidebar-search-add.tsx | 2 +- .../workflow/skill/skill-doc-editor.tsx | 10 ++++---- .../components/workflow/skill/store/index.ts | 18 ++++++++----- .../workflow/store/workflow/index.ts | 4 ++- 10 files changed, 46 insertions(+), 59 deletions(-) diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 9a13576a67..f98085bdc6 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -1,8 +1,7 @@ 'use client' -import type { WorkflowSliceShape } from './store/workflow/workflow-slice' 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 { useQueryState } from 'nuqs' import { @@ -19,7 +18,6 @@ import { WorkflowContextProvider, } from '@/app/components/workflow/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 { @@ -248,29 +246,10 @@ 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/editor-tabs.tsx b/web/app/components/workflow/skill/editor-tabs.tsx index 90507d6c34..37e036ae05 100644 --- a/web/app/components/workflow/skill/editor-tabs.tsx +++ b/web/app/components/workflow/skill/editor-tabs.tsx @@ -12,26 +12,26 @@ import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree' const EditorTabs: FC = () => { const { t } = useTranslation('workflow') - 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 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 053a8ab494..8c96eddaca 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -49,8 +49,8 @@ const FileTree: React.FC = ({ className }) => { const { data: treeData, isLoading, error } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 - const expandedFolderIds = useStore(s => s.expandedFolderIds!) - const activeTabId = useStore(s => s.activeTabId!) + 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 9db7fd5725..0de88de0f7 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 @@ -22,7 +22,7 @@ const TreeContextMenu: FC = ({ treeRef }) => { 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 422ab0c6e6..072b49553e 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -26,7 +26,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) const { t } = useTranslation('workflow') const isFolder = node.data.node_type === 'folder' 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 hasContextMenu = contextMenuNodeId === node.data.id const storeApi = useWorkflowStore() @@ -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 f0593437ad..d517dc8911 100644 --- a/web/app/components/workflow/skill/hooks/use-file-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-file-operations.ts @@ -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 365fe91dba..6064eadbf0 100644 --- a/web/app/components/workflow/skill/sidebar-search-add.tsx +++ b/web/app/components/workflow/skill/sidebar-search-add.tsx @@ -55,7 +55,7 @@ const SidebarSearchAdd: FC = () => { const [showMenu, setShowMenu] = useState(false) const { data: treeData } = useSkillAssetTreeData() - const activeTabId = useStore(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 0398721dbf..dae6799131 100644 --- a/web/app/components/workflow/skill/skill-doc-editor.tsx +++ b/web/app/components/workflow/skill/skill-doc-editor.tsx @@ -34,8 +34,8 @@ const SkillDocEditor: FC = () => { const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' - const activeTabId = useStore(s => s.activeTabId!) - const dirtyContents = useStore(s => s.dirtyContents!) + const activeTabId = useStore(s => s.activeTabId) + const dirtyContents = useStore(s => s.dirtyContents) const storeApi = useWorkflowStore() const { data: nodeMap } = useSkillAssetNodeMap() @@ -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 7eaf67768c..2314845711 100644 --- a/web/app/components/workflow/skill/store/index.ts +++ b/web/app/components/workflow/skill/store/index.ts @@ -216,13 +216,19 @@ export type SkillEditorSliceShape resetSkillEditor: () => void } -export const createSkillEditorSlice: StateCreator = (set, get, store) => { - const args = [set, get, store] as Parameters> +export const createSkillEditorSlice: StateCreator = (set, get, store) => { + // 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> + const fileTreeArgs = [set, get, store] as unknown as Parameters> + const dirtyArgs = [set, get, store] as unknown as Parameters> + const menuArgs = [set, get, store] as unknown as Parameters> + return { - ...createTabSlice(...args), - ...createFileTreeSlice(...args), - ...createDirtySlice(...args), - ...createFileOperationsMenuSlice(...args), + ...createTabSlice(...tabArgs), + ...createFileTreeSlice(...fileTreeArgs), + ...createDirtySlice(...dirtyArgs), + ...createFileOperationsMenuSlice(...menuArgs), resetSkillEditor: () => { set({ diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index b208792f6e..d9a368fd91 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -23,6 +23,7 @@ import { } from 'zustand' import { createStore } from 'zustand/vanilla' import { WorkflowContext } from '@/app/components/workflow/context' +import { createSkillEditorSlice } from '@/app/components/workflow/skill/store' import { createChatVariableSlice } from './chat-variable-slice' import { createInspectVarsSlice } from './debug/inspect-vars-slice' import { createEnvVariableSlice } from './env-variable-slice' @@ -41,7 +42,6 @@ import { createWorkflowSlice } from './workflow-slice' export type SliceFromInjection = Partial & Partial - & Partial export type Shape = ChatVariableSliceShape @@ -57,6 +57,7 @@ export type Shape & WorkflowSliceShape & InspectVarsSliceShape & LayoutSliceShape + & SkillEditorSliceShape & SliceFromInjection export type InjectWorkflowStoreSliceFn = StateCreator @@ -82,6 +83,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => { ...createWorkflowSlice(...args), ...createInspectVarsSlice(...args), ...createLayoutSlice(...args), + ...createSkillEditorSlice(...args), ...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection), })) }