chore: upload file and auto insert

This commit is contained in:
Joel
2026-03-30 10:56:29 +08:00
parent 09b3e53c43
commit 11895d07c1
5 changed files with 194 additions and 188 deletions

View File

@@ -9,6 +9,7 @@ import type {
VariableBlockType,
WorkflowVariableBlockType,
} from '../../../types'
import type { TreeNodeData } from '@/app/components/workflow/skill/type'
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
import type { EventEmitterValue } from '@/context/event-emitter'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
@@ -29,6 +30,7 @@ import {
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
import { VarType } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@@ -64,6 +66,73 @@ beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
const mocks = vi.hoisted(() => ({
uploadedResourceIds: ['11111111-1111-1111-1111-111111111111'],
}))
vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel', () => ({
FilePickerPanel: ({
onSelectNode,
showAddFiles,
onAddFiles,
}: {
onSelectNode: (node: TreeNodeData) => void
showAddFiles?: boolean
onAddFiles?: () => void
}) => (
<div>
<button
type="button"
onClick={() => onSelectNode({
id: '33333333-3333-3333-3333-333333333333',
node_type: 'file',
name: 'existing.md',
path: '/existing.md',
extension: 'md',
size: 1,
children: [],
})}
>
mock-existing-file
</button>
{showAddFiles && (
<button type="button" onClick={onAddFiles}>
mock-add-files
</button>
)}
</div>
),
}))
vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal', () => ({
default: ({
isOpen,
onUploadedFiles,
}: {
isOpen: boolean
onClose: () => void
onUploadedFiles?: (resourceIds: string[]) => void
}) => {
if (!isOpen)
return null
return (
<button
type="button"
onClick={() => onUploadedFiles?.(mocks.uploadedResourceIds)}
>
mock-upload-success
</button>
)
},
}))
vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component', () => ({
default: ({ resourceId }: { resourceId: string }) => (
<span>{`mock-file-reference:${resourceId}`}</span>
),
}))
// ─── Typed factories (no `any` / `never`) ────────────────────────────────────
function makeContextBlock(overrides: Partial<ContextBlockType> = {}): ContextBlockType {
@@ -150,6 +219,7 @@ const MinimalEditor: React.FC<{
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportSandbox?: boolean
captures: Captures
}> = ({
triggerString,
@@ -160,10 +230,12 @@ const MinimalEditor: React.FC<{
currentBlock,
errorMessageBlock,
lastRunBlock,
isSupportSandbox,
captures,
}) => {
const initialConfig = React.useMemo(() => ({
namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`,
nodes: [FileReferenceNode],
onError: (e: Error) => {
throw e
},
@@ -189,6 +261,7 @@ const MinimalEditor: React.FC<{
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportSandbox={isSupportSandbox}
/>
</LexicalComposer>
</EventEmitterContextProvider>
@@ -707,179 +780,32 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
})
})
describe('blur/focus menu visibility', () => {
it('hides the menu after a 200ms delay when blur command is dispatched', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
it('inserts uploaded files into the editor after add files succeeds from the sandbox slash menu', async () => {
const user = userEvent.setup()
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
render((
<MinimalEditor
triggerString="/"
workflowVariableBlock={makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [makeWorkflowNodeVar('output', VarType.string)]),
])}
isSupportSandbox
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
const editor = await waitForEditor(captures)
await setEditorText(editor, '/', true)
await flushNextTick()
vi.useFakeTimers()
await user.click(await screen.findByText('workflow.nodes.llm.files'))
await user.click(await screen.findByRole('button', { name: 'mock-add-files' }))
await user.click(await screen.findByRole('button', { name: 'mock-upload-success' }))
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('restores menu visibility when focus command is dispatched after blur hides it', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
act(() => {
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
})
vi.useRealTimers()
await setEditorText(editor, '{', true)
await waitFor(() => {
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
})
})
it('cancels the blur timer when focus arrives before the 200ms timeout', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
act(() => {
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useRealTimers()
})
it('cancels a pending blur timer when a subsequent blur targets var-search-input', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
const varInput = document.createElement('input')
varInput.classList.add('var-search-input')
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: varInput }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useRealTimers()
})
it('does not hide the menu when blur target is var-search-input', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
const target = document.createElement('input')
target.classList.add('var-search-input')
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: target }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useRealTimers()
await waitFor(() => {
expect(readEditorText(editor)).toContain('§[file].[app].[11111111-1111-1111-1111-111111111111]§')
expect(readEditorText(editor)).not.toContain('/')
})
})
})

View File

@@ -26,6 +26,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { mergeRegister } from '@lexical/utils'
import {
$createTextNode,
$getRoot,
$getSelection,
$insertNodes,
@@ -228,16 +229,41 @@ const ComponentPicker = ({
[editor],
)
const handleSelectFileReference = useCallback((resourceId: string) => {
editor.update(() => {
const match = checkForTriggerMatch(triggerString, editor)
const nodeToRemove = match ? $splitNodeContainingQuery(match) : null
if (nodeToRemove)
nodeToRemove.remove()
const removeTriggerText = useCallback(() => {
const match = getMatchFromSelection()
if (!match)
return
$insertNodes([$createFileReferenceNode({ resourceId })])
const nodeToRemove = $splitNodeContainingQuery(match)
if (nodeToRemove)
nodeToRemove.remove()
}, [getMatchFromSelection])
const insertFileReferences = useCallback((resourceIds: string[]) => {
if (!resourceIds.length)
return
editor.focus(() => {
editor.update(() => {
removeTriggerText()
if (!$isRangeSelection($getSelection()))
$getRoot().selectEnd()
const fileNodes = resourceIds.flatMap((resourceId, index) => {
const node = $createFileReferenceNode({ resourceId })
if (index === resourceIds.length - 1)
return [node]
return [node, $createTextNode(' ')]
})
$insertNodes(fileNodes)
})
})
}, [checkForTriggerMatch, editor, triggerString])
}, [editor, removeTriggerText])
const handleSelectFileReference = useCallback((resourceId: string) => {
insertFileReferences([resourceId])
}, [insertFileReferences])
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
@@ -448,6 +474,9 @@ const ComponentPicker = ({
showHeader={false}
showAddFiles
onAddFiles={() => {
editor.update(() => {
removeTriggerText()
})
setFileUploadModalKey(key => key + 1)
setIsFileUploadModalOpen(true)
handleClose()
@@ -566,7 +595,7 @@ const ComponentPicker = ({
}
</>
)
}, [blurHidden, isAgentTrigger, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show])
}, [isAgentTrigger, blurHidden, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show, editor, removeTriggerText])
return (
<>
@@ -589,6 +618,7 @@ const ComponentPicker = ({
key={fileUploadModalKey}
isOpen={isFileUploadModalOpen}
onClose={() => setIsFileUploadModalOpen(false)}
onUploadedFiles={insertFileReferences}
/>
)}
</>

View File

@@ -30,6 +30,7 @@ type FilePickerUploadModalProps = {
isOpen: boolean
onClose: () => void
defaultFolderId?: string
onUploadedFiles?: (resourceIds: string[]) => void
}
type AddFileMode = 'create' | 'upload'
@@ -133,6 +134,7 @@ const FilePickerUploadModal = ({
isOpen,
onClose,
defaultFolderId,
onUploadedFiles,
}: FilePickerUploadModalProps) => {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
@@ -191,6 +193,7 @@ const FilePickerUploadModal = ({
appId,
storeApi,
onClose: noop,
onFilesUploaded: uploadedNodes => onUploadedFiles?.(uploadedNodes.map(node => node.id)),
})
const isCreatingFile = uploadFile.isPending
const isBusy = isCreating || isCreatingFile
@@ -252,18 +255,19 @@ const FilePickerUploadModal = ({
try {
const emptyBlob = new Blob([''], { type: 'text/plain' })
const file = new File([emptyBlob], trimmedFileName)
await uploadFile.mutateAsync({
const createdFile = await uploadFile.mutateAsync({
appId,
file,
parentId: toApiParentId(effectiveUploadFolderId),
})
emitTreeUpdate()
onUploadedFiles?.([createdFile.id])
onClose()
}
catch {
toast.error(t('skillSidebar.menu.createError'))
}
}, [appId, canCreate, effectiveUploadFolderId, emitTreeUpdate, onClose, t, trimmedFileName, uploadFile])
}, [appId, canCreate, effectiveUploadFolderId, emitTreeUpdate, onClose, onUploadedFiles, t, trimmedFileName, uploadFile])
const modeLabel = t('skillEditor.uploadIn')
return (

View File

@@ -1,6 +1,6 @@
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
import { useCreateOperations } from './use-create-operations'
@@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({
createFolderPending: false,
uploadPending: false,
batchPending: false,
uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise<void>>(),
uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise<AppAssetNode | undefined>>(),
batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise<unknown>>(),
prepareSkillUploadFile: vi.fn<(file: File) => Promise<File>>(),
emitTreeUpdate: vi.fn<() => void>(),
@@ -88,6 +88,16 @@ const createInputChangeEvent = (files: File[] | null) => {
} as unknown as React.ChangeEvent<HTMLInputElement>
}
const createUploadedNode = (id: string, name: string): AppAssetNode => ({
id,
node_type: 'file',
name,
parent_id: null,
order: 0,
extension: name.split('.').pop() || '',
size: 1,
})
const withRelativePath = (file: File, relativePath: string): File => {
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
@@ -245,6 +255,34 @@ describe('useCreateOperations', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should report successfully uploaded nodes to onFilesUploaded', async () => {
const { storeApi } = createStoreApi()
const onClose = vi.fn()
const onFilesUploaded = vi.fn()
const first = new File(['first'], 'first.md', { type: 'text/markdown' })
const second = new File(['second'], 'second.txt', { type: 'text/plain' })
const event = createInputChangeEvent([first, second])
const uploadedFirst = createUploadedNode('11111111-1111-1111-1111-111111111111', 'first.md')
const uploadedSecond = createUploadedNode('22222222-2222-2222-2222-222222222222', 'second.txt')
mocks.uploadMutateAsync
.mockResolvedValueOnce(uploadedFirst)
.mockResolvedValueOnce(uploadedSecond)
const { result } = renderHook(() => useCreateOperations({
parentId: 'folder-success',
appId: 'app-success',
storeApi,
onClose,
onFilesUploaded,
}))
await act(async () => {
await result.current.handleFileChange(event)
})
expect(onFilesUploaded).toHaveBeenCalledWith([uploadedFirst, uploadedSecond])
})
it('should set partial_error when some file uploads fail but still emit updates for uploaded files', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()

View File

@@ -2,7 +2,7 @@
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
import { useCallback, useRef } from 'react'
import {
useBatchUpload,
@@ -17,6 +17,7 @@ type UseCreateOperationsOptions = {
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
onClose: () => void
onFilesUploaded?: (nodes: AppAssetNode[]) => void
}
const getRelativePath = (file: File) => {
@@ -28,6 +29,7 @@ export function useCreateOperations({
appId,
storeApi,
onClose,
onFilesUploaded,
}: UseCreateOperationsOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
@@ -62,21 +64,27 @@ export function useCreateOperations({
try {
const uploadFiles = await Promise.all(files.map(file => prepareSkillUploadFile(file)))
await Promise.all(
const uploadedNodes = (await Promise.all(
uploadFiles.map(async (file) => {
try {
await uploadFileAsync({ appId, file, parentId })
const node = await uploadFileAsync({ appId, file, parentId })
progress.uploaded++
return node
}
catch {
progress.failed++
return null
}
finally {
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}),
)
)).filter((node): node is AppAssetNode => !!node)
storeApi.getState().setUploadStatus(progress.failed > 0 ? 'partial_error' : 'success')
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
if (uploadedNodes.length > 0)
onFilesUploaded?.(uploadedNodes)
}
catch {
storeApi.getState().setUploadStatus('partial_error')
@@ -87,7 +95,7 @@ export function useCreateOperations({
e.target.value = ''
onClose()
}
}, [appId, uploadFileAsync, onClose, parentId, storeApi, emitTreeUpdate])
}, [appId, uploadFileAsync, onClose, onFilesUploaded, parentId, storeApi, emitTreeUpdate])
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])