From 11895d07c1f08a2fac139bc301c3357ea69411fe Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 30 Mar 2026 10:56:29 +0800 Subject: [PATCH] chore: upload file and auto insert --- .../__tests__/index.spec.tsx | 264 +++++++----------- .../plugins/component-picker-block/index.tsx | 48 +++- .../plugins/file-picker-upload-modal.tsx | 8 +- .../operations/use-create-operations.spec.tsx | 42 ++- .../operations/use-create-operations.ts | 20 +- 5 files changed, 194 insertions(+), 188 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index 51532c827d8..fc94fbdb7bf 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -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 + }) => ( +
+ + {showAddFiles && ( + + )} +
+ ), +})) + +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 ( + + ) + }, +})) + +vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component', () => ({ + default: ({ resourceId }: { resourceId: string }) => ( + {`mock-file-reference:${resourceId}`} + ), +})) + // ─── Typed factories (no `any` / `never`) ──────────────────────────────────── function makeContextBlock(overrides: Partial = {}): 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} /> @@ -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(( - - )) + render(( + + )) - 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(( - - )) - - 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(( - - )) - - 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(( - - )) - - 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(( - - )) - - 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('/') }) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index b4ff8a05e57..76c3227d562 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -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} /> )} diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx index d73ca0eab9d..a1b255cb2d4 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx @@ -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 ( diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx index b377360f366..33a1b9153f9 100644 --- a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx @@ -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>(), + uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise>(), batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise>(), prepareSkillUploadFile: vi.fn<(file: File) => Promise>(), emitTreeUpdate: vi.fn<() => void>(), @@ -88,6 +88,16 @@ const createInputChangeEvent = (files: File[] | null) => { } as unknown as React.ChangeEvent } +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() diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts index de44bfd6a02..b78bcd4fbe9 100644 --- a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts @@ -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 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(null) const folderInputRef = useRef(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) => { const files = Array.from(e.target.files || [])