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 }