feat: split different filetypes

This commit is contained in:
Joel
2026-01-15 14:52:42 +08:00
parent 28ccd42a1c
commit 2fb8883918
9 changed files with 265 additions and 28 deletions

View File

@ -0,0 +1,39 @@
import type { FC } from 'react'
import Editor from '@monaco-editor/react'
import * as React from 'react'
import Loading from '@/app/components/base/loading'
type CodeFileEditorProps = {
language: string
theme: string
value: string
onChange: (value: string | undefined) => void
onMount: (editor: any, monaco: any) => void
}
const CodeFileEditor: FC<CodeFileEditorProps> = ({ language, theme, value, onChange, onMount }) => {
return (
<Editor
language={language}
theme={theme}
value={value}
loading={<Loading type="area" />}
onChange={onChange}
options={{
minimap: { enabled: false },
lineNumbersMinChars: 3,
wordWrap: 'on',
unicodeHighlight: {
ambiguousCharacters: false,
},
stickyScroll: { enabled: false },
fontSize: 13,
lineHeight: 20,
padding: { top: 12, bottom: 12 },
}}
onMount={onMount}
/>
)
}
export default React.memo(CodeFileEditor)

View File

@ -0,0 +1,26 @@
import type { FC } from 'react'
import * as React from 'react'
import PromptEditor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
type MarkdownFileEditorProps = {
title: string
value: string
onChange: (value: string) => void
}
const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ title, value, onChange }) => {
return (
<div className="h-full w-full">
<PromptEditor
title={title}
value={value}
onChange={onChange}
className="h-full"
editorContainerClassName="h-full"
containerBackgroundClassName="bg-components-panel-bg"
/>
</div>
)
}
export default React.memo(MarkdownFileEditor)

View File

@ -0,0 +1,43 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type MediaFilePreviewProps = {
type: 'image' | 'video'
src: string
}
const MediaFilePreview: FC<MediaFilePreviewProps> = ({ type, src }) => {
const { t } = useTranslation('workflow')
if (!src) {
return (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillEditor.previewUnavailable')}
</span>
</div>
)
}
return (
<div className="flex h-full w-full items-center justify-center p-6">
{type === 'image' && (
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
/>
)}
{type === 'video' && (
<video
src={src}
controls
className="max-h-full max-w-full"
/>
)}
</div>
)
}
export default React.memo(MediaFilePreview)

View File

@ -0,0 +1,17 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
const OfficeFilePlaceholder: FC = () => {
const { t } = useTranslation('workflow')
return (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillEditor.officePlaceholder')}
</span>
</div>
)
}
export default React.memo(OfficeFilePlaceholder)

View File

@ -0,0 +1,55 @@
import type { FC } from 'react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
import { formatFileSize } from '@/utils/format'
type UnsupportedFileDownloadProps = {
name: string
size?: number
downloadUrl?: string
}
const UnsupportedFileDownload: FC<UnsupportedFileDownloadProps> = ({ name, size, downloadUrl }) => {
const { t } = useTranslation('workflow')
const fileSize = size ? formatFileSize(size) : ''
const handleDownload = useCallback(() => {
if (!downloadUrl || typeof window === 'undefined')
return
window.open(downloadUrl, '_blank', 'noopener,noreferrer')
}, [downloadUrl])
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full max-w-[360px] flex-col items-center gap-3 pb-0 pr-2 pt-1">
<div className="flex flex-col items-center gap-1">
<FileTypeIcon type={FileAppearanceTypeEnum.custom} size="xl" className="size-16 text-text-tertiary" />
<div className="flex flex-col items-center gap-1 text-center">
<p className="system-md-medium text-text-secondary">{name}</p>
{fileSize && (
<p className="system-xs-regular text-text-tertiary">{fileSize}</p>
)}
</div>
</div>
<div className="h-px w-64 bg-components-panel-border-subtle" />
<p className="system-sm-regular text-center text-text-tertiary">
{t('skillEditor.unsupportedPreview')}
</p>
<Button
variant="primary"
size="medium"
onClick={handleDownload}
disabled={!downloadUrl}
>
{t('operation.download', { ns: 'common' })}
</Button>
</div>
</div>
)
}
export default React.memo(UnsupportedFileDownload)

