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:
yyh 2026-01-29 16:42:22 +08:00
parent 079484d21c
commit 76484406a2
No known key found for this signature in database
9 changed files with 223 additions and 26 deletions

View File

@ -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}
/>

View File

@ -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>
)

View File

@ -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,
})
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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>
)
: (

View File

@ -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[] = []