mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 22:18:15 +08:00
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import type { OnMount } from '@monaco-editor/react'
|
|
import type { SkillFileDataMode } from '../../hooks/use-skill-file-data'
|
|
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'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
|
import Loading from '@/app/components/base/loading'
|
|
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
|
import useTheme from '@/hooks/use-theme'
|
|
import { Theme } from '@/types/app'
|
|
import { basePath } from '@/utils/var'
|
|
import { useSkillCodeCollaboration } from '../../../collaboration/skills/use-skill-code-collaboration'
|
|
import { useSkillMarkdownCollaboration } from '../../../collaboration/skills/use-skill-markdown-collaboration'
|
|
import { START_TAB_ID } from '../../constants'
|
|
import CodeFileEditor from '../../editor/code-file-editor'
|
|
import MarkdownFileEditor from '../../editor/markdown-file-editor'
|
|
import { useSkillAssetNodeMap } from '../../hooks/file-tree/data/use-skill-asset-tree'
|
|
import { useSkillSaveManager } from '../../hooks/skill-save-context'
|
|
import { useFileNodeViewState } from '../../hooks/use-file-node-view-state'
|
|
import { useFileTypeInfo } from '../../hooks/use-file-type-info'
|
|
import { useSkillFileData } from '../../hooks/use-skill-file-data'
|
|
import StartTabContent from '../../start-tab'
|
|
import { getFileLanguage } from '../../utils/file-utils'
|
|
import MediaFilePreview from '../../viewer/media-file-preview'
|
|
import UnsupportedFileDownload from '../../viewer/unsupported-file-download'
|
|
|
|
type SkillFileMetadata = {
|
|
files?: Record<string, AppAssetTreeView>
|
|
}
|
|
|
|
const extractFileReferenceIds = (content: string) => {
|
|
const ids = new Set<string>()
|
|
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: () => <Loading type="area" /> },
|
|
)
|
|
|
|
const PdfFilePreview = dynamic(
|
|
() => import('../../viewer/pdf-file-preview'),
|
|
{ ssr: false, loading: () => <Loading type="area" /> },
|
|
)
|
|
|
|
if (typeof window !== 'undefined')
|
|
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
|
|
|
|
const FileContentPanel = () => {
|
|
const { t } = useTranslation('workflow')
|
|
const { theme: appTheme } = useTheme()
|
|
const [isMounted, setIsMounted] = useState(false)
|
|
|
|
const appDetail = useAppStore(s => s.appDetail)
|
|
const appId = appDetail?.id || ''
|
|
|
|
const activeTabId = useStore(s => s.activeTabId)
|
|
const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId)
|
|
const storeApi = useWorkflowStore()
|
|
const {
|
|
data: nodeMap,
|
|
isLoading: isNodeMapLoading,
|
|
isFetching: isNodeMapFetching,
|
|
isFetched: isNodeMapFetched,
|
|
} = useSkillAssetNodeMap()
|
|
|
|
const isStartTab = activeTabId === START_TAB_ID
|
|
const fileTabId = isStartTab ? null : activeTabId
|
|
|
|
const draftContent = useStore(s => fileTabId ? s.dirtyContents.get(fileTabId) : undefined)
|
|
const currentMetadata = useStore(s => fileTabId ? s.fileMetadata.get(fileTabId) : undefined)
|
|
const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false)
|
|
|
|
const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined
|
|
const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId)
|
|
const fileNodeViewState = useFileNodeViewState({
|
|
fileTabId,
|
|
hasCurrentFileNode: Boolean(currentFileNode),
|
|
isNodeMapLoading,
|
|
isNodeMapFetching,
|
|
isNodeMapFetched,
|
|
})
|
|
const isNodeReady = fileNodeViewState === 'ready'
|
|
|
|
const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isEditable, isPreviewable } = useFileTypeInfo(isNodeReady ? currentFileNode : undefined)
|
|
const fileDataMode: SkillFileDataMode = !fileTabId || !isNodeReady
|
|
? 'none'
|
|
: isEditable
|
|
? 'content'
|
|
: 'download'
|
|
|
|
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, fileTabId, fileDataMode)
|
|
|
|
const originalContent = fileContent?.content ?? ''
|
|
const currentContent = draftContent !== undefined ? draftContent : originalContent
|
|
const initialContentRegistryRef = useRef<Map<string, string>>(new Map())
|
|
const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error)
|
|
|
|
if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId))
|
|
initialContentRegistryRef.current.set(fileTabId, currentContent)
|
|
|
|
const initialCollaborativeContent = fileTabId
|
|
? (initialContentRegistryRef.current.get(fileTabId) ?? currentContent)
|
|
: ''
|
|
|
|
useEffect(() => {
|
|
if (!fileTabId || !fileContent)
|
|
return
|
|
if (isMetadataDirty)
|
|
return
|
|
let nextMetadata: Record<string, unknown> = {}
|
|
if (fileContent.metadata) {
|
|
if (typeof fileContent.metadata === 'string') {
|
|
try {
|
|
nextMetadata = JSON.parse(fileContent.metadata)
|
|
}
|
|
catch {
|
|
nextMetadata = {}
|
|
}
|
|
}
|
|
else {
|
|
nextMetadata = fileContent.metadata
|
|
}
|
|
}
|
|
const { setFileMetadata, clearDraftMetadata } = storeApi.getState()
|
|
setFileMetadata(fileTabId, nextMetadata)
|
|
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<string, AppAssetTreeView> = {}
|
|
|
|
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
|
|
const newValue = value ?? ''
|
|
|
|
if (newValue === originalContent)
|
|
storeApi.getState().clearDraftContent(fileTabId)
|
|
else
|
|
storeApi.getState().setDraftContent(fileTabId, newValue)
|
|
updateFileReferenceMetadata(newValue)
|
|
storeApi.getState().pinTab(fileTabId)
|
|
}, [fileTabId, isEditable, originalContent, storeApi, updateFileReferenceMetadata])
|
|
|
|
const { saveFile, registerFallback, unregisterFallback } = useSkillSaveManager()
|
|
const handleLeaderSync = useCallback(() => {
|
|
if (!fileTabId || !isEditable)
|
|
return
|
|
void saveFile(fileTabId)
|
|
}, [fileTabId, isEditable, saveFile])
|
|
|
|
const saveFileRef = useRef(saveFile)
|
|
saveFileRef.current = saveFile
|
|
|
|
const fallbackRef = useRef({ content: originalContent, metadata: currentMetadata })
|
|
|
|
useEffect(() => {
|
|
if (!fileTabId || fileContent?.content === undefined)
|
|
return
|
|
|
|
const fallback = { content: originalContent, metadata: currentMetadata }
|
|
fallbackRef.current = fallback
|
|
registerFallback(fileTabId, fallback)
|
|
|
|
return () => {
|
|
unregisterFallback(fileTabId)
|
|
}
|
|
}, [fileTabId, fileContent?.content, originalContent, currentMetadata, registerFallback, unregisterFallback])
|
|
|
|
useEffect(() => {
|
|
if (!fileTabId || !isEditable)
|
|
return
|
|
|
|
return () => {
|
|
const { content: fallbackContent, metadata: fallbackMetadata } = fallbackRef.current
|
|
void saveFileRef.current(fileTabId, {
|
|
fallbackContent,
|
|
fallbackMetadata,
|
|
})
|
|
}
|
|
}, [fileTabId, isEditable])
|
|
|
|
const handleEditorAutoFocus = useCallback(() => {
|
|
if (!fileTabId)
|
|
return
|
|
storeApi.getState().clearEditorAutoFocus(fileTabId)
|
|
}, [fileTabId, storeApi])
|
|
|
|
const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => {
|
|
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark')
|
|
setIsMounted(true)
|
|
}, [appTheme])
|
|
|
|
const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext'
|
|
const theme = appTheme === Theme.light ? 'light' : 'vs-dark'
|
|
|
|
const { handleCollaborativeChange: handleMarkdownCollaborativeChange } = useSkillMarkdownCollaboration({
|
|
appId,
|
|
fileId: fileTabId,
|
|
enabled: canInitCollaboration && isMarkdown,
|
|
initialContent: initialCollaborativeContent,
|
|
baselineContent: originalContent,
|
|
onLocalChange: handleEditorChange,
|
|
onLeaderSync: handleLeaderSync,
|
|
})
|
|
const { handleCollaborativeChange: handleCodeCollaborativeChange } = useSkillCodeCollaboration({
|
|
appId,
|
|
fileId: fileTabId,
|
|
enabled: canInitCollaboration && isCodeOrText,
|
|
initialContent: initialCollaborativeContent,
|
|
baselineContent: originalContent,
|
|
onLocalChange: handleEditorChange,
|
|
onLeaderSync: handleLeaderSync,
|
|
})
|
|
|
|
if (isStartTab)
|
|
return <StartTabContent />
|
|
|
|
if (!fileTabId) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
|
<span className="system-sm-regular">
|
|
{t('skillSidebar.empty')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (fileNodeViewState === 'resolving') {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
|
<Loading type="area" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (fileNodeViewState === 'missing') {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
|
<span className="system-sm-regular">
|
|
{t('skillSidebar.loadError')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
|
<Loading type="area" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
|
<span className="system-sm-regular">
|
|
{t('skillSidebar.loadError')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// For non-editable files (media, sqlite, unsupported), use download URL
|
|
const downloadUrl = downloadUrlData?.download_url || ''
|
|
const fileName = currentFileNode?.name || ''
|
|
const fileSize = currentFileNode?.size
|
|
const isUnsupportedFile = !isPreviewable
|
|
|
|
return (
|
|
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
|
{isMarkdown
|
|
? (
|
|
<MarkdownFileEditor
|
|
key={fileTabId}
|
|
instanceId={fileTabId || undefined}
|
|
value={currentContent}
|
|
onChange={handleMarkdownCollaborativeChange}
|
|
autoFocus={shouldAutoFocusEditor}
|
|
onAutoFocus={handleEditorAutoFocus}
|
|
collaborationEnabled={canInitCollaboration}
|
|
/>
|
|
)
|
|
: null}
|
|
{isCodeOrText
|
|
? (
|
|
<CodeFileEditor
|
|
key={fileTabId}
|
|
language={language}
|
|
theme={isMounted ? theme : 'default-theme'}
|
|
value={currentContent}
|
|
onChange={handleCodeCollaborativeChange}
|
|
onMount={handleEditorDidMount}
|
|
autoFocus={shouldAutoFocusEditor}
|
|
onAutoFocus={handleEditorAutoFocus}
|
|
fileId={fileTabId}
|
|
collaborationEnabled={canInitCollaboration}
|
|
/>
|
|
)
|
|
: null}
|
|
{isImage || isVideo
|
|
? (
|
|
<MediaFilePreview
|
|
type={isImage ? 'image' : 'video'}
|
|
src={downloadUrl}
|
|
/>
|
|
)
|
|
: null}
|
|
{isSQLite
|
|
? (
|
|
<SQLiteFilePreview
|
|
key={fileTabId}
|
|
downloadUrl={downloadUrl}
|
|
/>
|
|
)
|
|
: null}
|
|
{isPdf
|
|
? (
|
|
<PdfFilePreview
|
|
downloadUrl={downloadUrl}
|
|
/>
|
|
)
|
|
: null}
|
|
{isUnsupportedFile
|
|
? (
|
|
<UnsupportedFileDownload
|
|
name={fileName}
|
|
size={fileSize}
|
|
downloadUrl={downloadUrl}
|
|
/>
|
|
)
|
|
: null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(FileContentPanel)
|