From 76484406a22061ad50b36a91c0b8013fd58029bf Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 29 Jan 2026 16:42:22 +0800 Subject: [PATCH] feat(inspect): add read-only file preview in ArtifactsTab Implement ReadOnlyFilePreview to render sandbox files by type (code, markdown, image, video, SQLite, unsupported) using existing skill viewer components with readOnly support. Add useSandboxFileDownloadUrl and useFetchTextContent hooks for data fetching, and generalize useFileTypeInfo to accept any file-like object. --- .../skill/editor/code-file-editor.tsx | 5 +- .../skill/editor/markdown-file-editor.tsx | 33 ++++---- .../skill/hooks/use-fetch-text-content.ts | 10 +++ .../skill/hooks/use-file-type-info.ts | 5 +- .../skill/viewer/read-only-code-preview.tsx | 47 +++++++++++ .../skill/viewer/read-only-file-preview.tsx | 80 +++++++++++++++++++ .../viewer/read-only-markdown-preview.tsx | 22 +++++ .../variable-inspect/artifacts-tab.tsx | 33 ++++++-- web/service/use-sandbox-file.ts | 14 ++++ 9 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-fetch-text-content.ts create mode 100644 web/app/components/workflow/skill/viewer/read-only-code-preview.tsx create mode 100644 web/app/components/workflow/skill/viewer/read-only-file-preview.tsx create mode 100644 web/app/components/workflow/skill/viewer/read-only-markdown-preview.tsx diff --git a/web/app/components/workflow/skill/editor/code-file-editor.tsx b/web/app/components/workflow/skill/editor/code-file-editor.tsx index 9b6aa12c3b..216576e1d4 100644 --- a/web/app/components/workflow/skill/editor/code-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/code-file-editor.tsx @@ -12,6 +12,7 @@ type CodeFileEditorProps = { onMount: OnMount fileId?: string | null collaborationEnabled?: boolean + readOnly?: boolean } const CodeFileEditor = ({ @@ -22,12 +23,13 @@ const CodeFileEditor = ({ onMount, fileId, collaborationEnabled, + readOnly, }: CodeFileEditorProps) => { const [editorInstance, setEditorInstance] = React.useState[0] | null>(null) const { overlay } = useSkillCodeCursors({ editor: editorInstance, fileId: fileId ?? null, - enabled: Boolean(collaborationEnabled && fileId), + enabled: Boolean(collaborationEnabled && fileId && !readOnly), }) const handleMount = React.useCallback((editor, monaco) => { setEditorInstance(editor) @@ -53,6 +55,7 @@ const CodeFileEditor = ({ fontSize: 13, lineHeight: 20, padding: { top: 12, bottom: 12 }, + readOnly, }} onMount={handleMount} /> diff --git a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx index df3d9045eb..a61f139123 100644 --- a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx @@ -7,6 +7,7 @@ type MarkdownFileEditorProps = { value: string onChange: (value: string) => void collaborationEnabled?: boolean + readOnly?: boolean } const MarkdownFileEditor = ({ @@ -14,6 +15,7 @@ const MarkdownFileEditor = ({ value, onChange, collaborationEnabled, + readOnly, }: MarkdownFileEditorProps) => { const { t } = useTranslation() const handleChange = React.useCallback((val: string) => { @@ -28,23 +30,26 @@ const MarkdownFileEditor = ({ instanceId={instanceId} value={value} onChange={handleChange} - collaborationEnabled={collaborationEnabled} + editable={!readOnly} + collaborationEnabled={readOnly ? false : collaborationEnabled} showLineNumbers className="h-full" wrapperClassName="h-full" - placeholder={( - - {t('promptEditor.skillMarkdown.placeholderPrefix', { ns: 'common' })} - / - - {t('promptEditor.skillMarkdown.placeholderReferenceFiles', { ns: 'common' })} - - @ - - {t('promptEditor.skillMarkdown.placeholderUseTools', { ns: 'common' })} - - - )} + placeholder={readOnly + ? undefined + : ( + + {t('promptEditor.skillMarkdown.placeholderPrefix', { ns: 'common' })} + / + + {t('promptEditor.skillMarkdown.placeholderReferenceFiles', { ns: 'common' })} + + @ + + {t('promptEditor.skillMarkdown.placeholderUseTools', { ns: 'common' })} + + + )} /> ) diff --git a/web/app/components/workflow/skill/hooks/use-fetch-text-content.ts b/web/app/components/workflow/skill/hooks/use-fetch-text-content.ts new file mode 100644 index 0000000000..9b950fd1eb --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-fetch-text-content.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' + +export function useFetchTextContent(downloadUrl: string | undefined) { + return useQuery({ + queryKey: ['fileTextContent', downloadUrl], + queryFn: () => fetch(downloadUrl!).then(r => r.text()), + enabled: !!downloadUrl, + staleTime: Infinity, + }) +} diff --git a/web/app/components/workflow/skill/hooks/use-file-type-info.ts b/web/app/components/workflow/skill/hooks/use-file-type-info.ts index 2e888f79c8..a867950c44 100644 --- a/web/app/components/workflow/skill/hooks/use-file-type-info.ts +++ b/web/app/components/workflow/skill/hooks/use-file-type-info.ts @@ -1,4 +1,3 @@ -import type { AppAssetTreeView } from '@/types/app-asset' import { useMemo } from 'react' import { getFileExtension, @@ -19,9 +18,9 @@ export type FileTypeInfo = { isMediaFile: boolean } -export function useFileTypeInfo(fileNode: AppAssetTreeView | undefined): FileTypeInfo { +export function useFileTypeInfo(fileNode: { name: string, extension?: string | null } | undefined): FileTypeInfo { return useMemo(() => { - const ext = getFileExtension(fileNode?.name, fileNode?.extension) + const ext = getFileExtension(fileNode?.name, fileNode?.extension ?? undefined) const markdown = isMarkdownFile(ext) const image = isImageFile(ext) const video = isVideoFile(ext) diff --git a/web/app/components/workflow/skill/viewer/read-only-code-preview.tsx b/web/app/components/workflow/skill/viewer/read-only-code-preview.tsx new file mode 100644 index 0000000000..54ca616da7 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/read-only-code-preview.tsx @@ -0,0 +1,47 @@ +'use client' + +import type { OnMount } from '@monaco-editor/react' +import { loader } from '@monaco-editor/react' +import * as React from 'react' +import { useCallback, useRef, useState } from 'react' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { basePath } from '@/utils/var' +import CodeFileEditor from '../editor/code-file-editor' + +if (typeof window !== 'undefined') + loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } }) + +type ReadOnlyCodePreviewProps = { + value: string + language: string +} + +const ReadOnlyCodePreview = ({ value, language }: ReadOnlyCodePreviewProps) => { + const { theme: appTheme } = useTheme() + const [isMounted, setIsMounted] = useState(false) + const editorRef = useRef[0] | null>(null) + + const theme = appTheme === Theme.light ? 'light' : 'vs-dark' + + const handleMount: OnMount = useCallback((editor, monaco) => { + editorRef.current = editor + monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') + setIsMounted(true) + }, [appTheme]) + + const noop = useCallback(() => {}, []) + + return ( + + ) +} + +export default React.memo(ReadOnlyCodePreview) diff --git a/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx b/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx new file mode 100644 index 0000000000..deb5c9a060 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx @@ -0,0 +1,80 @@ +'use client' + +import dynamic from 'next/dynamic' +import * as React from 'react' +import Loading from '@/app/components/base/loading' +import { useFetchTextContent } from '../hooks/use-fetch-text-content' +import { useFileTypeInfo } from '../hooks/use-file-type-info' +import { getFileLanguage } from '../utils/file-utils' +import MediaFilePreview from './media-file-preview' +import UnsupportedFileDownload from './unsupported-file-download' + +const ReadOnlyCodePreview = dynamic( + () => import('./read-only-code-preview'), + { ssr: false, loading: () => }, +) + +const ReadOnlyMarkdownPreview = dynamic( + () => import('./read-only-markdown-preview'), + { ssr: false, loading: () => }, +) + +const SQLiteFilePreview = dynamic( + () => import('./sqlite-file-preview'), + { ssr: false, loading: () => }, +) + +type ReadOnlyFilePreviewProps = { + downloadUrl: string + fileName: string + extension?: string | null + fileSize?: number | null +} + +const ReadOnlyFilePreview = ({ + downloadUrl, + fileName, + extension, + fileSize, +}: ReadOnlyFilePreviewProps) => { + const fileNode = React.useMemo( + () => ({ name: fileName, extension }), + [fileName, extension], + ) + const { isMarkdown, isCodeOrText, isImage, isVideo, isSQLite } = useFileTypeInfo(fileNode) + const isTextFile = isMarkdown || isCodeOrText + const { data: textContent, isLoading: isTextLoading } = useFetchTextContent( + isTextFile ? downloadUrl : undefined, + ) + + if (isTextFile && isTextLoading) + return + + if (isMarkdown) + return + + if (isCodeOrText) { + return ( + + ) + } + + if (isImage || isVideo) + return + + if (isSQLite) + return + + return ( + + ) +} + +export default React.memo(ReadOnlyFilePreview) diff --git a/web/app/components/workflow/skill/viewer/read-only-markdown-preview.tsx b/web/app/components/workflow/skill/viewer/read-only-markdown-preview.tsx new file mode 100644 index 0000000000..8c6aaa4970 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/read-only-markdown-preview.tsx @@ -0,0 +1,22 @@ +'use client' + +import * as React from 'react' +import MarkdownFileEditor from '../editor/markdown-file-editor' + +type ReadOnlyMarkdownPreviewProps = { + value: string +} + +const noop = () => {} + +const ReadOnlyMarkdownPreview = ({ value }: ReadOnlyMarkdownPreviewProps) => { + return ( + + ) +} + +export default React.memo(ReadOnlyMarkdownPreview) diff --git a/web/app/components/workflow/variable-inspect/artifacts-tab.tsx b/web/app/components/workflow/variable-inspect/artifacts-tab.tsx index 41235d1137..bfec17b113 100644 --- a/web/app/components/workflow/variable-inspect/artifacts-tab.tsx +++ b/web/app/components/workflow/variable-inspect/artifacts-tab.tsx @@ -11,9 +11,10 @@ import SearchLinesSparkle from '@/app/components/base/icons/src/vender/knowledge import { FileDownload01 } from '@/app/components/base/icons/src/vender/line/files' import Loading from '@/app/components/base/loading' import ArtifactsTree from '@/app/components/workflow/skill/file-tree/artifacts-tree' +import ReadOnlyFilePreview from '@/app/components/workflow/skill/viewer/read-only-file-preview' import { useAppContext } from '@/context/app-context' import { useDocLink } from '@/context/i18n' -import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file' +import { useDownloadSandboxFile, useSandboxFileDownloadUrl, useSandboxFilesTree } from '@/service/use-sandbox-file' import { cn } from '@/utils/classnames' import InspectLayout from './inspect-layout' import SplitPanel from './split-panel' @@ -60,8 +61,11 @@ const ArtifactsTab = (headerProps: InspectHeaderProps) => { enabled: !!sandboxId, }) const downloadMutation = useDownloadSandboxFile(sandboxId) - const [selectedFile, setSelectedFile] = useState(null) + const { data: downloadUrlData, isLoading: isDownloadUrlLoading } = useSandboxFileDownloadUrl( + sandboxId, + selectedFile?.path, + ) const handleFileSelect = useCallback((node: SandboxFileTreeNode) => { if (node.node_type === 'file') @@ -172,12 +176,25 @@ const ArtifactsTab = (headerProps: InspectHeaderProps) => {
{file ? ( -
-
-

- {t('debug.variableInspect.tabArtifacts.previewNotAvailable')} -

-
+
+ {isDownloadUrlLoading + ? + : downloadUrlData?.download_url + ? ( + + ) + : ( +
+

+ {t('debug.variableInspect.tabArtifacts.previewNotAvailable')} +

+
+ )}
) : ( diff --git a/web/service/use-sandbox-file.ts b/web/service/use-sandbox-file.ts index 983cccd05b..9603f8d20c 100644 --- a/web/service/use-sandbox-file.ts +++ b/web/service/use-sandbox-file.ts @@ -53,6 +53,20 @@ export function useDownloadSandboxFile(sandboxId: string | undefined) { }) } +export function useSandboxFileDownloadUrl( + sandboxId: string | undefined, + path: string | undefined, +) { + return useQuery({ + queryKey: ['sandboxFileDownloadUrl', sandboxId, path], + queryFn: () => consoleClient.sandboxFile.downloadFile({ + params: { sandboxId: sandboxId! }, + body: { path: path! }, + }), + enabled: !!sandboxId && !!path, + }) +} + function buildTreeFromFlatList(nodes: SandboxFileNode[]): SandboxFileTreeNode[] { const nodeMap = new Map() const roots: SandboxFileTreeNode[] = []