feat: support choose folders and files

This commit is contained in:
Joel 2026-01-19 14:46:45 +08:00
parent 0d5e971a0c
commit 3a775fc2bf
10 changed files with 522 additions and 0 deletions

View File

@ -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<SkillEditorProps> = ({
with: (node: TextNode) => new CustomTextNode(node.__text),
},
ToolBlockNode,
FileReferenceNode,
],
editorState: textToEditorState(value || ''),
onError: (error: Error) => {
@ -120,6 +124,8 @@ const SkillEditor: FC<SkillEditorProps> = ({
<>
<ToolBlock />
<ToolBlockReplacementBlock />
<FileReferenceReplacementBlock />
{editable && <FilePickerBlock />}
{editable && <ToolPickerBlock scope={toolPickerScope} />}
</>
<OnChangePlugin onChange={handleEditorChange} />

View File

@ -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<HTMLElement | null>,
{ selectOptionAndCleanUp }: { selectOptionAndCleanUp: (option: MenuOption) => void },
) => {
if (!anchorElementRef.current)
return null
const closeMenu = () => selectOptionAndCleanUp(options[0])
return ReactDOM.createPortal(
<PortalToFollowElem
open
placement="bottom-start"
offset={4}
onOpenChange={(open) => {
if (!open)
closeMenu()
}}
>
<PortalToFollowElemTrigger asChild>
<span className="inline-block h-0 w-0" />
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<FilePickerPanel
onSelectNode={(node) => {
insertFileReference(node.id)
closeMenu()
}}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>,
anchorElementRef.current,
)
}, [insertFileReference, options])
return (
<LexicalTypeaheadMenuPlugin
options={options}
onSelectOption={() => { }}
onQueryChange={() => { }}
menuRenderFn={renderMenu}
triggerFn={checkForTriggerMatch}
anchorClassName="z-[999999] translate-y-[calc(-100%-3px)]"
/>
)
}
export default React.memo(FilePickerBlock)
export { FilePickerPanel }

View File

@ -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<TreeNodeData> & {
onSelectNode: (node: TreeNodeData) => void
}
const FilePickerTreeNode: FC<FilePickerTreeNodeProps> = ({ 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 (
<div
ref={dragHandle}
style={style}
role="treeitem"
tabIndex={0}
aria-selected={isSelected}
aria-expanded={isFolder ? node.isOpen : undefined}
className={cn(
'group relative flex h-6 cursor-pointer items-center gap-px overflow-hidden rounded-md',
'hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-active',
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<TreeGuideLines level={node.level} />
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
<div className="flex size-4 shrink-0 items-center justify-center">
{isFolder
? (
node.isOpen
? <RiFolderOpenLine className="size-4 text-text-accent" aria-hidden="true" />
: <RiFolderLine className="size-4 text-text-secondary" aria-hidden="true" />
)
: (
<FileTypeIcon type={fileIconType as FileAppearanceType} size="sm" />
)}
</div>
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected ? 'text-text-primary' : 'text-text-secondary',
)}
>
{node.data.name}
</span>
</div>
{isFolder && (
<button
type="button"
tabIndex={-1}
onClick={handleToggle}
aria-label={t('skillSidebar.toggleFolder')}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md',
'text-text-tertiary hover:bg-state-base-hover-alt',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
>
{node.isOpen
? <RiArrowDownSLine className="size-4" aria-hidden="true" />
: <RiArrowRightSLine className="size-4" aria-hidden="true" />}
</button>
)}
</div>
)
}
FilePickerTreeNode.displayName = 'FilePickerTreeNode'
type FilePickerPanelProps = {
onSelectNode: (node: TreeNodeData) => void
}
const FilePickerPanel: FC<FilePickerPanelProps> = ({ onSelectNode }) => {
const { t } = useTranslation('workflow')
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
const expandedFolderIds = useStore(s => s.expandedFolderIds)
const storeApi = useWorkflowStore()
const containerRef = useRef<HTMLDivElement>(null)
const containerSize = useSize(containerRef)
const treeNodes = useMemo(() => treeData?.children || [], [treeData?.children])
const initialOpenState = useMemo(() => toOpensObject(expandedFolderIds), [expandedFolderIds])
const renderNode = useCallback((props: NodeRendererProps<TreeNodeData>) => (
<FilePickerTreeNode {...props} onSelectNode={onSelectNode} />
), [onSelectNode])
return (
<div
className="w-[280px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm"
onMouseDown={(e) => {
const target = e.target as HTMLElement
if (target.closest('input, textarea, select'))
return
e.preventDefault()
}}
>
<div className="flex items-center gap-1 px-4 pb-1 pt-1.5">
<span className="flex-1 text-[12px] font-medium uppercase leading-4 text-text-tertiary">
{t('skillEditor.referenceFiles')}
</span>
<RiQuestionLine className="size-4 text-text-tertiary" aria-hidden="true" />
</div>
<div ref={containerRef} className="max-h-[320px] min-h-[120px] px-2 pb-2">
{isLoading && (
<div className="flex h-full items-center justify-center py-6">
<Loading type="area" />
</div>
)}
{!isLoading && error && (
<div className="flex items-center justify-center py-6 text-[12px] text-text-tertiary">
{t('skillSidebar.loadError')}
</div>
)}
{!isLoading && !error && treeNodes.length === 0 && (
<div className="flex items-center justify-center py-6 text-[12px] text-text-tertiary">
{t('skillSidebar.empty')}
</div>
)}
{!isLoading && !error && treeNodes.length > 0 && (
<Tree<TreeNodeData>
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}
</Tree>
)}
</div>
</div>
)
}
export { FilePickerPanel }

