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 048a7d4027..2240c70169 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 @@ -1,6 +1,7 @@ import type { LexicalNode } from 'lexical' import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' import type { TreeNodeData } from '@/app/components/workflow/skill/type' +import type { AppAssetTreeView } from '@/types/app-asset' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { RiAlertFill, RiFolderLine } from '@remixicon/react' import { $getNodeByKey } from 'lexical' @@ -16,8 +17,10 @@ import { } from '@/app/components/base/portal-to-follow-elem' import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks' import Tooltip from '@/app/components/base/tooltip' +import { START_TAB_ID } from '@/app/components/workflow/skill/constants' import { useSkillAssetNodeMap } from '@/app/components/workflow/skill/hooks/use-skill-asset-tree' import { getFileIconType } from '@/app/components/workflow/skill/utils/file-utils' +import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { FilePickerPanel } from '../file-picker-panel' import FilePreviewPanel from './file-preview-panel' @@ -28,10 +31,16 @@ type FileReferenceBlockProps = { resourceId: string } +type SkillFileMetadata = { + files?: Record +} + const FileReferenceBlock = ({ nodeKey, resourceId }: FileReferenceBlockProps) => { const [editor] = useLexicalComposerContext() const [ref, isSelected] = useSelectOrDelete(nodeKey) const { data: nodeMap, isLoading: isNodeMapLoading } = useSkillAssetNodeMap() + const activeTabId = useStore(s => s.activeTabId) + const fileMetadata = useStore(s => s.fileMetadata) const [open, setOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false) const [previewStyle, setPreviewStyle] = useState(null) @@ -40,7 +49,16 @@ const FileReferenceBlock = ({ nodeKey, resourceId }: FileReferenceBlockProps) => const { t } = useTranslation() const isInteractive = editor.isEditable() - const currentNode = useMemo(() => nodeMap?.get(resourceId), [nodeMap, resourceId]) + const metadataFiles = useMemo(() => { + if (!activeTabId || activeTabId === START_TAB_ID) + return undefined + const metadata = fileMetadata.get(activeTabId) as SkillFileMetadata | undefined + return metadata?.files + }, [activeTabId, fileMetadata]) + + const treeNode = useMemo(() => nodeMap?.get(resourceId), [nodeMap, resourceId]) + const metadataNode = useMemo(() => metadataFiles?.[resourceId], [metadataFiles, resourceId]) + const currentNode = useMemo(() => treeNode ?? metadataNode, [metadataNode, treeNode]) const fallbackName = useMemo(() => { if (resourceId.includes('/')) { @@ -50,7 +68,7 @@ const FileReferenceBlock = ({ nodeKey, resourceId }: FileReferenceBlockProps) => return resourceId.slice(0, 8) }, [resourceId]) const isFolder = currentNode?.node_type === 'folder' - const isMissing = !isNodeMapLoading && !currentNode + const isMissing = !isNodeMapLoading && !treeNode const shouldPreview = isPreviewEnabled && !isFolder && !isMissing const displayName = currentNode?.name ?? fallbackName const iconType = !isFolder && currentNode diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx index 6e88dd633e..18b0f82e30 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx @@ -2,6 +2,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import type { Emoji } from '@/app/components/tools/types' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { AppAssetTreeView } from '@/types/app-asset' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { RiAlertFill } from '@remixicon/react' import * as React from 'react' @@ -73,6 +74,7 @@ type ToolConfigMetadata = { type SkillFileMetadata = { tools?: Record + files?: Record } const getVarKindType = (type: FormTypeEnum | string) => { diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index c6e101ac59..ec2f0825a5 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -1,7 +1,9 @@ 'use client' import type { OnMount } from '@monaco-editor/react' +import type { AppAssetTreeView } from '@/types/app-asset' import { loader } from '@monaco-editor/react' +import isDeepEqual from 'fast-deep-equal' import dynamic from 'next/dynamic' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' @@ -26,6 +28,23 @@ import { getFileLanguage } from './utils/file-utils' import MediaFilePreview from './viewer/media-file-preview' import UnsupportedFileDownload from './viewer/unsupported-file-download' +type SkillFileMetadata = { + files?: Record +} + +const extractFileReferenceIds = (content: string) => { + const ids = new Set() + const regex = /§\[file\]\.\[app\]\.\[([a-fA-F0-9-]{36})\]§/g + let match: RegExpExecArray | null + match = regex.exec(content) + while (match !== null) { + if (match[1]) + ids.add(match[1]) + match = regex.exec(content) + } + return ids +} + const SQLiteFilePreview = dynamic( () => import('./viewer/sqlite-file-preview'), { ssr: false, loading: () => }, @@ -101,6 +120,34 @@ const FileContentPanel = () => { clearDraftMetadata(fileTabId) }, [fileTabId, isMetadataDirty, fileContent, storeApi]) + const updateFileReferenceMetadata = useCallback((content: string) => { + if (!fileTabId) + return + + const referenceIds = extractFileReferenceIds(content) + const metadata = (currentMetadata || {}) as SkillFileMetadata + const existingFiles = metadata.files || {} + const nextFiles: Record = {} + + referenceIds.forEach((id) => { + const node = nodeMap?.get(id) + if (node) + nextFiles[id] = node + else if (existingFiles[id]) + nextFiles[id] = existingFiles[id] + }) + + const nextMetadata: SkillFileMetadata = { ...metadata } + if (Object.keys(nextFiles).length > 0) + nextMetadata.files = nextFiles + else if ('files' in nextMetadata) + delete nextMetadata.files + + if (isDeepEqual(metadata, nextMetadata)) + return + storeApi.getState().setDraftMetadata(fileTabId, nextMetadata) + }, [currentMetadata, fileTabId, nodeMap, storeApi]) + const handleEditorChange = useCallback((value: string | undefined) => { if (!fileTabId || !isEditable) return @@ -110,9 +157,9 @@ const FileContentPanel = () => { storeApi.getState().clearDraftContent(fileTabId) else storeApi.getState().setDraftContent(fileTabId, newValue) - + updateFileReferenceMetadata(newValue) storeApi.getState().pinTab(fileTabId) - }, [fileTabId, isEditable, originalContent, storeApi]) + }, [fileTabId, isEditable, originalContent, storeApi, updateFileReferenceMetadata]) const { saveFile, registerFallback, unregisterFallback } = useSkillSaveManager() const handleLeaderSync = useCallback(() => {