mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
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.
This commit is contained in:
parent
079484d21c
commit
76484406a2
@ -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<Parameters<typeof onMount>[0] | null>(null)
|
||||
const { overlay } = useSkillCodeCursors({
|
||||
editor: editorInstance,
|
||||
fileId: fileId ?? null,
|
||||
enabled: Boolean(collaborationEnabled && fileId),
|
||||
enabled: Boolean(collaborationEnabled && fileId && !readOnly),
|
||||
})
|
||||
const handleMount = React.useCallback<OnMount>((editor, monaco) => {
|
||||
setEditorInstance(editor)
|
||||
@ -53,6 +55,7 @@ const CodeFileEditor = ({
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
readOnly,
|
||||
}}
|
||||
onMount={handleMount}
|
||||
/>
|
||||
|
||||
@ -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={(
|
||||
<span className="flex items-center gap-1 text-components-input-text-placeholder">
|
||||
<span>{t('promptEditor.skillMarkdown.placeholderPrefix', { ns: 'common' })}</span>
|
||||
<span className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-placeholder">/</span>
|
||||
<span className="text-[13px] leading-4 underline decoration-dotted">
|
||||
{t('promptEditor.skillMarkdown.placeholderReferenceFiles', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-placeholder">@</span>
|
||||
<span className="text-[13px] leading-4 underline decoration-dotted">
|
||||
{t('promptEditor.skillMarkdown.placeholderUseTools', { ns: 'common' })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
placeholder={readOnly
|
||||
? undefined
|
||||
: (
|
||||
<span className="flex items-center gap-1 text-components-input-text-placeholder">
|
||||
<span>{t('promptEditor.skillMarkdown.placeholderPrefix', { ns: 'common' })}</span>
|
||||
<span className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-placeholder">/</span>
|
||||
<span className="text-[13px] leading-4 underline decoration-dotted">
|
||||
{t('promptEditor.skillMarkdown.placeholderReferenceFiles', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-placeholder">@</span>
|
||||
<span className="text-[13px] leading-4 underline decoration-dotted">
|
||||
{t('promptEditor.skillMarkdown.placeholderUseTools', { ns: 'common' })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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<Parameters<OnMount>[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 (
|
||||
<CodeFileEditor
|
||||
language={language}
|
||||
theme={isMounted ? theme : 'default-theme'}
|
||||
value={value}
|
||||
onChange={noop}
|
||||
onMount={handleMount}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ReadOnlyCodePreview)
|
||||
@ -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: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
const ReadOnlyMarkdownPreview = dynamic(
|
||||
() => import('./read-only-markdown-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
const SQLiteFilePreview = dynamic(
|
||||
() => import('./sqlite-file-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
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 <Loading type="area" />
|
||||
|
||||
if (isMarkdown)
|
||||
return <ReadOnlyMarkdownPreview value={textContent ?? ''} />
|
||||
|
||||
if (isCodeOrText) {
|
||||
return (
|
||||
<ReadOnlyCodePreview
|
||||
value={textContent ?? ''}
|
||||
language={getFileLanguage(fileName)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isImage || isVideo)
|
||||
return <MediaFilePreview type={isImage ? 'image' : 'video'} src={downloadUrl} />
|
||||
|
||||
if (isSQLite)
|
||||
return <SQLiteFilePreview downloadUrl={downloadUrl} />
|
||||
|
||||
return (
|
||||
<UnsupportedFileDownload
|
||||
name={fileName}
|
||||
size={fileSize ?? undefined}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ReadOnlyFilePreview)
|
||||
@ -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 (
|
||||
<MarkdownFileEditor
|
||||
value={value}
|
||||
onChange={noop}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ReadOnlyMarkdownPreview)
|
||||
@ -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<SandboxFileTreeNode | null>(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) => {
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{file
|
||||
? (
|
||||
<div className="grow overflow-auto p-2">
|
||||
<div className="flex h-full items-center justify-center rounded-xl bg-background-section">
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('debug.variableInspect.tabArtifacts.previewNotAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 grow">
|
||||
{isDownloadUrlLoading
|
||||
? <Loading type="area" />
|
||||
: downloadUrlData?.download_url
|
||||
? (
|
||||
<ReadOnlyFilePreview
|
||||
downloadUrl={downloadUrlData.download_url}
|
||||
fileName={file.name}
|
||||
extension={file.extension}
|
||||
fileSize={file.size}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-full items-center justify-center rounded-xl bg-background-section">
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('debug.variableInspect.tabArtifacts.previewNotAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
|
||||
@ -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<string, SandboxFileTreeNode>()
|
||||
const roots: SandboxFileTreeNode[] = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user