View File

@ -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<FileReferenceBlockProps> = ({ 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 (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PortalToFollowElemTrigger asChild>
<span
className={cn(
'inline-flex min-w-[18px] cursor-pointer select-none items-center gap-[2px] overflow-hidden rounded-[5px] border border-state-accent-hover-alt bg-state-accent-hover py-[1px] pl-[1px] pr-[4px] shadow-xs',
isSelected && 'border-text-accent',
)}
ref={ref}
title={title}
onMouseDown={() => setOpen(prev => !prev)}
>
<span className="flex items-center justify-center p-px">
{isFolder
? <RiFolderLine className="size-[14px] text-text-accent" aria-hidden="true" />
: (
<FileTypeIcon
type={(iconType || 'document') as FileAppearanceType}
size="sm"
className="!size-[14px]"
/>
)}
</span>
<span className="max-w-[180px] truncate text-[12px] font-medium leading-4 text-text-accent">
{displayName}
</span>
</span>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<FilePickerPanel onSelectNode={handleSelect} />
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(FileReferenceBlock)

View File

@ -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<React.JSX.Element> {
__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 (
<FileReferenceBlock
nodeKey={this.getKey()}
resourceId={this.__resourceId}
/>
)
}
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
}

View File

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

View File

@ -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}`
}

View File

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

View File

@ -991,6 +991,7 @@
"singleRun.testRunLoop": "测试运行循环",
"skillEditor.officePlaceholder": "预览功能将在后续版本支持",
"skillEditor.previewUnavailable": "无法预览",
"skillEditor.referenceFiles": "引用文件",
"skillEditor.unsupportedPreview": "该文件类型不支持预览",
"skillSidebar.addFile": "上传文件",
"skillSidebar.addFolder": "新建文件夹",

View File

@ -980,6 +980,7 @@
"singleRun.testRun": "測試運行",
"singleRun.testRunIteration": "測試運行迭代",
"singleRun.testRunLoop": "測試運行循環",
"skillEditor.referenceFiles": "參考檔案",
"tabs.-": "預設",
"tabs.addAll": "全部新增",
"tabs.agent": "代理策略",