From 92731bffba82441e00c0942b3787fbb03594a56d Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 29 Jan 2026 17:52:41 +0800 Subject: [PATCH] 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. --- .../workflow/skill/artifact-content-panel.tsx | 54 +++++++++++++++++++ .../components/workflow/skill/constants.ts | 14 +++++ .../components/workflow/skill/file-tabs.tsx | 14 +++-- .../skill/file-tree/artifacts-section.tsx | 9 ++++ .../hooks/use-sync-tree-with-active-tab.ts | 9 +++- .../skill/hooks/use-tree-node-handlers.ts | 9 +++- web/app/components/workflow/skill/main.tsx | 12 ++++- .../workflow/skill-editor/artifact-slice.ts | 24 +++++++++ .../store/workflow/skill-editor/index.ts | 4 ++ .../store/workflow/skill-editor/types.ts | 7 +++ 10 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 web/app/components/workflow/skill/artifact-content-panel.tsx create mode 100644 web/app/components/workflow/store/workflow/skill-editor/artifact-slice.ts 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 }