From 3a775fc2bf5f89f3f9dea77f25289d51c22910f9 Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 19 Jan 2026 14:46:45 +0800 Subject: [PATCH] feat: support choose folders and files --- .../skill/editor/skill-editor/index.tsx | 6 + .../plugins/file-picker-block.tsx | 96 +++++++++ .../plugins/file-picker-panel.tsx | 187 ++++++++++++++++++ .../file-reference-block/component.tsx | 90 +++++++++ .../plugins/file-reference-block/node.tsx | 81 ++++++++ .../replacement-block.tsx | 43 ++++ .../plugins/file-reference-block/utils.ts | 16 ++ web/i18n/en-US/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 10 files changed, 522 insertions(+) create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-block.tsx create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel.tsx create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component.tsx create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node.tsx create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block.tsx create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/utils.ts diff --git a/web/app/components/workflow/skill/editor/skill-editor/index.tsx b/web/app/components/workflow/skill/editor/skill-editor/index.tsx index ad6769d101..21232d94b1 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/index.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/index.tsx @@ -21,6 +21,9 @@ import UpdateBlock from '@/app/components/base/prompt-editor/plugins/update-bloc import { textToEditorState } from '@/app/components/base/prompt-editor/utils' import { cn } from '@/utils/classnames' import styles from './line-numbers.module.css' +import FilePickerBlock from './plugins/file-picker-block' +import { FileReferenceNode } from './plugins/file-reference-block/node' +import FileReferenceReplacementBlock from './plugins/file-reference-block/replacement-block' import { ToolBlock, ToolBlockNode, @@ -71,6 +74,7 @@ const SkillEditor: FC = ({ with: (node: TextNode) => new CustomTextNode(node.__text), }, ToolBlockNode, + FileReferenceNode, ], editorState: textToEditorState(value || ''), onError: (error: Error) => { @@ -120,6 +124,8 @@ const SkillEditor: FC = ({ <> + + {editable && } {editable && } diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-block.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-block.tsx new file mode 100644 index 0000000000..4aabcec1cc --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-block.tsx @@ -0,0 +1,96 @@ +import type { LexicalNode } from 'lexical' +import type { FC } from 'react' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { LexicalTypeaheadMenuPlugin, MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' +import { + $insertNodes, +} from 'lexical' +import * as React from 'react' +import { useCallback, useMemo } from 'react' +import ReactDOM from 'react-dom' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import { useBasicTypeaheadTriggerMatch } from '@/app/components/base/prompt-editor/hooks' +import { $splitNodeContainingQuery } from '@/app/components/base/prompt-editor/utils' +import { FilePickerPanel } from './file-picker-panel' +import { $createFileReferenceNode } from './file-reference-block/node' + +class FilePickerMenuOption extends MenuOption { + constructor() { + super('file-picker') + } +} + +const FilePickerBlock: FC = () => { + const [editor] = useLexicalComposerContext() + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, + maxLength: 0, + }) + + const options = useMemo(() => [new FilePickerMenuOption()], []) + + const insertFileReference = useCallback((resourceId: string) => { + editor.update(() => { + const match = checkForTriggerMatch('/', editor) + const nodeToRemove = match ? $splitNodeContainingQuery(match) : null + if (nodeToRemove) + nodeToRemove.remove() + + const nodes: LexicalNode[] = [$createFileReferenceNode({ resourceId })] + $insertNodes(nodes) + }) + }, [checkForTriggerMatch, editor]) + + const renderMenu = useCallback(( + anchorElementRef: React.RefObject, + { selectOptionAndCleanUp }: { selectOptionAndCleanUp: (option: MenuOption) => void }, + ) => { + if (!anchorElementRef.current) + return null + + const closeMenu = () => selectOptionAndCleanUp(options[0]) + + return ReactDOM.createPortal( + { + if (!open) + closeMenu() + }} + > + + + + + { + insertFileReference(node.id) + closeMenu() + }} + /> + + , + anchorElementRef.current, + ) + }, [insertFileReference, options]) + + return ( + { }} + onQueryChange={() => { }} + menuRenderFn={renderMenu} + triggerFn={checkForTriggerMatch} + anchorClassName="z-[999999] translate-y-[calc(-100%-3px)]" + /> + ) +} + +export default React.memo(FilePickerBlock) +export { FilePickerPanel } diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel.tsx new file mode 100644 index 0000000000..14b1f71b31 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel.tsx @@ -0,0 +1,187 @@ +import type { FC } from 'react' +import type { NodeRendererProps } from 'react-arborist' +import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' +import type { TreeNodeData } from '@/app/components/workflow/skill/type' +import { RiArrowDownSLine, RiArrowRightSLine, RiFolderLine, RiFolderOpenLine, RiQuestionLine } from '@remixicon/react' +import { useSize } from 'ahooks' +import * as React from 'react' +import { useCallback, useMemo, useRef } from 'react' +import { Tree } from 'react-arborist' +import { useTranslation } from 'react-i18next' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import Loading from '@/app/components/base/loading' +import TreeGuideLines from '@/app/components/workflow/skill/file-tree/tree-guide-lines' +import { useSkillAssetTreeData } from '@/app/components/workflow/skill/hooks/use-skill-asset-tree' +import { getFileIconType } from '@/app/components/workflow/skill/utils/file-utils' +import { toOpensObject } from '@/app/components/workflow/skill/utils/tree-utils' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import { cn } from '@/utils/classnames' + +type FilePickerTreeNodeProps = NodeRendererProps & { + onSelectNode: (node: TreeNodeData) => void +} + +const FilePickerTreeNode: FC = ({ node, style, dragHandle, onSelectNode }) => { + const { t } = useTranslation('workflow') + const isFolder = node.data.node_type === 'folder' + const isSelected = node.isSelected + const fileIconType = !isFolder ? getFileIconType(node.data.name) : null + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + node.select() + onSelectNode(node.data) + }, [node, onSelectNode]) + + const handleToggle = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + node.toggle() + }, [node]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelectNode(node.data) + } + }, [node, onSelectNode]) + + return ( +
+ +
+
+ {isFolder + ? ( + node.isOpen + ?
+ + {node.data.name} + +
+ {isFolder && ( + + )} +
+ ) +} + +FilePickerTreeNode.displayName = 'FilePickerTreeNode' + +type FilePickerPanelProps = { + onSelectNode: (node: TreeNodeData) => void +} + +const FilePickerPanel: FC = ({ onSelectNode }) => { + const { t } = useTranslation('workflow') + const { data: treeData, isLoading, error } = useSkillAssetTreeData() + const expandedFolderIds = useStore(s => s.expandedFolderIds) + const storeApi = useWorkflowStore() + const containerRef = useRef(null) + const containerSize = useSize(containerRef) + + const treeNodes = useMemo(() => treeData?.children || [], [treeData?.children]) + const initialOpenState = useMemo(() => toOpensObject(expandedFolderIds), [expandedFolderIds]) + + const renderNode = useCallback((props: NodeRendererProps) => ( + + ), [onSelectNode]) + + return ( +
{ + const target = e.target as HTMLElement + if (target.closest('input, textarea, select')) + return + e.preventDefault() + }} + > +
+ + {t('skillEditor.referenceFiles')} + +
+
+ {isLoading && ( +
+ +
+ )} + {!isLoading && error && ( +
+ {t('skillSidebar.loadError')} +
+ )} + {!isLoading && !error && treeNodes.length === 0 && ( +
+ {t('skillSidebar.empty')} +
+ )} + {!isLoading && !error && treeNodes.length > 0 && ( + + data={treeNodes} + idAccessor="id" + childrenAccessor="children" + width="100%" + height={containerSize?.height ?? 240} + rowHeight={24} + indent={20} + overscanCount={5} + openByDefault={false} + initialOpenState={initialOpenState} + onToggle={(id) => { + storeApi.getState().toggleFolder(id) + }} + disableDrag + disableDrop + > + {renderNode} + + )} +
+
+ ) +} + +export { FilePickerPanel } diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component.tsx new file mode 100644 index 0000000000..436dbea010 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component.tsx @@ -0,0 +1,90 @@ +import type { LexicalNode } from 'lexical' +import type { FC } from 'react' +import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' +import type { TreeNodeData } from '@/app/components/workflow/skill/type' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { RiFolderLine } from '@remixicon/react' +import { $getNodeByKey } from 'lexical' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks' +import { useSkillAssetNodeMap } from '@/app/components/workflow/skill/hooks/use-skill-asset-tree' +import { getFileIconType } from '@/app/components/workflow/skill/utils/file-utils' +import { cn } from '@/utils/classnames' +import { FilePickerPanel } from '../file-picker-panel' + +type FileReferenceBlockProps = { + nodeKey: string + resourceId: string +} + +const FileReferenceBlock: FC = ({ nodeKey, resourceId }) => { + const [editor] = useLexicalComposerContext() + const [ref, isSelected] = useSelectOrDelete(nodeKey) + const { data: nodeMap } = useSkillAssetNodeMap() + const [open, setOpen] = useState(false) + + const currentNode = useMemo(() => nodeMap?.get(resourceId), [nodeMap, resourceId]) + const isFolder = currentNode?.node_type === 'folder' + const displayName = currentNode?.name ?? resourceId + const iconType = !isFolder && currentNode ? getFileIconType(currentNode.name) : null + const title = currentNode?.path ?? displayName + + const handleSelect = useCallback((node: TreeNodeData) => { + editor.update(() => { + const targetNode = $getNodeByKey(nodeKey) + if (targetNode?.getType() === 'file-reference-block') { + const fileNode = targetNode as LexicalNode & { setResourceId?: (resourceId: string) => void } + fileNode.setResourceId?.(node.id) + } + }) + setOpen(false) + }, [editor, nodeKey]) + + return ( + + + setOpen(prev => !prev)} + > + + {isFolder + ? + + {displayName} + + + + + + + + ) +} + +export default React.memo(FileReferenceBlock) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node.tsx new file mode 100644 index 0000000000..8a53aa9e32 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node.tsx @@ -0,0 +1,81 @@ +import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical' +import { DecoratorNode } from 'lexical' +import FileReferenceBlock from './component' +import { buildFileReferenceToken } from './utils' + +export type FileReferencePayload = { + resourceId: string +} + +export type SerializedFileReferenceNode = SerializedLexicalNode & FileReferencePayload + +export class FileReferenceNode extends DecoratorNode { + __resourceId: string + + static getType(): string { + return 'file-reference-block' + } + + static clone(node: FileReferenceNode): FileReferenceNode { + return new FileReferenceNode({ resourceId: node.__resourceId }, node.__key) + } + + isInline(): boolean { + return true + } + + constructor(payload: FileReferencePayload, key?: NodeKey) { + super(key) + this.__resourceId = payload.resourceId + } + + createDOM(): HTMLElement { + const span = document.createElement('span') + span.classList.add('inline-flex', 'items-center', 'align-middle') + return span + } + + updateDOM(): false { + return false + } + + decorate(): React.JSX.Element { + return ( + + ) + } + + setResourceId(resourceId: string): void { + const writable = this.getWritable() + writable.__resourceId = resourceId + } + + exportJSON(): SerializedFileReferenceNode { + return { + type: 'file-reference-block', + version: 1, + resourceId: this.__resourceId, + } + } + + static importJSON(serializedNode: SerializedFileReferenceNode): FileReferenceNode { + return $createFileReferenceNode(serializedNode) + } + + getTextContent(): string { + return buildFileReferenceToken(this.__resourceId) + } +} + +export function $createFileReferenceNode(payload: FileReferencePayload): FileReferenceNode { + return new FileReferenceNode(payload) +} + +export function $isFileReferenceNode( + node: FileReferenceNode | LexicalNode | null | undefined, +): node is FileReferenceNode { + return node instanceof FileReferenceNode +} diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block.tsx new file mode 100644 index 0000000000..c19a899331 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block.tsx @@ -0,0 +1,43 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister } from '@lexical/utils' +import { $createTextNode } from 'lexical' +import { useEffect, useMemo } from 'react' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import { decoratorTransform } from '@/app/components/base/prompt-editor/utils' +import { $createFileReferenceNode, FileReferenceNode } from './node' +import { getFileReferenceTokenRegexString, parseFileReferenceToken } from './utils' + +const FileReferenceReplacementBlock = () => { + const [editor] = useLexicalComposerContext() + const regex = useMemo(() => new RegExp(getFileReferenceTokenRegexString(), 'i'), []) + + useEffect(() => { + if (!editor.hasNodes([FileReferenceNode])) + throw new Error('FileReferenceReplacementBlock: FileReferenceNode not registered on editor') + + const getMatch = (text: string) => { + const matchArr = regex.exec(text) + if (!matchArr) + return null + return { + start: matchArr.index, + end: matchArr.index + matchArr[0].length, + } + } + + const createFileReferenceNode = (textNode: CustomTextNode) => { + const parsed = parseFileReferenceToken(textNode.getTextContent()) + if (!parsed) + return $createTextNode(textNode.getTextContent()) + return $createFileReferenceNode(parsed) + } + + return mergeRegister( + editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createFileReferenceNode)), + ) + }, [editor, regex]) + + return null +} + +export default FileReferenceReplacementBlock diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/utils.ts b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/utils.ts new file mode 100644 index 0000000000..8efd3ed768 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/utils.ts @@ -0,0 +1,16 @@ +export const getFileReferenceTokenRegexString = (): string => { + return '§\\[file\\]\\.\\[app\\]\\.\\[[a-fA-F0-9-]{36}\\]§' +} + +export const parseFileReferenceToken = (text: string) => { + const match = /^§\[file\]\.\[app\]\.\[([a-fA-F0-9-]{36})\]§$/.exec(text) + if (!match) + return null + return { + resourceId: match[1], + } +} + +export const buildFileReferenceToken = (resourceId: string) => { + return `§[file].[app].[${resourceId}]§` +} diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 9a36ea0c16..80f05b3f96 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -997,6 +997,7 @@ "singleRun.testRunLoop": "Test Run Loop", "skillEditor.officePlaceholder": "Preview will be supported in a future update", "skillEditor.previewUnavailable": "Preview unavailable", + "skillEditor.referenceFiles": "Reference files", "skillEditor.unsupportedPreview": "This file type is not supported for preview", "skillSidebar.addFile": "Upload File", "skillSidebar.addFolder": "New Folder", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index d94443a23a..725bdedc4a 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -991,6 +991,7 @@ "singleRun.testRunLoop": "测试运行循环", "skillEditor.officePlaceholder": "预览功能将在后续版本支持", "skillEditor.previewUnavailable": "无法预览", + "skillEditor.referenceFiles": "引用文件", "skillEditor.unsupportedPreview": "该文件类型不支持预览", "skillSidebar.addFile": "上传文件", "skillSidebar.addFolder": "新建文件夹", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index b16ba1fcd9..f4d2199db2 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -980,6 +980,7 @@ "singleRun.testRun": "測試運行", "singleRun.testRunIteration": "測試運行迭代", "singleRun.testRunLoop": "測試運行循環", + "skillEditor.referenceFiles": "參考檔案", "tabs.-": "預設", "tabs.addAll": "全部新增", "tabs.agent": "代理策略",