feat: support picker vars files ui in editor

This commit is contained in:
Joel
2026-01-20 13:56:26 +08:00
parent 27de07e93d
commit 2650ceb0a6
6 changed files with 217 additions and 81 deletions

View File

@ -29,6 +29,8 @@ import {
} from 'lexical'
import * as React from 'react'
import { useEffect } from 'react'
import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
import FileReferenceReplacementBlock from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
import {
@ -41,13 +43,13 @@ import {
ContextBlockNode,
ContextBlockReplacementBlock,
} from './plugins/context-block'
import {
CurrentBlock,
CurrentBlockNode,
CurrentBlockReplacementBlock,
} from './plugins/current-block'
import { CustomTextNode } from './plugins/custom-text/node'
import {
ErrorMessageBlock,
ErrorMessageBlockNode,
@ -106,6 +108,7 @@ export type PromptEditorProps = {
lastRunBlock?: LastRunBlockType
agentBlock?: AgentBlockType
isSupportFileVar?: boolean
isSupportSandbox?: boolean
}
const PromptEditor: FC<PromptEditorProps> = ({
@ -132,6 +135,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
lastRunBlock,
agentBlock,
isSupportFileVar,
isSupportSandbox,
}) => {
const { eventEmitter } = useEventEmitterContextContext()
const initialConfig = {
@ -152,6 +156,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
CurrentBlockNode,
ErrorMessageBlockNode,
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
...(isSupportSandbox ? [FileReferenceNode] : []),
],
editorState: textToEditorState(value || ''),
onError: (error: Error) => {
@ -215,6 +220,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
{(!agentBlock || agentBlock.show) && (
<ComponentPickerBlock
@ -244,6 +250,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
{
contextBlock?.show && (
@ -285,6 +292,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
</>
)
}
{isSupportSandbox && <FileReferenceReplacementBlock />}
{
currentBlock?.show && (
<>

View File

@ -38,9 +38,13 @@ import {
useState,
} from 'react'
import ReactDOM from 'react-dom'
import { useTranslation } from 'react-i18next'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { SegmentedControl } from '@/app/components/base/segmented-control'
import AgentNodeList from '@/app/components/workflow/nodes/_base/components/agent-node-list'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { FilePickerPanel } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel'
import { $createFileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
import { BlockEnum } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
@ -66,6 +70,7 @@ type ComponentPickerProps = {
lastRunBlock?: LastRunBlockType
agentBlock?: AgentBlockType
isSupportFileVar?: boolean
isSupportSandbox?: boolean
}
const ComponentPicker = ({
triggerString,
@ -80,7 +85,9 @@ const ComponentPicker = ({
lastRunBlock,
agentBlock,
isSupportFileVar,
isSupportSandbox,
}: ComponentPickerProps) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
@ -114,6 +121,7 @@ const ComponentPicker = ({
}, [checkForTriggerMatch, editor])
const [queryString, setQueryString] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'variables' | 'files'>('variables')
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
@ -153,6 +161,17 @@ 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()
$insertNodes([$createFileReferenceNode({ resourceId })])
})
}, [checkForTriggerMatch, editor, triggerString])
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
const match = getMatchFromSelection()
@ -227,6 +246,10 @@ const ComponentPicker = ({
const isAgentTrigger = triggerString === '@' && agentBlock?.show
const showAssembleVariables = triggerString === '/'
const agentNodes: AgentNode[] = useMemo(() => agentBlock?.agentNodes || [], [agentBlock?.agentNodes])
const handleOpen = useCallback(() => {
if (isSupportSandbox && triggerString === '/')
setActiveTab('variables')
}, [isSupportSandbox, triggerString])
const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
anchorElementRef,
@ -240,12 +263,87 @@ const ComponentPicker = ({
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
return null
}
const isSandboxMenu = isSupportSandbox && triggerString === '/'
if (!(anchorElementRef.current && (isSandboxMenu || allFlattenOptions.length || workflowVariableBlock?.show)))
return null
setTimeout(() => {
if (anchorElementRef.current)
refs.setReference(anchorElementRef.current)
}, 0)
if (isSandboxMenu) {
return (
<>
{ReactDOM.createPortal(
<div className="h-0 w-0">
<div
className="w-[300px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg"
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
ref={refs.setFloating}
>
<SegmentedControl
size="small"
padding="with"
activeState="accent"
className="w-full"
btnClassName="flex-1"
options={[
{
value: 'variables',
text: t('promptEditor.variable.outputToolDisabledItem.title', { ns: 'common' }),
},
{
value: 'files',
text: t('nodes.llm.files', { ns: 'workflow' }),
},
]}
value={activeTab}
onChange={setActiveTab}
/>
<div className="mt-2">
{activeTab === 'variables' && (
<VarReferenceVars
searchBoxClassName="mt-1"
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
handleClose()
}}
maxHeightClass="max-h-[34vh]"
isSupportFileVar={isSupportFileVar}
onClose={handleClose}
onBlur={handleClose}
showManageInputField={workflowVariableBlock?.showManageInputField}
onManageInputField={workflowVariableBlock?.onManageInputField}
autoFocus={false}
isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code}
/>
)}
{activeTab === 'files' && (
<FilePickerPanel
onSelectNode={(node) => {
handleSelectFileReference(node.id)
handleClose()
}}
className="w-full border-0 bg-transparent p-0 shadow-none"
contentClassName="px-0"
showHeader={false}
/>
)}
</div>
</div>
</div>,
anchorElementRef.current,
)}
</>
)
}
return (
<>
{
@ -261,79 +359,79 @@ const ComponentPicker = ({
>
{isAgentTrigger
? (
<AgentNodeList
nodes={agentNodes.map(node => ({
...node,
type: BlockEnum.Agent || BlockEnum.LLM,
}))}
onSelect={handleSelectAgent}
onClose={handleClose}
onBlur={handleClose}
maxHeightClass="max-h-[34vh]"
autoFocus={false}
hideSearch={useExternalSearch}
externalSearchText={useExternalSearch ? (queryString ?? '') : undefined}
enableKeyboardNavigation={useExternalSearch}
/>
)
<AgentNodeList
nodes={agentNodes.map(node => ({
...node,
type: BlockEnum.Agent || BlockEnum.LLM,
}))}
onSelect={handleSelectAgent}
onClose={handleClose}
onBlur={handleClose}
maxHeightClass="max-h-[34vh]"
autoFocus={false}
hideSearch={useExternalSearch}
externalSearchText={useExternalSearch ? (queryString ?? '') : undefined}
enableKeyboardNavigation={useExternalSearch}
/>
)
: (
<>
<>
{
workflowVariableBlock?.show && (
<div className="p-1">
<VarReferenceVars
searchBoxClassName="mt-1"
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
}}
maxHeightClass="max-h-[34vh]"
isSupportFileVar={isSupportFileVar}
onClose={handleClose}
onBlur={handleClose}
showManageInputField={workflowVariableBlock.showManageInputField}
onManageInputField={workflowVariableBlock.onManageInputField}
showAssembleVariables={showAssembleVariables}
onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined}
autoFocus={false}
isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code}
hideSearch={useExternalSearch}
externalSearchText={useExternalSearch ? (queryString ?? '') : undefined}
enableKeyboardNavigation={useExternalSearch}
/>
</div>
)
}
{
workflowVariableBlock?.show && !!options.length && (
<div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div>
)
}
<div>
{
workflowVariableBlock?.show && (
<div className="p-1">
<VarReferenceVars
searchBoxClassName="mt-1"
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
}}
maxHeightClass="max-h-[34vh]"
isSupportFileVar={isSupportFileVar}
onClose={handleClose}
onBlur={handleClose}
showManageInputField={workflowVariableBlock.showManageInputField}
onManageInputField={workflowVariableBlock.onManageInputField}
showAssembleVariables={showAssembleVariables}
onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined}
autoFocus={false}
isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code}
hideSearch={useExternalSearch}
externalSearchText={useExternalSearch ? (queryString ?? '') : undefined}
enableKeyboardNavigation={useExternalSearch}
/>
</div>
)
options.map((option, index) => (
<Fragment key={option.key}>
{
index !== 0 && options.at(index - 1)?.group !== option.group && (
<div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
{
workflowVariableBlock?.show && !!options.length && (
<div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div>
)
}
<div>
{
options.map((option, index) => (
<Fragment key={option.key}>
{
index !== 0 && options.at(index - 1)?.group !== option.group && (
<div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
</div>
</>
)}
</div>
</>
)}
</div>
</div>,
anchorElementRef.current,
@ -341,13 +439,14 @@ const ComponentPicker = ({
}
</>
)
}, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables, useExternalSearch])
}, [isAgentTrigger, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock.showManageInputField, workflowVariableBlock.onManageInputField, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference])
return (
<LexicalTypeaheadMenuPlugin
options={allFlattenOptions}
options={(isSupportSandbox && triggerString === '/') ? [] : allFlattenOptions}
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
onOpen={handleOpen}
// The `translate` class is used to workaround the issue that the `typeahead-menu` menu is not positioned as expected.
// See also https://github.com/facebook/lexical/blob/772520509308e8ba7e4a82b6cd1996a78b3298d0/packages/lexical-react/src/shared/LexicalMenu.ts#L498
//

View File

@ -62,6 +62,7 @@ type Props = {
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
isSupportFileVar?: boolean
isSupportSandbox?: boolean
isSupportPromptGenerator?: boolean
onGenerated?: (prompt: string) => void
modelConfig?: ModelConfig
@ -102,6 +103,7 @@ const Editor: FC<Props> = ({
nodesOutputVars,
availableNodes = [],
isSupportFileVar,
isSupportSandbox,
isSupportPromptGenerator,
isSupportJinja,
editionType,
@ -295,6 +297,7 @@ const Editor: FC<Props> = ({
onFocus={setFocus}
editable={!readOnly}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className="absolute inset-0 z-10"></div>}

View File

@ -40,6 +40,7 @@ type Props = {
varList: Variable[]
handleAddVariable: (payload: any) => void
modelConfig?: ModelConfig
isSupportSandbox?: boolean
}
const roleOptions = [
@ -82,6 +83,7 @@ const ConfigPromptItem: FC<Props> = ({
varList,
handleAddVariable,
modelConfig,
isSupportSandbox,
}) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
@ -149,6 +151,7 @@ const ConfigPromptItem: FC<Props> = ({
varList={varList}
handleAddVariable={handleAddVariable}
isSupportFileVar
isSupportSandbox={isSupportSandbox}
/>
)
}

View File

@ -7,6 +7,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { v4 as uuid4 } from 'uuid'
import { useFeatures } from '@/app/components/base/features/hooks'
import { DragHandle } from '@/app/components/base/icons/src/vender/line/others'
import {
PortalToFollowElem,
@ -59,6 +60,8 @@ const ConfigPrompt: FC<Props> = ({
}) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const features = useFeatures(s => s.features)
const isSupportSandbox = !!features.sandbox?.enabled
const {
setControlPromptEditorRerenderKey,
} = workflowStore.getState()
@ -337,6 +340,7 @@ const ConfigPrompt: FC<Props> = ({
varList={varList}
handleAddVariable={handleAddVariable}
modelConfig={modelConfig}
isSupportSandbox={isSupportSandbox}
/>
</div>
)
@ -410,6 +414,7 @@ const ConfigPrompt: FC<Props> = ({
handleAddVariable={handleAddVariable}
onGenerated={handleGenerated}
modelConfig={modelConfig}
isSupportSandbox={isSupportSandbox}
/>
</div>
)}

View File

@ -109,11 +109,17 @@ FilePickerTreeNode.displayName = 'FilePickerTreeNode'
type FilePickerPanelProps = {
onSelectNode: (node: TreeNodeData) => void
focusNodeId?: string
className?: string
contentClassName?: string
showHeader?: boolean
}
const FilePickerPanel: FC<FilePickerPanelProps> = ({
onSelectNode,
focusNodeId,
className,
contentClassName,
showHeader = true,
}) => {
const { t } = useTranslation('workflow')
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
@ -141,7 +147,10 @@ const FilePickerPanel: FC<FilePickerPanelProps> = ({
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"
className={cn(
'w-[280px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm',
className,
)}
onMouseDown={(e) => {
const target = e.target as HTMLElement
if (target.closest('input, textarea, select'))
@ -149,13 +158,22 @@ const FilePickerPanel: FC<FilePickerPanelProps> = ({
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">
{showHeader && (
<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={cn(
'max-h-[320px] min-h-[120px] px-2 pb-2',
!showHeader && 'pt-2',
contentClassName,
)}
>
{isLoading && (
<div className="flex h-full items-center justify-center py-6">
<Loading type="area" />