View File

@ -3,7 +3,7 @@
import type { OnMount } from '@monaco-editor/react'
import type { FC } from 'react'
import type { AppAssetTreeView } from './type'
import Editor, { loader } from '@monaco-editor/react'
import { loader } from '@monaco-editor/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,9 +14,14 @@ import useTheme from '@/hooks/use-theme'
import { useGetAppAssetFileContent, useGetAppAssetTree, useUpdateAppAssetFileContent } from '@/service/use-app-asset'
import { Theme } from '@/types/app'
import { basePath } from '@/utils/var'
import CodeFileEditor from './editor/code-file-editor'
import MarkdownFileEditor from './editor/markdown-file-editor'
import MediaFilePreview from './editor/media-file-preview'
import OfficeFilePlaceholder from './editor/office-file-placeholder'
import UnsupportedFileDownload from './editor/unsupported-file-download'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
import { buildNodeMap } from './type'
import { getFileLanguage } from './utils'
import { getFileExtension, getFileLanguage, isCodeOrTextFile, isImageFile, isMarkdownFile, isOfficeFile, isVideoFile } from './utils'
// load file from local instead of cdn
if (typeof window !== 'undefined')
@ -64,6 +69,15 @@ const SkillDocEditor: FC = () => {
// Get current file node
const currentFileNode = activeTabId ? nodeMap.get(activeTabId) : undefined
const fileExtension = useMemo(() => {
return getFileExtension(currentFileNode?.name, currentFileNode?.extension)
}, [currentFileNode?.extension, currentFileNode?.name])
const isMarkdown = useMemo(() => isMarkdownFile(fileExtension), [fileExtension])
const isCodeOrText = useMemo(() => isCodeOrTextFile(fileExtension), [fileExtension])
const isImage = useMemo(() => isImageFile(fileExtension), [fileExtension])
const isVideo = useMemo(() => isVideoFile(fileExtension), [fileExtension])
const isOffice = useMemo(() => isOfficeFile(fileExtension), [fileExtension])
const isEditable = isMarkdown || isCodeOrText
// Fetch file content from API
const {
@ -89,15 +103,15 @@ const SkillDocEditor: FC = () => {
// Handle editor content change
const handleEditorChange = useCallback((value: string | undefined) => {
if (!activeTabId)
if (!activeTabId || !isEditable)
return
// Set draft content in store
storeApi.getState().setDraftContent(activeTabId, value ?? '')
}, [activeTabId, storeApi])
}, [activeTabId, isEditable, storeApi])
// Handle save
const handleSave = useCallback(async () => {
if (!activeTabId || !appId)
if (!activeTabId || !appId || !isEditable)
return
const content = dirtyContents.get(activeTabId)
@ -123,7 +137,7 @@ const SkillDocEditor: FC = () => {
message: String(error),
})
}
}, [activeTabId, appId, dirtyContents, storeApi, t, updateContent])
}, [activeTabId, appId, dirtyContents, isEditable, storeApi, t, updateContent])
// Handle keyboard shortcuts
useEffect(() => {
@ -189,28 +203,44 @@ const SkillDocEditor: FC = () => {
)
}
const previewUrl = fileContent?.content || ''
const fileName = currentFileNode?.name || ''
const fileSize = currentFileNode?.size
return (
<div className="h-full w-full overflow-hidden bg-components-panel-bg">
<Editor
language={language}
theme={isMounted ? theme : 'default-theme'}
value={currentContent}
loading={<Loading type="area" />}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
lineNumbersMinChars: 3,
wordWrap: 'on',
unicodeHighlight: {
ambiguousCharacters: false,
},
stickyScroll: { enabled: false },
fontSize: 13,
lineHeight: 20,
padding: { top: 12, bottom: 12 },
}}
onMount={handleEditorDidMount}
/>
{isMarkdown && (
<MarkdownFileEditor
title={fileName}
value={currentContent}
onChange={handleEditorChange}
/>
)}
{isCodeOrText && (
<CodeFileEditor
language={language}
theme={isMounted ? theme : 'default-theme'}
value={currentContent}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
/>
)}
{(isImage || isVideo) && (
<MediaFilePreview
type={isImage ? 'image' : 'video'}
src={previewUrl}
/>
)}
{isOffice && (
<OfficeFilePlaceholder />
)}
{!isMarkdown && !isCodeOrText && !isImage && !isVideo && !isOffice && (
<UnsupportedFileDownload
name={fileName}
size={fileSize}
downloadUrl={previewUrl}
/>
)}
</div>
)
}

