mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 00:48:04 +08:00
feat: add ArtifactSlice and integrate artifact preview into skill editor tabs
Introduce a dedicated Zustand ArtifactSlice to manage artifact selection state with mutual exclusion against the main file tree. Artifact files from the sandbox can now be opened as tabs in the skill editor, rendered via a lightweight ArtifactContentPanel that reuses ReadOnlyFilePreview.
This commit is contained in:
54
web/app/components/workflow/skill/artifact-content-panel.tsx
Normal file
54
web/app/components/workflow/skill/artifact-content-panel.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useSandboxFileDownloadUrl } from '@/service/use-sandbox-file'
|
||||
import { getArtifactPath } from './constants'
|
||||
import { getFileExtension } from './utils/file-utils'
|
||||
import ReadOnlyFilePreview from './viewer/read-only-file-preview'
|
||||
|
||||
const ArtifactContentPanel = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const { userProfile } = useAppContext()
|
||||
const sandboxId = userProfile?.id
|
||||
|
||||
const path = activeTabId ? getArtifactPath(activeTabId) : undefined
|
||||
const fileName = path?.split('/').pop() ?? ''
|
||||
const extension = getFileExtension(fileName)
|
||||
|
||||
const { data: ticket, isLoading } = useSandboxFileDownloadUrl(sandboxId, path)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ticket?.download_url) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
||||
<ReadOnlyFilePreview
|
||||
downloadUrl={ticket.download_url}
|
||||
fileName={fileName}
|
||||
extension={extension}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ArtifactContentPanel)
|
||||
@ -28,6 +28,20 @@ export const NODE_MENU_TYPE = {
|
||||
|
||||
export type NodeMenuType = (typeof NODE_MENU_TYPE)[keyof typeof NODE_MENU_TYPE]
|
||||
|
||||
export const ARTIFACT_TAB_PREFIX = 'artifact:' as const
|
||||
|
||||
export function isArtifactTab(tabId: string | null): boolean {
|
||||
return tabId?.startsWith(ARTIFACT_TAB_PREFIX) ?? false
|
||||
}
|
||||
|
||||
export function getArtifactPath(tabId: string): string {
|
||||
return tabId.slice(ARTIFACT_TAB_PREFIX.length)
|
||||
}
|
||||
|
||||
export function makeArtifactTabId(path: string): string {
|
||||
return `${ARTIFACT_TAB_PREFIX}${path}`
|
||||
}
|
||||
|
||||
export const SIDEBAR_MIN_WIDTH = 240
|
||||
export const SIDEBAR_MAX_WIDTH = 480
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 320
|
||||
|
||||
@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { START_TAB_ID } from './constants'
|
||||
import { getArtifactPath, isArtifactTab, START_TAB_ID } from './constants'
|
||||
import FileTabItem from './file-tab-item'
|
||||
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
|
||||
import StartTabItem from './start-tab-item'
|
||||
import { getFileExtension } from './utils/file-utils'
|
||||
|
||||
const FileTabs = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
@ -38,6 +39,8 @@ const FileTabs = () => {
|
||||
}, [storeApi])
|
||||
|
||||
const closeTab = useCallback((fileId: string) => {
|
||||
if (isArtifactTab(fileId))
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().closeTab(fileId)
|
||||
storeApi.getState().clearDraftContent(fileId)
|
||||
storeApi.getState().clearFileMetadata(fileId)
|
||||
@ -74,8 +77,11 @@ const FileTabs = () => {
|
||||
onClick={handleStartTabClick}
|
||||
/>
|
||||
{openTabIds.map((fileId) => {
|
||||
const node = nodeMap?.get(fileId)
|
||||
const name = node?.name ?? fileId
|
||||
const isArtifact = isArtifactTab(fileId)
|
||||
const node = isArtifact ? undefined : nodeMap?.get(fileId)
|
||||
const artifactFileName = isArtifact ? getArtifactPath(fileId).split('/').pop() ?? fileId : undefined
|
||||
const name = isArtifact ? artifactFileName! : (node?.name ?? fileId)
|
||||
const extension = isArtifact ? getFileExtension(artifactFileName!) : node?.extension
|
||||
const isActive = activeTabId === fileId
|
||||
const isDirty = dirtyContents.has(fileId) || dirtyMetadataIds.has(fileId)
|
||||
const isPreview = previewTabId === fileId
|
||||
@ -85,7 +91,7 @@ const FileTabs = () => {
|
||||
key={fileId}
|
||||
fileId={fileId}
|
||||
name={name}
|
||||
extension={node?.extension}
|
||||
extension={extension}
|
||||
isActive={isActive}
|
||||
isDirty={isDirty}
|
||||
isPreview={isPreview}
|
||||
|
||||
@ -6,6 +6,7 @@ import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FolderSpark from '@/app/components/base/icons/src/vender/workflow/FolderSpark'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -26,11 +27,17 @@ const ArtifactsSection = ({ className }: ArtifactsSectionProps) => {
|
||||
const { data: treeData, hasFiles, isLoading } = useSandboxFilesTree(sandboxId)
|
||||
|
||||
const downloadMutation = useDownloadSandboxFile(sandboxId)
|
||||
const storeApi = useWorkflowStore()
|
||||
const selectedArtifactPath = useStore(s => s.selectedArtifactPath)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback((node: SandboxFileTreeNode) => {
|
||||
storeApi.getState().selectArtifact(node.path)
|
||||
}, [storeApi])
|
||||
|
||||
const handleDownload = useCallback(async (node: SandboxFileTreeNode) => {
|
||||
try {
|
||||
const ticket = await downloadMutation.mutateAsync(node.path)
|
||||
@ -91,6 +98,8 @@ const ArtifactsSection = ({ className }: ArtifactsSectionProps) => {
|
||||
<ArtifactsTree
|
||||
data={treeData}
|
||||
onDownload={handleDownload}
|
||||
onSelect={handleSelect}
|
||||
selectedPath={selectedArtifactPath ?? undefined}
|
||||
isDownloading={downloadMutation.isPending}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import { useEffect } from 'react'
|
||||
import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
|
||||
import { isArtifactTab, START_TAB_ID } from '@/app/components/workflow/skill/constants'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
type UseSyncTreeWithActiveTabOptions = {
|
||||
@ -32,6 +32,13 @@ export function useSyncTreeWithActiveTab({
|
||||
if (!tree)
|
||||
return
|
||||
|
||||
if (isArtifactTab(activeTabId)) {
|
||||
requestAnimationFrame(() => {
|
||||
tree.deselectAll()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const node = tree.get(activeTabId)
|
||||
if (!node)
|
||||
|
||||
@ -33,10 +33,12 @@ export function useTreeNodeHandlers({
|
||||
)
|
||||
|
||||
const openFilePreview = useCallback(() => {
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().openTab(node.data.id, { pinned: false })
|
||||
}, [node.data.id, storeApi])
|
||||
|
||||
const openFilePinned = useCallback(() => {
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().openTab(node.data.id, { pinned: true })
|
||||
}, [node.data.id, storeApi])
|
||||
|
||||
@ -89,10 +91,13 @@ export function useTreeNodeHandlers({
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (isFolder)
|
||||
if (isFolder) {
|
||||
node.toggle()
|
||||
else
|
||||
}
|
||||
else {
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().openTab(node.data.id, { pinned: true })
|
||||
}
|
||||
}
|
||||
}, [isFolder, node, storeApi])
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
import * as React from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import ArtifactContentPanel from './artifact-content-panel'
|
||||
import { isArtifactTab } from './constants'
|
||||
import ContentArea from './content-area'
|
||||
import ContentBody from './content-body'
|
||||
import FileContentPanel from './file-content-panel'
|
||||
@ -19,6 +22,13 @@ const SkillAutoSaveManager = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const ContentRouter = () => {
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
if (isArtifactTab(activeTabId))
|
||||
return <ArtifactContentPanel />
|
||||
return <FileContentPanel />
|
||||
}
|
||||
|
||||
const SkillMain = () => {
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
@ -36,7 +46,7 @@ const SkillMain = () => {
|
||||
<ContentArea>
|
||||
<FileTabs />
|
||||
<ContentBody>
|
||||
<FileContentPanel />
|
||||
<ContentRouter />
|
||||
</ContentBody>
|
||||
</ContentArea>
|
||||
</SkillPageLayout>
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { ArtifactSliceShape, SkillEditorSliceShape } from './types'
|
||||
import { makeArtifactTabId } from '@/app/components/workflow/skill/constants'
|
||||
|
||||
export type { ArtifactSliceShape } from './types'
|
||||
|
||||
export const createArtifactSlice: StateCreator<
|
||||
SkillEditorSliceShape,
|
||||
[],
|
||||
[],
|
||||
ArtifactSliceShape
|
||||
> = (set, get) => ({
|
||||
selectedArtifactPath: null,
|
||||
|
||||
selectArtifact: (path: string) => {
|
||||
get().clearSelection()
|
||||
set({ selectedArtifactPath: path })
|
||||
get().openTab(makeArtifactTabId(path), { pinned: true })
|
||||
},
|
||||
|
||||
clearArtifactSelection: () => {
|
||||
set({ selectedArtifactPath: null })
|
||||
},
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { SkillEditorSliceShape } from './types'
|
||||
import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
|
||||
import { createArtifactSlice } from './artifact-slice'
|
||||
import { createClipboardSlice } from './clipboard-slice'
|
||||
import { createDirtySlice } from './dirty-slice'
|
||||
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
|
||||
@ -9,6 +10,7 @@ import { createMetadataSlice } from './metadata-slice'
|
||||
import { createTabSlice } from './tab-slice'
|
||||
import { createUploadSlice } from './upload-slice'
|
||||
|
||||
export type { ArtifactSliceShape } from './artifact-slice'
|
||||
export type { ClipboardSliceShape } from './clipboard-slice'
|
||||
export type { DirtySliceShape } from './dirty-slice'
|
||||
export type { FileOperationsMenuSliceShape } from './file-operations-menu-slice'
|
||||
@ -26,6 +28,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
||||
...createMetadataSlice(...args),
|
||||
...createFileOperationsMenuSlice(...args),
|
||||
...createUploadSlice(...args),
|
||||
...createArtifactSlice(...args),
|
||||
|
||||
resetSkillEditor: () => {
|
||||
const [set] = args
|
||||
@ -48,6 +51,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
||||
fileTreeSearchTerm: '',
|
||||
uploadStatus: 'idle',
|
||||
uploadProgress: { uploaded: 0, total: 0, failed: 0 },
|
||||
selectedArtifactPath: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@ -118,6 +118,12 @@ export type UploadSliceShape = {
|
||||
resetUpload: () => void
|
||||
}
|
||||
|
||||
export type ArtifactSliceShape = {
|
||||
selectedArtifactPath: string | null
|
||||
selectArtifact: (path: string) => void
|
||||
clearArtifactSelection: () => void
|
||||
}
|
||||
|
||||
export type SkillEditorSliceShape
|
||||
= TabSliceShape
|
||||
& FileTreeSliceShape
|
||||
@ -126,6 +132,7 @@ export type SkillEditorSliceShape
|
||||
& MetadataSliceShape
|
||||
& FileOperationsMenuSliceShape
|
||||
& UploadSliceShape
|
||||
& ArtifactSliceShape
|
||||
& {
|
||||
resetSkillEditor: () => void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user