diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 2dcc8beed0..86335f05b6 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -34,6 +34,7 @@ import * as React from 'react' import { useEffect } from 'react' import { Trans } from 'react-i18next' import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node' +import { FilePreviewContextProvider } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/preview-context' import FileReferenceReplacementBlock from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block' import { ToolBlock, @@ -316,47 +317,33 @@ const PromptEditor: FC = ({ return ( -
- - )} - placeholder={( - - )} - ErrorBoundary={LexicalErrorBoundary} - /> - - {!isSupportSandbox && (!agentBlock || agentBlock.show) && ( + +
+ + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> = ({ currentBlock={currentBlock} errorMessageBlock={errorMessageBlock} lastRunBlock={lastRunBlock} - agentBlock={agentBlock} isSupportFileVar={isSupportFileVar} + isSupportSandbox={isSupportSandbox} /> - )} - {isSupportSandbox && ( - <> - - - - {editable && !disableToolBlocks && } - - )} - - { - contextBlock?.show && ( + {!isSupportSandbox && (!agentBlock || agentBlock.show) && ( + + )} + {isSupportSandbox && ( <> - - + + + + {editable && !disableToolBlocks && } - ) - } - { - queryBlock?.show && ( - <> - - - - ) - } - { - historyBlock?.show && ( - <> - - - - ) - } - { - (variableBlock?.show || externalToolBlock?.show) && ( - <> - + )} + + { + contextBlock?.show && ( + <> + + + + ) + } + { + queryBlock?.show && ( + <> + + + + ) + } + { + historyBlock?.show && ( + <> + + + + ) + } + { + (variableBlock?.show || externalToolBlock?.show) && ( + <> + + + + ) + } + { + workflowVariableBlock?.show && ( + <> + + + + ) + } + {isSupportSandbox && } + { + currentBlock?.show && ( + <> + + + + ) + } + { + errorMessageBlock?.show && ( + <> + + + + ) + } + { + lastRunBlock?.show && ( + <> + + + + ) + } + { + isSupportFileVar && ( - - ) - } - { - workflowVariableBlock?.show && ( - <> - - - - ) - } - {isSupportSandbox && } - { - currentBlock?.show && ( - <> - - - - ) - } - { - errorMessageBlock?.show && ( - <> - - - - ) - } - { - lastRunBlock?.show && ( - <> - - - - ) - } - { - isSupportFileVar && ( - - ) - } - - - - - - - {/* */} -
+ ) + } + + + + + + + {/* */} +
+
) diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx index b3275e8662..d8b91fb730 100644 --- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx @@ -168,7 +168,16 @@ const Editor: FC = ({ return ( -
+
diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 8baef05eff..9fa9b10d23 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -517,6 +517,7 @@ const BasePanel: FC = ({
-
- - )} - placeholder={( - - )} - ErrorBoundary={LexicalErrorBoundary} - /> - <> - - - - - {editable && } - {editable && } - - - - - - - -
+ +
+ + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> + <> + + + + + {editable && } + {editable && } + + + + + + + +
+
) } 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 index aca2f34ddd..61c679e08d 100644 --- 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 @@ -5,7 +5,8 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' import { PortalToFollowElem, @@ -17,6 +18,8 @@ import { useSkillAssetNodeMap } from '@/app/components/workflow/skill/hooks/use- import { getFileIconType } from '@/app/components/workflow/skill/utils/file-utils' import { cn } from '@/utils/classnames' import { FilePickerPanel } from '../file-picker-panel' +import FilePreviewPanel from './file-preview-panel' +import { useFilePreviewContext } from './preview-context' type FileReferenceBlockProps = { nodeKey: string @@ -28,6 +31,10 @@ const FileReferenceBlock = ({ nodeKey, resourceId }: FileReferenceBlockProps) => const [ref, isSelected] = useSelectOrDelete(nodeKey) const { data: nodeMap } = useSkillAssetNodeMap() const [open, setOpen] = useState(false) + const [previewOpen, setPreviewOpen] = useState(false) + const [previewStyle, setPreviewStyle] = useState(null) + const closeTimerRef = useRef | null>(null) + const { enabled: isPreviewEnabled } = useFilePreviewContext() const currentNode = useMemo(() => nodeMap?.get(resourceId), [nodeMap, resourceId]) const isFolder = currentNode?.node_type === 'folder' @@ -46,7 +53,69 @@ const FileReferenceBlock = ({ nodeKey, resourceId }: FileReferenceBlockProps) => setOpen(false) }, [editor, nodeKey]) - return ( + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current) + closeTimerRef.current = null + } + }, []) + + const handlePreviewEnter = useCallback(() => { + clearCloseTimer() + setPreviewOpen(true) + }, [clearCloseTimer]) + + const handlePreviewLeave = useCallback(() => { + clearCloseTimer() + closeTimerRef.current = setTimeout(() => { + setPreviewOpen(false) + }, 120) + }, [clearCloseTimer]) + + useEffect(() => { + return () => { + clearCloseTimer() + } + }, [clearCloseTimer]) + + const updatePreviewPosition = useCallback(() => { + const anchor = ref.current?.closest('[data-workflow-node-panel="true"]') as HTMLElement | null + || ref.current?.closest('[data-prompt-editor-panel="true"]') as HTMLElement | null + || ref.current?.closest('[data-skill-editor-root="true"]') as HTMLElement | null + if (!anchor) + return + const rect = anchor.getBoundingClientRect() + const width = 400 + const gap = 4 + const left = Math.max(8, rect.left - gap - width) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setPreviewStyle(_prev => ({ + position: 'fixed', + top: rect.top, + left, + height: rect.height, + })) + }, [ref]) + + useEffect(() => { + if (!previewOpen || !isPreviewEnabled) + return + updatePreviewPosition() + const handleUpdate = () => updatePreviewPosition() + window.addEventListener('scroll', handleUpdate, true) + window.addEventListener('resize', handleUpdate) + const anchor = ref.current?.closest('[data-skill-editor-root="true"]') as HTMLElement | null + const resizeObserver = anchor ? new ResizeObserver(handleUpdate) : null + if (anchor && resizeObserver) + resizeObserver.observe(anchor) + return () => { + window.removeEventListener('scroll', handleUpdate, true) + window.removeEventListener('resize', handleUpdate) + resizeObserver?.disconnect() + } + }, [isPreviewEnabled, previewOpen, ref, updatePreviewPosition]) + + const fileBlock = ( ) + + if (!isPreviewEnabled) + return fileBlock + + return ( + <> + + {fileBlock} + + {previewOpen && previewStyle && typeof document !== 'undefined' && createPortal( +
+ setPreviewOpen(false)} + /> +
, + document.body, + )} + + ) } export default React.memo(FileReferenceBlock) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/file-preview-panel.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/file-preview-panel.tsx new file mode 100644 index 0000000000..69bef12e94 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/file-preview-panel.tsx @@ -0,0 +1,160 @@ +import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' +import type { AppAssetTreeView } from '@/types/app-asset' +import { RiCloseLine, RiExternalLinkLine, RiFolderLine } from '@remixicon/react' +import * as React from 'react' +import { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import Loading from '@/app/components/base/loading' +import { WorkflowContext } from '@/app/components/workflow/context' +import SkillEditor from '@/app/components/workflow/skill/editor/skill-editor' +import { useFileTypeInfo } from '@/app/components/workflow/skill/hooks/use-file-type-info' +import { getFileIconType } from '@/app/components/workflow/skill/utils/file-utils' +import { useGetAppAssetFileContent } from '@/service/use-app-asset' +import { cn } from '@/utils/classnames' + +type FilePreviewPanelProps = { + resourceId: string + currentNode?: AppAssetTreeView + className?: string + style?: React.CSSProperties + onClose?: () => void +} + +const FilePreviewPanel = ({ resourceId, currentNode, className, style, onClose }: FilePreviewPanelProps) => { + const { t } = useTranslation(['workflow', 'common']) + const workflowStore = useContext(WorkflowContext) + const appId = useAppStore(s => s.appDetail?.id || '') + + const isFolder = currentNode?.node_type === 'folder' + const fileTypeInfo = useFileTypeInfo(isFolder ? undefined : currentNode) + const canPreviewText = !isFolder && fileTypeInfo.isEditable + + const { data: fileContent, isLoading, error } = useGetAppAssetFileContent(appId, resourceId, { + enabled: canPreviewText, + }) + + const content = useMemo(() => { + if (!canPreviewText || !fileContent) + return '' + if (typeof fileContent?.content === 'string') + return fileContent.content + return JSON.stringify(fileContent, null, 2) + }, [canPreviewText, fileContent]) + + const pathSegments = useMemo( + () => (currentNode?.path ?? '').split('/').filter(Boolean), + [currentNode?.path], + ) + + const folderName = isFolder + ? (currentNode?.name ?? resourceId) + : (pathSegments.length > 1 ? pathSegments[0] : null) + const fileName = isFolder + ? null + : (pathSegments[pathSegments.length - 1] ?? currentNode?.name ?? resourceId) + const iconType = !isFolder && currentNode + ? getFileIconType(currentNode.name, currentNode.extension) + : null + + const canOpenInEditor = Boolean(resourceId && !isFolder && workflowStore) + + const handleOpenInEditor = useCallback(() => { + if (!canOpenInEditor || !workflowStore) + return + workflowStore.getState().openTab(resourceId) + }, [canOpenInEditor, workflowStore, resourceId]) + + return ( +
+
+
+ {folderName && ( +
+
+ )} + {folderName && fileName && ( + / + )} + {fileName && ( +
+ + + {fileName} + +
+ )} +
+ + +
+
+ {isFolder && ( +
+ {t('skillEditor.previewUnavailable')} +
+ )} + {!isFolder && !fileTypeInfo.isEditable && ( +
+ {t('skillEditor.unsupportedPreview')} +
+ )} + {canPreviewText && isLoading && ( +
+ +
+ )} + {canPreviewText && error && ( +
+ {t('skillSidebar.loadError')} +
+ )} + {canPreviewText && !isLoading && !error && ( + + )} +
+
+ ) +} + +export default React.memo(FilePreviewPanel) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/preview-context.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/preview-context.tsx new file mode 100644 index 0000000000..a801f9c9a8 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/preview-context.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' + +type FilePreviewContextValue = { + enabled: boolean +} + +const FilePreviewContext = React.createContext({ enabled: false }) + +export const FilePreviewContextProvider = FilePreviewContext.Provider + +// eslint-disable-next-line react-refresh/only-export-components +export const useFilePreviewContext = () => { + return React.useContext(FilePreviewContext) +}