View File

@ -1,17 +1,38 @@
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
const CODE_EXTENSIONS = ['json', 'yaml', 'yml', 'toml', 'js', 'jsx', 'ts', 'tsx', 'py', 'schema']
const TEXT_EXTENSIONS = ['txt', 'log', 'ini', 'env']
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'webm', 'mpeg', 'mpg', 'm4v', 'avi']
const OFFICE_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
export const getFileExtension = (name?: string, extension?: string) => {
if (extension)
return extension.toLowerCase()
if (!name)
return ''
return name.split('.').pop()?.toLowerCase() ?? ''
}
export const getFileIconType = (name: string) => {
const extension = name.split('.').pop()?.toLowerCase() ?? ''
if (['md', 'markdown', 'mdx'].includes(extension))
if (MARKDOWN_EXTENSIONS.includes(extension))
return FileAppearanceTypeEnum.markdown
if (['json', 'yaml', 'yml', 'toml', 'js', 'jsx', 'ts', 'tsx', 'py', 'schema'].includes(extension))
if (CODE_EXTENSIONS.includes(extension))
return FileAppearanceTypeEnum.code
return FileAppearanceTypeEnum.document
}
export const isMarkdownFile = (extension: string) => MARKDOWN_EXTENSIONS.includes(extension)
export const isCodeOrTextFile = (extension: string) => CODE_EXTENSIONS.includes(extension) || TEXT_EXTENSIONS.includes(extension)
export const isImageFile = (extension: string) => IMAGE_EXTENSIONS.includes(extension)
export const isVideoFile = (extension: string) => VIDEO_EXTENSIONS.includes(extension)
export const isOfficeFile = (extension: string) => OFFICE_EXTENSIONS.includes(extension)
/**
* Get Monaco editor language from file name extension
*/

View File

@ -995,6 +995,9 @@
"singleRun.testRun": "Test Run",
"singleRun.testRunIteration": "Test Run Iteration",
"singleRun.testRunLoop": "Test Run Loop",
"skillEditor.officePlaceholder": "Preview will be supported in a future update",
"skillEditor.previewUnavailable": "Preview unavailable",
"skillEditor.unsupportedPreview": "This file type is not supported for preview",
"skillSidebar.addFile": "Upload File",
"skillSidebar.addFolder": "New Folder",
"skillSidebar.dropTip": "Drop files here to upload",

View File

@ -989,6 +989,9 @@
"singleRun.testRun": "测试运行",
"singleRun.testRunIteration": "测试运行迭代",
"singleRun.testRunLoop": "测试运行循环",
"skillEditor.officePlaceholder": "预览功能将在后续版本支持",
"skillEditor.previewUnavailable": "无法预览",
"skillEditor.unsupportedPreview": "该文件类型不支持预览",
"skillSidebar.addFile": "上传文件",
"skillSidebar.addFolder": "新建文件夹",
"skillSidebar.dropTip": "拖放文件到此处上传",