diff --git a/web/app/components/workflow/skill/artifact-content-panel.tsx b/web/app/components/workflow/skill/artifact-content-panel.tsx
new file mode 100644
index 0000000000..c3badc89a0
--- /dev/null
+++ b/web/app/components/workflow/skill/artifact-content-panel.tsx
@@ -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 (
+
+
+
+ )
+ }
+
+ if (!ticket?.download_url) {
+ return (
+
+
+ {t('skillSidebar.loadError')}
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default React.memo(ArtifactContentPanel)
diff --git a/web/app/components/workflow/skill/constants.ts b/web/app/components/workflow/skill/constants.ts
index e25bd7442f..0bf8f42e37 100644
--- a/web/app/components/workflow/skill/constants.ts
+++ b/web/app/components/workflow/skill/constants.ts
@@ -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
diff --git a/web/app/components/workflow/skill/file-tabs.tsx b/web/app/components/workflow/skill/file-tabs.tsx
index 32b8bc622b..07a5f7b61f 100644
--- a/web/app/components/workflow/skill/file-tabs.tsx
+++ b/web/app/components/workflow/skill/file-tabs.tsx
@@ -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}
diff --git a/web/app/components/workflow/skill/file-tree/artifacts-section.tsx b/web/app/components/workflow/skill/file-tree/artifacts-section.tsx
index 4824dc32cd..f75817e879 100644
--- a/web/app/components/workflow/skill/file-tree/artifacts-section.tsx
+++ b/web/app/components/workflow/skill/file-tree/artifacts-section.tsx
@@ -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) => {
)
diff --git a/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts b/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts
index 19a75f36de..5e5bead339 100644
--- a/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts
+++ b/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts
@@ -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)
diff --git a/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts b/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts
index 90a0e11b86..31a07ffd93 100644
--- a/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts
+++ b/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts
@@ -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])
diff --git a/web/app/components/workflow/skill/main.tsx b/web/app/components/workflow/skill/main.tsx
index 610e056544..29f554464b 100644
--- a/web/app/components/workflow/skill/main.tsx
+++ b/web/app/components/workflow/skill/main.tsx
@@ -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
+ return
+}
+
const SkillMain = () => {
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
@@ -36,7 +46,7 @@ const SkillMain = () => {
-
+
diff --git a/web/app/components/workflow/store/workflow/skill-editor/artifact-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/artifact-slice.ts
new file mode 100644
index 0000000000..1e5447b49b
--- /dev/null
+++ b/web/app/components/workflow/store/workflow/skill-editor/artifact-slice.ts
@@ -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 })
+ },
+})
diff --git a/web/app/components/workflow/store/workflow/skill-editor/index.ts b/web/app/components/workflow/store/workflow/skill-editor/index.ts
index f75434af7d..00ed709909 100644
--- a/web/app/components/workflow/store/workflow/skill-editor/index.ts
+++ b/web/app/components/workflow/store/workflow/skill-editor/index.ts
@@ -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 = (...a
...createMetadataSlice(...args),
...createFileOperationsMenuSlice(...args),
...createUploadSlice(...args),
+ ...createArtifactSlice(...args),
resetSkillEditor: () => {
const [set] = args
@@ -48,6 +51,7 @@ export const createSkillEditorSlice: StateCreator = (...a
fileTreeSearchTerm: '',
uploadStatus: 'idle',
uploadProgress: { uploaded: 0, total: 0, failed: 0 },
+ selectedArtifactPath: null,
})
},
})
diff --git a/web/app/components/workflow/store/workflow/skill-editor/types.ts b/web/app/components/workflow/store/workflow/skill-editor/types.ts
index f5f9920d09..f79cd9e834 100644
--- a/web/app/components/workflow/store/workflow/skill-editor/types.ts
+++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts
@@ -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
}