diff --git a/web/app/components/workflow/nodes/llm/__tests__/use-node-skills.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/use-node-skills.spec.tsx new file mode 100644 index 0000000000..e9ffcff515 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/__tests__/use-node-skills.spec.tsx @@ -0,0 +1,153 @@ +import type { ReactNode } from 'react' +import type { App, AppSSO } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useNodeSkills } from '../use-node-skills' + +type MockNode = { + id: string + data: Record +} + +const mocks = vi.hoisted(() => ({ + nodeSkills: vi.fn(), + nodeSkillsQueryKey: vi.fn((_input: unknown) => ['console', 'workflowDraft', 'nodeSkills']), + store: { + getState: vi.fn(), + }, + nodes: [] as MockNode[], +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + workflowDraft: { + nodeSkills: (input: unknown) => mocks.nodeSkills(input), + }, + }, + consoleQuery: { + workflowDraft: { + nodeSkills: { + queryKey: (input: unknown) => mocks.nodeSkillsQueryKey(input), + }, + }, + }, +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => mocks.store, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useNodeSkills', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.nodes = [ + { + id: 'node-1', + data: { + type: 'llm', + prompt_template: [{ text: 'first prompt', skill: true }], + }, + }, + ] + mocks.store.getState.mockImplementation(() => ({ + getNodes: () => mocks.nodes, + })) + mocks.nodeSkills.mockResolvedValue({ + tool_dependencies: [], + }) + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + }) + + it('should avoid refetching when the request key stays the same', async () => { + const { rerender } = renderHook( + ({ promptTemplateKey }) => useNodeSkills({ + nodeId: 'node-1', + promptTemplateKey, + }), + { + initialProps: { promptTemplateKey: 'prompt-key-1' }, + wrapper: createWrapper(), + }, + ) + + await waitFor(() => { + expect(mocks.nodeSkills).toHaveBeenCalledTimes(1) + }) + + mocks.nodes = [ + { + id: 'node-1', + data: { + type: 'llm', + prompt_template: [{ text: 'updated prompt', skill: true }], + }, + }, + ] + + rerender({ promptTemplateKey: 'prompt-key-1' }) + + await waitFor(() => { + expect(mocks.nodeSkills).toHaveBeenCalledTimes(1) + }) + }) + + it('should refetch with the latest node data when the request key changes', async () => { + const { rerender } = renderHook( + ({ promptTemplateKey }) => useNodeSkills({ + nodeId: 'node-1', + promptTemplateKey, + }), + { + initialProps: { promptTemplateKey: 'prompt-key-1' }, + wrapper: createWrapper(), + }, + ) + + await waitFor(() => { + expect(mocks.nodeSkills).toHaveBeenCalledTimes(1) + }) + + mocks.nodes = [ + { + id: 'node-1', + data: { + type: 'llm', + prompt_template: [{ text: 'updated prompt', skill: true }], + }, + }, + ] + + rerender({ promptTemplateKey: 'prompt-key-2' }) + + await waitFor(() => { + expect(mocks.nodeSkills).toHaveBeenCalledTimes(2) + }) + + expect(mocks.nodeSkills).toHaveBeenLastCalledWith({ + params: { appId: 'app-1' }, + body: { + type: 'llm', + prompt_template: [{ text: 'updated prompt', skill: true }], + }, + }) + }) +}) diff --git a/web/app/components/workflow/nodes/llm/components/computer-use-config.tsx b/web/app/components/workflow/nodes/llm/components/computer-use-config.tsx index a929d7a2ee..fe6f546f4a 100644 --- a/web/app/components/workflow/nodes/llm/components/computer-use-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/computer-use-config.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { ToolSetting } from '../types' +import type { ToolDependency } from '../use-node-skills' import * as React from 'react' import { useTranslation } from 'react-i18next' import Switch from '@/app/components/base/switch' @@ -19,7 +20,10 @@ type Props = { onChange: (enabled: boolean) => void nodeId: string toolSettings?: ToolSetting[] - promptTemplateKey: string + toolDependencies: ToolDependency[] + isNodeSkillsLoading: boolean + isNodeSkillsQueryEnabled: boolean + hasNodeSkillsData: boolean } const ComputerUseConfig: FC = ({ @@ -30,7 +34,10 @@ const ComputerUseConfig: FC = ({ onChange, nodeId, toolSettings, - promptTemplateKey, + toolDependencies, + isNodeSkillsLoading, + isNodeSkillsQueryEnabled, + hasNodeSkillsData, }) => { const { t } = useTranslation() const isDisabled = readonly || isDisabledByStructuredOutput @@ -89,7 +96,10 @@ const ComputerUseConfig: FC = ({ isComputerUseEnabled={enabled} nodeId={nodeId} toolSettings={toolSettings} - promptTemplateKey={promptTemplateKey} + toolDependencies={toolDependencies} + isNodeSkillsLoading={isNodeSkillsLoading} + isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled} + hasNodeSkillsData={hasNodeSkillsData} /> diff --git a/web/app/components/workflow/nodes/llm/components/reference-tool-config.tsx b/web/app/components/workflow/nodes/llm/components/reference-tool-config.tsx index 470fe6f372..c01d52c7c3 100644 --- a/web/app/components/workflow/nodes/llm/components/reference-tool-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/reference-tool-config.tsx @@ -13,7 +13,6 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import Switch from '@/app/components/base/switch' import { useNodeCurdKit } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { useNodeSkills } from '@/app/components/workflow/nodes/llm/use-node-skills' import useTheme from '@/hooks/use-theme' import { getLanguage } from '@/i18n-config/language' import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' @@ -26,7 +25,10 @@ type ReferenceToolConfigProps = { isComputerUseEnabled: boolean nodeId: string toolSettings?: ToolSetting[] - promptTemplateKey: string + toolDependencies: ToolDependency[] + isNodeSkillsLoading: boolean + isNodeSkillsQueryEnabled: boolean + hasNodeSkillsData: boolean } type ToolProviderGroup = { @@ -40,7 +42,10 @@ const ReferenceToolConfig: FC = ({ isComputerUseEnabled, nodeId, toolSettings, - promptTemplateKey, + toolDependencies, + isNodeSkillsLoading, + isNodeSkillsQueryEnabled, + hasNodeSkillsData, }) => { const isReferenceToolsDisabled = readonly || !isComputerUseEnabled || isDisabledByStructuredOutput const { i18n, t } = useTranslation() @@ -52,11 +57,6 @@ const ReferenceToolConfig: FC = ({ const { data: mcpTools } = useAllMCPTools() const locale = useMemo(() => getLanguage(i18n.language as Locale), [i18n.language]) - const { toolDependencies, isLoading, isQueryEnabled, hasData } = useNodeSkills({ - nodeId, - promptTemplateKey, - }) - const providers = useMemo(() => { const map = new Map() toolDependencies.forEach((tool) => { @@ -185,7 +185,7 @@ const ReferenceToolConfig: FC = ({ })) }, []) - const isInitialLoading = isQueryEnabled && isLoading && !hasData + const isInitialLoading = isNodeSkillsQueryEnabled && isNodeSkillsLoading && !hasNodeSkillsData const showNoData = !isInitialLoading && providers.length === 0 const renderProviderIcon = useCallback((providerId: string) => { diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 045f0e7106..5d4078822b 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -2,9 +2,8 @@ import type { FC } from 'react' import type { LLMNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' import { RiAlertFill, RiInformationLine, RiQuestionLine } from '@remixicon/react' -import { useDebounceFn } from 'ahooks' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import AddButton2 from '@/app/components/base/button/add-button' import Switch from '@/app/components/base/switch' @@ -36,6 +35,7 @@ import { useStructuredOutputMutualExclusion } from './use-structured-output-mutu import { getLLMModelIssue, LLMModelIssueCode } from './utils' const i18nPrefix = 'nodes.llm' +const SKILL_DEPENDENCY_DEBOUNCE_MS = 800 const Panel: FC> = ({ id, @@ -89,14 +89,29 @@ const Panel: FC> = ({ } }, [inputs.prompt_template]) const [skillsRefreshKey, setSkillsRefreshKey] = React.useState(promptTemplateKey) - const { run: scheduleSkillsRefresh } = useDebounceFn((nextKey: string) => { - setSkillsRefreshKey(nextKey) - }, { wait: 3000 }) - const handlePromptEditorBlur = useCallback(() => { - scheduleSkillsRefresh(promptTemplateKey) - }, [promptTemplateKey, scheduleSkillsRefresh]) + useEffect(() => { + if (skillsRefreshKey === promptTemplateKey) + return - const { toolDependencies } = useNodeSkills({ + const timerId = window.setTimeout(() => { + setSkillsRefreshKey(promptTemplateKey) + }, SKILL_DEPENDENCY_DEBOUNCE_MS) + + return () => { + window.clearTimeout(timerId) + } + }, [promptTemplateKey, skillsRefreshKey]) + + const handlePromptEditorBlur = useCallback(() => { + setSkillsRefreshKey(promptTemplateKey) + }, [promptTemplateKey]) + + const { + toolDependencies, + isLoading: isNodeSkillsLoading, + isQueryEnabled: isNodeSkillsQueryEnabled, + hasData: hasNodeSkillsData, + } = useNodeSkills({ nodeId: id, promptTemplateKey: skillsRefreshKey, enabled: isSupportSandbox, @@ -303,7 +318,10 @@ const Panel: FC> = ({ onChange={handleComputerUseChange} nodeId={id} toolSettings={inputs.tool_settings} - promptTemplateKey={skillsRefreshKey} + toolDependencies={toolDependencies} + isNodeSkillsLoading={isNodeSkillsLoading} + isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled} + hasNodeSkillsData={hasNodeSkillsData} /> )} diff --git a/web/app/components/workflow/nodes/llm/use-node-skills.ts b/web/app/components/workflow/nodes/llm/use-node-skills.ts index cbb656786a..e51a2e34e0 100644 --- a/web/app/components/workflow/nodes/llm/use-node-skills.ts +++ b/web/app/components/workflow/nodes/llm/use-node-skills.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' +import { useStoreApi } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' import { consoleClient, consoleQuery } from '@/service/client' @@ -21,7 +21,6 @@ type UseNodeSkillsParams = { export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: UseNodeSkillsParams) { const appId = useAppStore(s => s.appDetail?.id) const store = useStoreApi() - const nodeData = useReactFlowStore(s => s.getNodes().find(n => n.id === nodeId)?.data) const isQueryEnabled = enabled && !!appId && !!nodeId const queryKey = useMemo(() => { @@ -34,10 +33,9 @@ export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: Use }), nodeId, promptTemplateKey, - nodeData, store, ] - }, [appId, nodeId, promptTemplateKey, nodeData, store]) + }, [appId, nodeId, promptTemplateKey, store]) const { data, isLoading } = useQuery({ queryKey, diff --git a/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx new file mode 100644 index 0000000000..d1203b2aed --- /dev/null +++ b/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx @@ -0,0 +1,146 @@ +import type { App, AppSSO } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useSkillBatchUpload } from '../use-skill-batch-upload' + +type MockWorkflowState = { + setUploadStatus: ReturnType + setUploadProgress: ReturnType + openTab: ReturnType +} + +const mocks = vi.hoisted(() => ({ + mutateAsync: vi.fn(), + emitTreeUpdate: vi.fn(), + workflowState: { + setUploadStatus: vi.fn(), + setUploadProgress: vi.fn(), + openTab: vi.fn(), + } as MockWorkflowState, +})) + +vi.mock('@/service/use-app-asset', () => ({ + useBatchUpload: () => ({ + mutateAsync: mocks.mutateAsync, + }), +})) + +vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => mocks.workflowState, + }), +})) + +describe('useSkillBatchUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.mutateAsync.mockResolvedValue([]) + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + }) + + describe('Upload State', () => { + it('should mark upload as in progress with the provided total', () => { + const { result } = renderHook(() => useSkillBatchUpload()) + + act(() => { + result.current.startUpload(3) + }) + + expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('uploading') + expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 3, failed: 0 }) + }) + + it('should update upload progress through the shared helper', () => { + const { result } = renderHook(() => useSkillBatchUpload()) + + act(() => { + result.current.setUploadProgress(2, 5) + }) + + expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 2, total: 5, failed: 0 }) + }) + }) + + describe('Tree Upload', () => { + it('should upload the tree and broadcast success when the batch upload succeeds', async () => { + mocks.mutateAsync.mockResolvedValueOnce([ + { id: 'folder-id', name: 'alpha', node_type: 'folder', size: 0, children: [] }, + ]) + const { result } = renderHook(() => useSkillBatchUpload()) + const files = new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]]) + const tree = [{ name: 'alpha', node_type: 'folder' as const, children: [] }] + + let uploadedNodes: unknown + await act(async () => { + uploadedNodes = await result.current.uploadTree({ tree, files }) + }) + + expect(mocks.mutateAsync).toHaveBeenCalledWith({ + appId: 'app-1', + tree, + files, + parentId: null, + onProgress: expect.any(Function), + }) + expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('success') + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(uploadedNodes).toEqual([ + { id: 'folder-id', name: 'alpha', node_type: 'folder', size: 0, children: [] }, + ]) + }) + + it('should skip the upload mutation when app id is missing', async () => { + useAppStore.setState({ appDetail: undefined }) + const { result } = renderHook(() => useSkillBatchUpload()) + const files = new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]]) + + let uploadedNodes: unknown + await act(async () => { + uploadedNodes = await result.current.uploadTree({ + tree: [{ name: 'alpha', node_type: 'folder', children: [] }], + files, + }) + }) + + expect(mocks.mutateAsync).not.toHaveBeenCalled() + expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalledWith('success') + expect(uploadedNodes).toEqual([]) + }) + }) + + describe('Skill Document Opening', () => { + it('should open the first nested SKILL.md file when present', () => { + const { result } = renderHook(() => useSkillBatchUpload()) + + let openedId: string | null = null + act(() => { + openedId = result.current.openCreatedSkillDocument([ + { + id: 'folder-id', + name: 'alpha', + node_type: 'folder', + size: 0, + children: [ + { + id: 'skill-md-id', + name: 'SKILL.md', + node_type: 'file', + size: 12, + children: [], + }, + ], + }, + ]) + }) + + expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true }) + expect(openedId).toBe('skill-md-id') + }) + }) +}) diff --git a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx index f0c314d202..f8be2f44ec 100644 --- a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx +++ b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx @@ -1,32 +1,16 @@ -import type { App, AppSSO } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { useStore as useAppStore } from '@/app/components/app/store' import CreateBlankSkillModal from './create-blank-skill-modal' -type MockWorkflowState = { - setUploadStatus: ReturnType - setUploadProgress: ReturnType - openTab: ReturnType -} - const mocks = vi.hoisted(() => ({ - mutateAsync: vi.fn(), - emitTreeUpdate: vi.fn(), + appId: 'app-1', + startUpload: vi.fn(), + failUpload: vi.fn(), + uploadTree: vi.fn(), + openCreatedSkillDocument: vi.fn(), prepareSkillUploadFile: vi.fn(), toastSuccess: vi.fn(), toastError: vi.fn(), existingNames: new Set(), - workflowState: { - setUploadStatus: vi.fn(), - setUploadProgress: vi.fn(), - openTab: vi.fn(), - } as MockWorkflowState, -})) - -vi.mock('@/service/use-app-asset', () => ({ - useBatchUpload: () => ({ - mutateAsync: mocks.mutateAsync, - }), })) vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({ @@ -35,17 +19,17 @@ vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({ }), })) -vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({ - useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, -})) - vi.mock('../utils/skill-upload-utils', () => ({ prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args), })) -vi.mock('@/app/components/workflow/store', () => ({ - useWorkflowStore: () => ({ - getState: () => mocks.workflowState, +vi.mock('./use-skill-batch-upload', () => ({ + useSkillBatchUpload: () => ({ + appId: mocks.appId, + startUpload: mocks.startUpload, + failUpload: mocks.failUpload, + uploadTree: mocks.uploadTree, + openCreatedSkillDocument: mocks.openCreatedSkillDocument, }), })) @@ -60,10 +44,9 @@ describe('CreateBlankSkillModal', () => { beforeEach(() => { vi.clearAllMocks() mocks.existingNames = new Set() - useAppStore.setState({ - appDetail: { id: 'app-1' } as App & Partial, - }) + mocks.appId = 'app-1' mocks.prepareSkillUploadFile.mockImplementation(async (file: File) => file) + mocks.uploadTree.mockResolvedValue([]) }) describe('Rendering', () => { @@ -102,27 +85,22 @@ describe('CreateBlankSkillModal', () => { describe('Create Flow', () => { it('should upload skill template and notify success when creation succeeds', async () => { const onClose = vi.fn() - mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => { - onProgress?.(1, 1) - return [{ - children: [{ id: 'skill-md-id' }], - }] - }) + mocks.uploadTree.mockResolvedValueOnce([ + { id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] }, + ]) render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i })) await waitFor(() => { - expect(mocks.mutateAsync).toHaveBeenCalledTimes(1) + expect(mocks.uploadTree).toHaveBeenCalledTimes(1) }) - expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') - expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success') - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 }) - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 }) - expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) - expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true }) + expect(mocks.startUpload).toHaveBeenCalledWith(1) + expect(mocks.openCreatedSkillDocument).toHaveBeenCalledWith([ + { id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] }, + ]) expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skill.startTab.createSuccess:{"name":"new-skill"}') expect(mocks.toastError).not.toHaveBeenCalled() expect(onClose).toHaveBeenCalledTimes(1) @@ -131,14 +109,14 @@ describe('CreateBlankSkillModal', () => { it('should set partial error and show error toast when upload fails', async () => { const onClose = vi.fn() - mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed')) + mocks.uploadTree.mockRejectedValueOnce(new Error('upload failed')) render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i })) await waitFor(() => { - expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') + expect(mocks.failUpload).toHaveBeenCalledTimes(1) }) expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.createError') @@ -148,17 +126,17 @@ describe('CreateBlankSkillModal', () => { }) it('should not start upload when app id is missing', () => { - useAppStore.setState({ appDetail: undefined }) + mocks.appId = '' render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i })) - expect(mocks.mutateAsync).not.toHaveBeenCalled() + expect(mocks.uploadTree).not.toHaveBeenCalled() + expect(mocks.startUpload).not.toHaveBeenCalled() }) it('should trigger create flow when Enter key is pressed and form is valid', async () => { - mocks.mutateAsync.mockResolvedValueOnce([]) render() const input = screen.getByRole('textbox') @@ -166,7 +144,7 @@ describe('CreateBlankSkillModal', () => { fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) await waitFor(() => { - expect(mocks.mutateAsync).toHaveBeenCalledTimes(1) + expect(mocks.uploadTree).toHaveBeenCalledTimes(1) }) }) }) diff --git a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx index 1e011a6d4d..bbf36ce523 100644 --- a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx +++ b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx @@ -3,16 +3,13 @@ import type { BatchUploadNodeInput } from '@/types/app-asset' import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog' import { toast } from '@/app/components/base/ui/toast' -import { useWorkflowStore } from '@/app/components/workflow/store' -import { useBatchUpload } from '@/service/use-app-asset' import { useExistingSkillNames } from '../hooks/file-tree/data/use-skill-asset-tree' -import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration' import { prepareSkillUploadFile } from '../utils/skill-upload-utils' +import { useSkillBatchUpload } from './use-skill-batch-upload' const SKILL_MD_TEMPLATE = (name: string) => `--- name: ${name} @@ -32,17 +29,13 @@ const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps) const [skillName, setSkillName] = useState('') const [isCreating, setIsCreating] = useState(false) - const appDetail = useAppStore(s => s.appDetail) - const appId = appDetail?.id || '' - const storeApi = useWorkflowStore() - - const batchUpload = useBatchUpload() - const batchUploadRef = useRef(batchUpload) - batchUploadRef.current = batchUpload - - const emitTreeUpdate = useSkillTreeUpdateEmitter() - const emitTreeUpdateRef = useRef(emitTreeUpdate) - emitTreeUpdateRef.current = emitTreeUpdate + const { + appId, + startUpload, + failUpload, + uploadTree, + openCreatedSkillDocument, + } = useSkillBatchUpload() const { data: existingNames } = useExistingSkillNames() @@ -69,8 +62,7 @@ const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps) return setIsCreating(true) - storeApi.getState().setUploadStatus('uploading') - storeApi.getState().setUploadProgress({ uploaded: 0, total: 1, failed: 0 }) + startUpload(1) try { const content = SKILL_MD_TEMPLATE(trimmedName) @@ -86,35 +78,21 @@ const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps) const files = new Map() files.set(`${trimmedName}/SKILL.md`, preparedFile) - const createdNodes = await batchUploadRef.current.mutateAsync({ - appId, - tree, - files, - parentId: null, - onProgress: (uploaded, total) => { - storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 }) - }, - }) - - storeApi.getState().setUploadStatus('success') - emitTreeUpdateRef.current() - - const skillMdId = createdNodes?.[0]?.children?.[0]?.id - if (skillMdId) - storeApi.getState().openTab(skillMdId, { pinned: true }) + const createdNodes = await uploadTree({ tree, files }) + openCreatedSkillDocument(createdNodes) toast.success(t('skill.startTab.createSuccess', { ns: 'workflow', name: trimmedName })) onClose() } catch { - storeApi.getState().setUploadStatus('partial_error') + failUpload() toast.error(t('skill.startTab.createError', { ns: 'workflow' })) } finally { setIsCreating(false) setSkillName('') } - }, [canCreate, appId, trimmedName, storeApi, onClose, t]) + }, [appId, canCreate, failUpload, onClose, openCreatedSkillDocument, startUpload, t, trimmedName, uploadTree]) return ( - setUploadProgress: ReturnType - openTab: ReturnType -} - const mocks = vi.hoisted(() => ({ + appId: 'app-1', extractAndValidateZip: vi.fn(), buildUploadDataFromZip: vi.fn(), - mutateAsync: vi.fn(), - emitTreeUpdate: vi.fn(), + startUpload: vi.fn(), + setUploadProgress: vi.fn(), + failUpload: vi.fn(), + uploadTree: vi.fn(), + openCreatedSkillDocument: vi.fn(), toastSuccess: vi.fn(), toastError: vi.fn(), existingNames: new Set(), - workflowState: { - setUploadStatus: vi.fn(), - setUploadProgress: vi.fn(), - openTab: vi.fn(), - } as MockWorkflowState, })) vi.mock('../utils/zip-extract', () => { @@ -46,25 +37,20 @@ vi.mock('../utils/zip-to-upload-tree', () => ({ buildUploadDataFromZip: (...args: unknown[]) => mocks.buildUploadDataFromZip(...args), })) -vi.mock('@/service/use-app-asset', () => ({ - useBatchUpload: () => ({ - mutateAsync: mocks.mutateAsync, - }), -})) - vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({ useExistingSkillNames: () => ({ data: mocks.existingNames, }), })) -vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({ - useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, -})) - -vi.mock('@/app/components/workflow/store', () => ({ - useWorkflowStore: () => ({ - getState: () => mocks.workflowState, +vi.mock('./use-skill-batch-upload', () => ({ + useSkillBatchUpload: () => ({ + appId: mocks.appId, + startUpload: mocks.startUpload, + setUploadProgress: mocks.setUploadProgress, + failUpload: mocks.failUpload, + uploadTree: mocks.uploadTree, + openCreatedSkillDocument: mocks.openCreatedSkillDocument, }), })) @@ -98,9 +84,8 @@ describe('ImportSkillModal', () => { beforeEach(() => { vi.clearAllMocks() mocks.existingNames = new Set() - useAppStore.setState({ - appDetail: { id: 'app-1' } as App & Partial, - }) + mocks.appId = 'app-1' + mocks.uploadTree.mockResolvedValue([]) }) describe('Rendering', () => { @@ -188,28 +173,23 @@ describe('ImportSkillModal', () => { tree: [{ name: 'new-skill', node_type: 'folder', children: [] }], files: new Map([['new-skill/SKILL.md', new File(['content'], 'SKILL.md')]]), }) - mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => { - onProgress?.(1, 1) - return [{ - children: [{ id: 'skill-md-id', name: 'SKILL.md' }], - }] - }) + mocks.uploadTree.mockResolvedValueOnce([ + { id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] }, + ]) render() selectFile(createZipFile('new-skill.zip', 2048)) fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })) await waitFor(() => { - expect(mocks.mutateAsync).toHaveBeenCalledTimes(1) + expect(mocks.uploadTree).toHaveBeenCalledTimes(1) }) - expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') - expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success') - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 0, failed: 0 }) - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 }) - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 }) - expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) - expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true }) + expect(mocks.startUpload).toHaveBeenCalledWith(0) + expect(mocks.setUploadProgress).toHaveBeenCalledWith(0, 1) + expect(mocks.openCreatedSkillDocument).toHaveBeenCalledWith([ + { id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] }, + ]) expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skill.startTab.importModal.importSuccess:{"name":"new-skill"}') expect(mocks.toastError).not.toHaveBeenCalled() expect(onClose).toHaveBeenCalledTimes(1) @@ -227,17 +207,17 @@ describe('ImportSkillModal', () => { fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })) await waitFor(() => { - expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') + expect(mocks.failUpload).toHaveBeenCalledTimes(1) }) expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.nameDuplicate') expect(mocks.toastSuccess).not.toHaveBeenCalled() expect(mocks.buildUploadDataFromZip).not.toHaveBeenCalled() - expect(mocks.mutateAsync).not.toHaveBeenCalled() + expect(mocks.uploadTree).not.toHaveBeenCalled() }) it('should not start import when app id is missing', () => { - useAppStore.setState({ appDetail: undefined }) + mocks.appId = '' render() selectFile(createZipFile('new-skill.zip', 2048)) @@ -245,8 +225,8 @@ describe('ImportSkillModal', () => { expect(mocks.extractAndValidateZip).not.toHaveBeenCalled() expect(mocks.buildUploadDataFromZip).not.toHaveBeenCalled() - expect(mocks.mutateAsync).not.toHaveBeenCalled() - expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalled() + expect(mocks.uploadTree).not.toHaveBeenCalled() + expect(mocks.startUpload).not.toHaveBeenCalled() }) it('should map zip validation error code to localized error message', async () => { @@ -257,7 +237,7 @@ describe('ImportSkillModal', () => { fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })) await waitFor(() => { - expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') + expect(mocks.failUpload).toHaveBeenCalledTimes(1) }) expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.errorEmptyZip') @@ -274,7 +254,7 @@ describe('ImportSkillModal', () => { fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })) await waitFor(() => { - expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') + expect(mocks.failUpload).toHaveBeenCalledTimes(1) }) expect(mocks.toastError).toHaveBeenCalledWith('custom zip error') @@ -289,7 +269,7 @@ describe('ImportSkillModal', () => { fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })) await waitFor(() => { - expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') + expect(mocks.failUpload).toHaveBeenCalledTimes(1) }) expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.errorInvalidZip') diff --git a/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx b/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx index 4775319378..76c0467138 100644 --- a/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx +++ b/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx @@ -3,16 +3,13 @@ import type { ChangeEvent, DragEvent } from 'react' import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import Button from '@/app/components/base/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog' import { toast } from '@/app/components/base/ui/toast' -import { useWorkflowStore } from '@/app/components/workflow/store' -import { useBatchUpload } from '@/service/use-app-asset' import { useExistingSkillNames } from '../hooks/file-tree/data/use-skill-asset-tree' -import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration' import { extractAndValidateZip, ZipValidationError } from '../utils/zip-extract' import { buildUploadDataFromZip } from '../utils/zip-to-upload-tree' +import { useSkillBatchUpload } from './use-skill-batch-upload' const NS = 'workflow' const PREFIX = 'skill.startTab.importModal' @@ -46,17 +43,14 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => { const [isImporting, setIsImporting] = useState(false) const [isDragOver, setIsDragOver] = useState(false) - const appDetail = useAppStore(s => s.appDetail) - const appId = appDetail?.id || '' - const storeApi = useWorkflowStore() - - const batchUpload = useBatchUpload() - const batchUploadRef = useRef(batchUpload) - batchUploadRef.current = batchUpload - - const emitTreeUpdate = useSkillTreeUpdateEmitter() - const emitTreeUpdateRef = useRef(emitTreeUpdate) - emitTreeUpdateRef.current = emitTreeUpdate + const { + appId, + startUpload, + setUploadProgress, + failUpload, + uploadTree, + openCreatedSkillDocument, + } = useSkillBatchUpload() const { data: existingNames } = useExistingSkillNames() @@ -107,8 +101,7 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => { return setIsImporting(true) - storeApi.getState().setUploadStatus('uploading') - storeApi.getState().setUploadProgress({ uploaded: 0, total: 0, failed: 0 }) + startUpload(0) try { const zipData = await selectedFile.arrayBuffer() @@ -116,38 +109,21 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => { if (existingNames?.has(extracted.rootFolderName)) { toast.error(t(`${PREFIX}.nameDuplicate`, { ns: NS })) + failUpload() setIsImporting(false) - storeApi.getState().setUploadStatus('partial_error') return } const { tree, files } = await buildUploadDataFromZip(extracted) - - storeApi.getState().setUploadProgress({ uploaded: 0, total: files.size, failed: 0 }) - - const createdNodes = await batchUploadRef.current.mutateAsync({ - appId, - tree, - files, - parentId: null, - onProgress: (uploaded, total) => { - storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 }) - }, - }) - - storeApi.getState().setUploadStatus('success') - emitTreeUpdateRef.current() - - const skillFolder = createdNodes?.[0] - const skillMd = skillFolder?.children?.find(c => c.name === 'SKILL.md') - if (skillMd?.id) - storeApi.getState().openTab(skillMd.id, { pinned: true }) + setUploadProgress(0, files.size) + const createdNodes = await uploadTree({ tree, files }) + openCreatedSkillDocument(createdNodes) toast.success(t(`${PREFIX}.importSuccess`, { ns: NS, name: extracted.rootFolderName })) onClose() } catch (error) { - storeApi.getState().setUploadStatus('partial_error') + failUpload() if (error instanceof ZipValidationError) { const i18nKey = ZIP_ERROR_I18N_KEYS[error.code as keyof typeof ZIP_ERROR_I18N_KEYS] toast.error(i18nKey ? t(i18nKey, { ns: NS }) : error.message) @@ -160,7 +136,7 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => { setIsImporting(false) setSelectedFile(null) } - }, [selectedFile, appId, storeApi, existingNames, t, onClose]) + }, [selectedFile, appId, startUpload, existingNames, setUploadProgress, uploadTree, openCreatedSkillDocument, t, onClose, failUpload]) return ( - setUploadProgress: ReturnType -} - type TemplateEntry = { id: string name: string @@ -17,15 +10,13 @@ type TemplateEntry = { } const mocks = vi.hoisted(() => ({ + appId: 'app-1', templates: [] as TemplateEntry[], buildUploadDataFromTemplate: vi.fn(), - mutateAsync: vi.fn(), - emitTreeUpdate: vi.fn(), + startUpload: vi.fn(), + failUpload: vi.fn(), + uploadTree: vi.fn(), existingNames: new Set(), - workflowState: { - setUploadStatus: vi.fn(), - setUploadProgress: vi.fn(), - } as MockWorkflowState, })) vi.mock('./templates/registry', () => ({ @@ -36,25 +27,18 @@ vi.mock('./templates/template-to-upload', () => ({ buildUploadDataFromTemplate: (...args: unknown[]) => mocks.buildUploadDataFromTemplate(...args), })) -vi.mock('@/service/use-app-asset', () => ({ - useBatchUpload: () => ({ - mutateAsync: mocks.mutateAsync, - }), -})) - vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({ useExistingSkillNames: () => ({ data: mocks.existingNames, }), })) -vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({ - useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, -})) - -vi.mock('@/app/components/workflow/store', () => ({ - useWorkflowStore: () => ({ - getState: () => mocks.workflowState, +vi.mock('./use-skill-batch-upload', () => ({ + useSkillBatchUpload: () => ({ + appId: mocks.appId, + startUpload: mocks.startUpload, + failUpload: mocks.failUpload, + uploadTree: mocks.uploadTree, }), })) @@ -87,10 +71,8 @@ describe('SkillTemplatesSection', () => { tree: [{ name: 'alpha', node_type: 'folder', children: [] }], files: new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]]), }) - mocks.mutateAsync.mockResolvedValue([]) - useAppStore.setState({ - appDetail: { id: 'app-1' } as App & Partial, - }) + mocks.uploadTree.mockResolvedValue([]) + mocks.appId = 'app-1' }) describe('Rendering', () => { @@ -125,47 +107,43 @@ describe('SkillTemplatesSection', () => { }) describe('Use Template Flow', () => { - it('should upload template and update workflow status when use action succeeds', async () => { - mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => { - onProgress?.(1, 1) - return [] - }) + it('should upload template when use action succeeds', async () => { render() fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0]) await waitFor(() => { - expect(mocks.mutateAsync).toHaveBeenCalledTimes(1) + expect(mocks.uploadTree).toHaveBeenCalledTimes(1) }) expect(mocks.buildUploadDataFromTemplate).toHaveBeenCalledWith('alpha', expect.any(Array)) - expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') - expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success') - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 }) - expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 }) - expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(mocks.startUpload).toHaveBeenCalledWith(1) + const uploadArg = mocks.uploadTree.mock.calls[0][0] + expect(uploadArg.tree).toEqual([{ name: 'alpha', node_type: 'folder', children: [] }]) + expect(uploadArg.files).toBeInstanceOf(Map) + expect(uploadArg.files.get('alpha/SKILL.md')).toBeInstanceOf(File) }) it('should set partial error when upload fails', async () => { - mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed')) + mocks.uploadTree.mockRejectedValueOnce(new Error('upload failed')) render() fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0]) await waitFor(() => { - expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') + expect(mocks.failUpload).toHaveBeenCalledTimes(1) }) }) it('should not start upload when app id is missing', () => { - useAppStore.setState({ appDetail: undefined }) + mocks.appId = '' render() fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0]) expect(mocks.templates[0].loadContent).not.toHaveBeenCalled() - expect(mocks.mutateAsync).not.toHaveBeenCalled() - expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalled() + expect(mocks.uploadTree).not.toHaveBeenCalled() + expect(mocks.startUpload).not.toHaveBeenCalled() }) }) }) diff --git a/web/app/components/workflow/skill/start-tab/skill-templates-section.tsx b/web/app/components/workflow/skill/start-tab/skill-templates-section.tsx index d9ef9cbe6d..036efa01ed 100644 --- a/web/app/components/workflow/skill/start-tab/skill-templates-section.tsx +++ b/web/app/components/workflow/skill/start-tab/skill-templates-section.tsx @@ -1,72 +1,46 @@ 'use client' import type { SkillTemplateSummary } from './templates/types' -import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { SearchMenu } from '@/app/components/base/icons/src/vender/knowledge' -import { useWorkflowStore } from '@/app/components/workflow/store' -import { useBatchUpload } from '@/service/use-app-asset' import { useExistingSkillNames } from '../hooks/file-tree/data/use-skill-asset-tree' -import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration' import SectionHeader from './section-header' import TemplateCard from './template-card' import TemplateSearch from './template-search' import { SKILL_TEMPLATES } from './templates/registry' import { buildUploadDataFromTemplate } from './templates/template-to-upload' +import { useSkillBatchUpload } from './use-skill-batch-upload' const SkillTemplatesSection = () => { const { t } = useTranslation('workflow') const [searchQuery, setSearchQuery] = useState('') const [loadingId, setLoadingId] = useState(null) - const appDetail = useAppStore(s => s.appDetail) - const appId = appDetail?.id || '' - const storeApi = useWorkflowStore() - const batchUpload = useBatchUpload() - const batchUploadRef = useRef(batchUpload) - batchUploadRef.current = batchUpload - const emitTreeUpdate = useSkillTreeUpdateEmitter() - const emitTreeUpdateRef = useRef(emitTreeUpdate) - emitTreeUpdateRef.current = emitTreeUpdate + const { appId, startUpload, failUpload, uploadTree } = useSkillBatchUpload() const { data: existingNames } = useExistingSkillNames() - const existingNamesRef = useRef(existingNames) - existingNamesRef.current = existingNames const handleUse = useCallback(async (summary: SkillTemplateSummary) => { const entry = SKILL_TEMPLATES.find(e => e.id === summary.id) - if (!entry || !appId || existingNamesRef.current?.has(summary.id)) + if (!entry || !appId || existingNames?.has(summary.id)) return setLoadingId(summary.id) - storeApi.getState().setUploadStatus('uploading') - storeApi.getState().setUploadProgress({ uploaded: 0, total: 1, failed: 0 }) + startUpload(1) try { const children = await entry.loadContent() const uploadData = await buildUploadDataFromTemplate(summary.id, children) - - await batchUploadRef.current.mutateAsync({ - appId, - tree: uploadData.tree, - files: uploadData.files, - parentId: null, - onProgress: (uploaded, total) => { - storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 }) - }, - }) - - storeApi.getState().setUploadStatus('success') - emitTreeUpdateRef.current() + await uploadTree(uploadData) } catch { - storeApi.getState().setUploadStatus('partial_error') + failUpload() } finally { setLoadingId(null) } - }, [appId, storeApi]) + }, [appId, existingNames, failUpload, startUpload, uploadTree]) const filtered = useMemo(() => { if (!searchQuery) diff --git a/web/app/components/workflow/skill/start-tab/use-skill-batch-upload.ts b/web/app/components/workflow/skill/start-tab/use-skill-batch-upload.ts new file mode 100644 index 0000000000..504f03c35b --- /dev/null +++ b/web/app/components/workflow/skill/start-tab/use-skill-batch-upload.ts @@ -0,0 +1,90 @@ +import type { BatchUploadNodeInput, BatchUploadNodeOutput } from '@/types/app-asset' +import { useCallback } from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { useBatchUpload } from '@/service/use-app-asset' +import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration' + +type UploadTreeParams = { + tree: BatchUploadNodeInput[] + files: Map +} + +const createProgress = (uploaded: number, total: number) => ({ + uploaded, + total, + failed: 0, +}) + +function findSkillDocumentNodeId(nodes: BatchUploadNodeOutput[]): string | null { + const queue = [...nodes] + + while (queue.length > 0) { + const node = queue.shift()! + if (node.name === 'SKILL.md') + return node.id + + if (node.children.length > 0) + queue.push(...node.children) + } + + return null +} + +export const useSkillBatchUpload = () => { + const appId = useAppStore(s => s.appDetail?.id || '') + const storeApi = useWorkflowStore() + const { mutateAsync } = useBatchUpload() + const emitTreeUpdate = useSkillTreeUpdateEmitter() + + const startUpload = useCallback((total: number) => { + const normalizedTotal = Math.max(total, 0) + const state = storeApi.getState() + state.setUploadStatus('uploading') + state.setUploadProgress(createProgress(0, normalizedTotal)) + }, [storeApi]) + + const setUploadProgress = useCallback((uploaded: number, total: number) => { + storeApi.getState().setUploadProgress(createProgress(uploaded, total)) + }, [storeApi]) + + const failUpload = useCallback(() => { + storeApi.getState().setUploadStatus('partial_error') + }, [storeApi]) + + const uploadTree = useCallback(async ({ + tree, + files, + }: UploadTreeParams): Promise => { + if (!appId) + return [] + + const createdNodes = await mutateAsync({ + appId, + tree, + files, + parentId: null, + onProgress: setUploadProgress, + }) + + storeApi.getState().setUploadStatus('success') + emitTreeUpdate() + return createdNodes + }, [appId, emitTreeUpdate, mutateAsync, setUploadProgress, storeApi]) + + const openCreatedSkillDocument = useCallback((nodes: BatchUploadNodeOutput[]): string | null => { + const skillDocumentId = findSkillDocumentNodeId(nodes) + if (skillDocumentId) + storeApi.getState().openTab(skillDocumentId, { pinned: true }) + return skillDocumentId + }, [storeApi]) + + return { + appId, + startUpload, + setUploadProgress, + failUpload, + uploadTree, + openCreatedSkillDocument, + } +}