refactor(web): streamline skill dependency fetching and batch upload flows

This commit is contained in:
CodingOnStar
2026-04-01 16:58:20 +08:00
parent 9ef53a3179
commit 4419d14887
13 changed files with 563 additions and 284 deletions

View File

@@ -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<string, unknown>
}
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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
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<AppSSO>,
})
})
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 }],
},
})
})
})

View File

@@ -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<Props> = ({
@@ -30,7 +34,10 @@ const ComputerUseConfig: FC<Props> = ({
onChange,
nodeId,
toolSettings,
promptTemplateKey,
toolDependencies,
isNodeSkillsLoading,
isNodeSkillsQueryEnabled,
hasNodeSkillsData,
}) => {
const { t } = useTranslation()
const isDisabled = readonly || isDisabledByStructuredOutput
@@ -89,7 +96,10 @@ const ComputerUseConfig: FC<Props> = ({
isComputerUseEnabled={enabled}
nodeId={nodeId}
toolSettings={toolSettings}
promptTemplateKey={promptTemplateKey}
toolDependencies={toolDependencies}
isNodeSkillsLoading={isNodeSkillsLoading}
isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled}
hasNodeSkillsData={hasNodeSkillsData}
/>
</div>
</FieldCollapse>

View File

@@ -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<ReferenceToolConfigProps> = ({
isComputerUseEnabled,
nodeId,
toolSettings,
promptTemplateKey,
toolDependencies,
isNodeSkillsLoading,
isNodeSkillsQueryEnabled,
hasNodeSkillsData,
}) => {
const isReferenceToolsDisabled = readonly || !isComputerUseEnabled || isDisabledByStructuredOutput
const { i18n, t } = useTranslation()
@@ -52,11 +57,6 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
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<ToolProviderGroup[]>(() => {
const map = new Map<string, ToolDependency[]>()
toolDependencies.forEach((tool) => {
@@ -185,7 +185,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
}))
}, [])
const isInitialLoading = isQueryEnabled && isLoading && !hasData
const isInitialLoading = isNodeSkillsQueryEnabled && isNodeSkillsLoading && !hasNodeSkillsData
const showNoData = !isInitialLoading && providers.length === 0
const renderProviderIcon = useCallback((providerId: string) => {

View File

@@ -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<NodePanelProps<LLMNodeType>> = ({
id,
@@ -89,14 +89,29 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
}
}, [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<NodePanelProps<LLMNodeType>> = ({
onChange={handleComputerUseChange}
nodeId={id}
toolSettings={inputs.tool_settings}
promptTemplateKey={skillsRefreshKey}
toolDependencies={toolDependencies}
isNodeSkillsLoading={isNodeSkillsLoading}
isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled}
hasNodeSkillsData={hasNodeSkillsData}
/>
</>
)}

View File

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

View File

@@ -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<typeof vi.fn>
setUploadProgress: ReturnType<typeof vi.fn>
openTab: ReturnType<typeof vi.fn>
}
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<AppSSO>,
})
})
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')
})
})
})

View File

@@ -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<typeof vi.fn>
setUploadProgress: ReturnType<typeof vi.fn>
openTab: ReturnType<typeof vi.fn>
}
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<string>(),
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<AppSSO>,
})
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(<CreateBlankSkillModal isOpen onClose={onClose} />)
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(<CreateBlankSkillModal isOpen onClose={onClose} />)
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(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
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(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
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)
})
})
})

View File

@@ -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<string, File>()
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 (
<Dialog

View File

@@ -1,28 +1,19 @@
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 { ZipValidationError } from '../utils/zip-extract'
import ImportSkillModal from './import-skill-modal'
type MockWorkflowState = {
setUploadStatus: ReturnType<typeof vi.fn>
setUploadProgress: ReturnType<typeof vi.fn>
openTab: ReturnType<typeof vi.fn>
}
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<string>(),
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<AppSSO>,
})
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(<ImportSkillModal isOpen onClose={onClose} />)
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
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')

View File

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

View File

@@ -1,13 +1,6 @@
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 SkillTemplatesSection from './skill-templates-section'
type MockWorkflowState = {
setUploadStatus: ReturnType<typeof vi.fn>
setUploadProgress: ReturnType<typeof vi.fn>
}
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<string>(),
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<AppSSO>,
})
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(<SkillTemplatesSection />)
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(<SkillTemplatesSection />)
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(<SkillTemplatesSection />)
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()
})
})
})

View File

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

View File

@@ -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<string, File>
}
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<BatchUploadNodeOutput[]> => {
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,
}
}