Revert "Refactor variable inspect panel layout and scrolling"

This reverts commit 9ab18b3ef6.
This commit is contained in:
yyh
2026-03-26 13:32:18 +08:00
parent 8fa5aa9c0d
commit 37d59222cf
23 changed files with 735 additions and 994 deletions

View File

@ -17,33 +17,17 @@ const {
mockEmit,
mockFetchInspectVarValue,
mockHandleNodeSelect,
mockDownloadUrlOptions,
mockTreeOptions,
mockUseQuery,
mockResetConversationVar,
mockResetToLastRunVar,
mockSetInputs,
flatData,
isLoading,
} = vi.hoisted(() => ({
mockEditInspectVarValue: vi.fn(),
mockEmit: vi.fn(),
mockFetchInspectVarValue: vi.fn(),
mockHandleNodeSelect: vi.fn(),
mockDownloadUrlOptions: vi.fn().mockReturnValue({
queryKey: ['sandboxFile', 'downloadFile'],
queryFn: vi.fn(),
}),
mockTreeOptions: vi.fn().mockReturnValue({
queryKey: ['sandboxFile', 'listFiles'],
queryFn: vi.fn(),
}),
mockUseQuery: vi.fn(),
mockResetConversationVar: vi.fn(),
mockResetToLastRunVar: vi.fn(),
mockSetInputs: vi.fn(),
flatData: [] as unknown[],
isLoading: false,
}))
let inspectVarsState: InspectVarsState
@ -105,21 +89,6 @@ vi.mock('../../hooks-store', () => ({
}),
}))
vi.mock('@tanstack/react-query', async importOriginal => ({
...await importOriginal<typeof import('@tanstack/react-query')>(),
useQuery: (options: { queryKey?: unknown }) => mockUseQuery(options),
}))
vi.mock('@/service/use-sandbox-file', async importOriginal => ({
...(await importOriginal<typeof import('@/service/use-sandbox-file')>()),
sandboxFileDownloadUrlOptions: (...args: unknown[]) => mockDownloadUrlOptions(...args),
sandboxFilesTreeOptions: (...args: unknown[]) => mockTreeOptions(...args),
useDownloadSandboxFile: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
@ -128,17 +97,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
vi.mock('@/app/components/base/features/hooks', async importOriginal => ({
...(await importOriginal<typeof import('@/app/components/base/features/hooks')>()),
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
features: {
sandbox: {
enabled: true,
},
},
}),
}))
const createEnvironmentVariable = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'API_KEY',
@ -166,20 +124,6 @@ const renderPanel = (initialStoreState: Record<string, unknown> = {}) => {
describe('VariableInspect Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockImplementation((options: { queryKey?: unknown }) => {
const treeKey = mockTreeOptions.mock.results.at(-1)?.value?.queryKey
if (treeKey && options.queryKey === treeKey) {
return {
data: flatData,
isLoading,
}
}
return {
data: undefined,
isLoading: false,
}
})
inspectVarsState = {
conversationVars: [],
systemVars: [],

View File

@ -1,40 +0,0 @@
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useTranslation } from 'react-i18next'
import SearchLinesSparkle from '@/app/components/base/icons/src/vender/knowledge/SearchLinesSparkle'
import { useDocLink } from '@/context/i18n'
const fileSystemArtifactsLocalizedPathMap = {
'zh-Hans': '/use-dify/build/file-system#产物' as DocPathWithoutLang,
'zh_Hans': '/use-dify/build/file-system#产物' as DocPathWithoutLang,
'ja-JP': '/use-dify/build/file-system#アーティファクト' as DocPathWithoutLang,
'ja_JP': '/use-dify/build/file-system#アーティファクト' as DocPathWithoutLang,
}
type Props = {
description: string
}
export default function ArtifactsEmptyState({ description }: Props) {
const { t } = useTranslation('workflow')
const docLink = useDocLink()
return (
<div className="flex h-full flex-col gap-3 rounded-xl bg-background-section p-8">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
<SearchLinesSparkle className="h-5 w-5 text-text-accent" aria-hidden="true" />
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-sm-semibold">{t('debug.variableInspect.tabArtifacts.emptyTitle')}</div>
<div className="text-text-tertiary system-xs-regular">{description}</div>
<a
className="cursor-pointer text-text-accent system-xs-regular"
href={docLink('/use-dify/build/file-system#artifacts' as DocPathWithoutLang, fileSystemArtifactsLocalizedPathMap)}
target="_blank"
rel="noopener noreferrer"
>
{t('debug.variableInspect.tabArtifacts.emptyLink')}
</a>
</div>
</div>
)
}

View File

@ -1,27 +0,0 @@
import type { ArtifactsInspectView } from './hooks/use-artifacts-inspect-state'
import ArtifactsTree from '@/app/components/workflow/skill/file-tree/artifacts/artifacts-tree'
type Props = Pick<
ArtifactsInspectView,
'handleFileSelect' | 'handleTreeDownload' | 'isDownloading' | 'selectedFilePath' | 'treeData'
>
export default function ArtifactsLeftPane({
treeData,
handleTreeDownload,
handleFileSelect,
selectedFilePath,
isDownloading,
}: Props) {
return (
<div className="py-1">
<ArtifactsTree
data={treeData}
onDownload={handleTreeDownload}
onSelect={handleFileSelect}
selectedPath={selectedFilePath}
isDownloading={isDownloading}
/>
</div>
)
}

View File

@ -1,118 +0,0 @@
import type { ArtifactsInspectView } from './hooks/use-artifacts-inspect-state'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Loading from '@/app/components/base/loading'
import ReadOnlyFilePreview from '@/app/components/workflow/skill/viewer/read-only-file-preview'
import { cn } from '@/utils/classnames'
import ArtifactsEmptyState from './artifacts-empty-state'
import useInspectShell from './hooks/use-inspect-shell'
type Props = Pick<
ArtifactsInspectView,
'downloadUrlData' | 'handleSelectedFileDownload' | 'isDownloadUrlLoading' | 'pathSegments' | 'selectedFile' | 'selectedFilePath'
>
function formatFileSize(bytes: number | null): string {
if (bytes === null || bytes === 0)
return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
export default function ArtifactsRightPane({
downloadUrlData,
handleSelectedFileDownload,
isDownloadUrlLoading,
pathSegments,
selectedFile,
selectedFilePath,
}: Props) {
const { t } = useTranslation('workflow')
const { isNarrow, onClose, openLeftPane } = useInspectShell()
const file = selectedFilePath ? selectedFile : null
return (
<>
<div className="flex shrink-0 items-center justify-between gap-1 px-2 pt-2">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isNarrow
? (
<ActionButton className="shrink-0" onClick={openLeftPane} aria-label="Open menu">
<span className="i-ri-menu-line h-4 w-4" aria-hidden="true" />
</ActionButton>
)
: null}
{file
? (
<>
<div className="flex w-0 grow items-center gap-1">
<div className="flex items-center gap-1 truncate">
{pathSegments.map(seg => (
<span key={seg.key} className="flex items-center gap-1">
{!seg.isFirst ? <span className="text-text-quaternary system-sm-regular">/</span> : null}
<span
className={cn(
'truncate system-sm-semibold',
seg.isLast ? 'text-text-secondary' : 'text-text-tertiary',
)}
>
{seg.part}
</span>
</span>
))}
</div>
<span className="shrink-0 text-text-tertiary system-xs-medium">
{formatFileSize(file.size)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<ActionButton
onClick={handleSelectedFileDownload}
disabled={!downloadUrlData?.download_url}
aria-label={`Download ${file.name}`}
>
<span className="i-custom-vender-line-files-file-download-01 h-4 w-4" aria-hidden="true" />
</ActionButton>
</div>
</>
)
: null}
</div>
<ActionButton className="shrink-0" onClick={onClose} aria-label="Close">
<span className="i-ri-close-line h-4 w-4" aria-hidden="true" />
</ActionButton>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{file
? (
<div className="min-h-0 grow">
{isDownloadUrlLoading
? <div className="flex h-full items-center justify-center"><Loading type="area" /></div>
: downloadUrlData?.download_url
? (
<ReadOnlyFilePreview
downloadUrl={downloadUrlData.download_url}
fileName={file.name}
extension={file.extension}
fileSize={file.size}
/>
)
: (
<div className="flex h-full items-center justify-center rounded-xl bg-background-section">
<p className="text-text-tertiary system-xs-regular">
{t('debug.variableInspect.tabArtifacts.previewNotAvailable')}
</p>
</div>
)}
</div>
)
: (
<div className="grow p-2">
<ArtifactsEmptyState description={t('debug.variableInspect.tabArtifacts.selectFile')} />
</div>
)}
</div>
</>
)
}

View File

@ -1,6 +1,7 @@
import type { SandboxFileNode } from '@/types/sandbox-file'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useArtifactsInspectView } from './hooks/use-artifacts-inspect-state'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ArtifactsTab from './artifacts-tab'
import { InspectTab } from './types'
type MockStoreState = {
appId: string | undefined
@ -10,6 +11,7 @@ type MockStoreState = {
}
}
isResponding: boolean
bottomPanelWidth: number
}
const mocks = vi.hoisted(() => ({
@ -17,6 +19,7 @@ const mocks = vi.hoisted(() => ({
appId: 'app-1',
workflowRunningData: undefined,
isResponding: false,
bottomPanelWidth: 640,
} as MockStoreState,
flatData: [] as SandboxFileNode[],
isLoading: false,
@ -38,7 +41,7 @@ vi.mock('../store', () => ({
vi.mock('@tanstack/react-query', async importOriginal => ({
...await importOriginal<typeof import('@tanstack/react-query')>(),
useQuery: (options: { queryKey?: unknown }) => mocks.mockUseQuery(options),
useQuery: (options: unknown) => mocks.mockUseQuery(options),
}))
vi.mock('@/service/use-sandbox-file', async importOriginal => ({
@ -51,6 +54,20 @@ vi.mock('@/service/use-sandbox-file', async importOriginal => ({
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
features: {
sandbox: {
enabled: true,
},
},
}),
}))
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
@ -64,12 +81,13 @@ const createFlatFileNode = (overrides: Partial<SandboxFileNode> = {}): SandboxFi
...overrides,
})
describe('useArtifactsInspectState', () => {
describe('ArtifactsTab', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState.appId = 'app-1'
mocks.storeState.workflowRunningData = undefined
mocks.storeState.isResponding = false
mocks.storeState.bottomPanelWidth = 640
mocks.flatData = [createFlatFileNode()]
mocks.isLoading = false
@ -81,7 +99,6 @@ describe('useArtifactsInspectState', () => {
isLoading: mocks.isLoading,
}
}
return {
data: undefined,
isLoading: false,
@ -90,24 +107,23 @@ describe('useArtifactsInspectState', () => {
})
it('should stop using stale file path for download url query after files are cleared', async () => {
const { result, rerender } = renderHook(() => useArtifactsInspectView())
const headerProps = {
activeTab: InspectTab.Artifacts,
onTabChange: vi.fn(),
onClose: vi.fn(),
}
act(() => {
result.current.handleFileSelect({
name: 'a.txt',
path: 'a.txt',
node_type: 'file',
size: 128,
extension: 'txt',
} as never)
})
const { rerender } = render(<ArtifactsTab {...headerProps} />)
fireEvent.click(screen.getByRole('button', { name: 'a.txt' }))
await waitFor(() => {
expect(mocks.mockDownloadUrlOptions).toHaveBeenCalledWith('app-1', 'a.txt')
})
mocks.flatData = []
rerender()
rerender(<ArtifactsTab {...headerProps} />)
await waitFor(() => {
const lastCall = mocks.mockDownloadUrlOptions.mock.calls.at(-1)

View File

@ -0,0 +1,241 @@
import type { InspectHeaderProps } from './inspect-layout'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import SearchLinesSparkle from '@/app/components/base/icons/src/vender/knowledge/SearchLinesSparkle'
import { FileDownload01 } from '@/app/components/base/icons/src/vender/line/files'
import Loading from '@/app/components/base/loading'
import ArtifactsTree from '@/app/components/workflow/skill/file-tree/artifacts/artifacts-tree'
import ReadOnlyFilePreview from '@/app/components/workflow/skill/viewer/read-only-file-preview'
import { useDocLink } from '@/context/i18n'
import { sandboxFileDownloadUrlOptions, sandboxFilesTreeOptions, useDownloadSandboxFile } from '@/service/use-sandbox-file'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { buildTreeFromFlatList } from '../skill/file-tree/artifacts/utils'
import { useStore } from '../store'
import { WorkflowRunningStatus } from '../types'
import InspectLayout from './inspect-layout'
import SplitPanel from './split-panel'
const fileSystemArtifactsLocalizedPathMap = {
'zh-Hans': '/use-dify/build/file-system#产物' as DocPathWithoutLang,
'zh_Hans': '/use-dify/build/file-system#产物' as DocPathWithoutLang,
'ja-JP': '/use-dify/build/file-system#アーティファクト' as DocPathWithoutLang,
'ja_JP': '/use-dify/build/file-system#アーティファクト' as DocPathWithoutLang,
}
const ArtifactsEmpty = ({ description }: { description: string }) => {
const { t } = useTranslation('workflow')
const docLink = useDocLink()
return (
<div className="flex h-full flex-col gap-3 rounded-xl bg-background-section p-8">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
<SearchLinesSparkle className="h-5 w-5 text-text-accent" aria-hidden="true" />
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-sm-semibold">{t('debug.variableInspect.tabArtifacts.emptyTitle')}</div>
<div className="text-text-tertiary system-xs-regular">{description}</div>
<a
className="cursor-pointer text-text-accent system-xs-regular"
href={docLink('/use-dify/build/file-system#artifacts' as DocPathWithoutLang, fileSystemArtifactsLocalizedPathMap)}
target="_blank"
rel="noopener noreferrer"
>
{t('debug.variableInspect.tabArtifacts.emptyLink')}
</a>
</div>
</div>
)
}
const formatFileSize = (bytes: number | null): string => {
if (bytes === null || bytes === 0)
return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
const ArtifactsTab = (headerProps: InspectHeaderProps) => {
const { t } = useTranslation('workflow')
const appId = useStore(s => s.appId)
const isWorkflowRunning = useStore(
s => s.workflowRunningData?.result?.status === WorkflowRunningStatus.Running,
)
const isResponding = useStore(s => s.isResponding)
const { data: flatData, isLoading } = useQuery({
...sandboxFilesTreeOptions(appId),
refetchInterval: (isWorkflowRunning || isResponding) ? 5000 : false,
})
const treeData = useMemo(() => flatData ? buildTreeFromFlatList(flatData) : undefined, [flatData])
const hasFiles = (flatData?.length ?? 0) > 0
const { mutateAsync: fetchDownloadUrl, isPending: isDownloading } = useDownloadSandboxFile(appId)
const [selectedFile, setSelectedFile] = useState<SandboxFileTreeNode | null>(null)
const selectedFilePath = useMemo(() => {
if (!selectedFile)
return undefined
const selectedExists = flatData?.some(
node => !node.is_dir && node.path === selectedFile.path,
) ?? false
return selectedExists ? selectedFile.path : undefined
}, [flatData, selectedFile])
const { data: downloadUrlData, isLoading: isDownloadUrlLoading } = useQuery({
...sandboxFileDownloadUrlOptions(appId, selectedFilePath),
retry: false,
})
const handleFileSelect = useCallback((node: SandboxFileTreeNode) => {
if (node.node_type === 'file')
setSelectedFile(node)
}, [])
const handleTreeDownload = useCallback(async (node: SandboxFileTreeNode) => {
try {
const ticket = await fetchDownloadUrl(node.path)
downloadUrl({ url: ticket.download_url, fileName: node.name })
}
catch (error) {
console.error('Download failed:', error)
}
}, [fetchDownloadUrl])
const handleSelectedFileDownload = useCallback(() => {
if (downloadUrlData?.download_url && selectedFile)
downloadUrl({ url: downloadUrlData.download_url, fileName: selectedFile.name })
}, [downloadUrlData, selectedFile])
if (isLoading) {
return (
<InspectLayout {...headerProps}>
<div className="flex h-full items-center justify-center">
<Loading />
</div>
</InspectLayout>
)
}
if (!hasFiles) {
return (
<InspectLayout {...headerProps}>
<div className="h-full p-2">
<ArtifactsEmpty description={t('debug.variableInspect.tabArtifacts.emptyTip')} />
</div>
</InspectLayout>
)
}
const file = selectedFilePath ? selectedFile : null
const parts = file?.path.split('/') ?? []
let cumPath = ''
const pathSegments = parts.map((part, i) => {
cumPath += (cumPath ? '/' : '') + part
return { part, key: cumPath, isFirst: i === 0, isLast: i === parts.length - 1 }
})
return (
<SplitPanel
{...headerProps}
left={(
<div className="h-full overflow-y-auto py-1">
<ArtifactsTree
data={treeData}
onDownload={handleTreeDownload}
onSelect={handleFileSelect}
selectedPath={selectedFilePath}
isDownloading={isDownloading}
/>
</div>
)}
>
{({ isNarrow, onOpenMenu, onClose: handleClose }) => (
<>
<div className="flex shrink-0 items-center justify-between gap-1 px-2 pt-2">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isNarrow && (
<ActionButton className="shrink-0" onClick={onOpenMenu} aria-label="Open menu">
<span className="i-ri-menu-line h-4 w-4" aria-hidden="true" />
</ActionButton>
)}
{file && (
<>
<div className="flex w-0 grow items-center gap-1">
<div className="flex items-center gap-1 truncate">
{pathSegments!.map(seg => (
<span key={seg.key} className="flex items-center gap-1">
{!seg.isFirst && <span className="text-text-quaternary system-sm-regular">/</span>}
<span
className={cn(
'truncate system-sm-semibold',
seg.isLast ? 'text-text-secondary' : 'text-text-tertiary',
)}
>
{seg.part}
</span>
</span>
))}
</div>
<span className="shrink-0 text-text-tertiary system-xs-medium">
{formatFileSize(file.size)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<ActionButton
onClick={handleSelectedFileDownload}
disabled={!downloadUrlData?.download_url}
aria-label={`Download ${file.name}`}
>
<FileDownload01 className="h-4 w-4" />
</ActionButton>
</div>
</>
)}
</div>
<ActionButton className="shrink-0" onClick={handleClose} aria-label="Close">
<span className="i-ri-close-line h-4 w-4" aria-hidden="true" />
</ActionButton>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{file
? (
<div className="min-h-0 grow">
{isDownloadUrlLoading
? <div className="flex h-full items-center justify-center"><Loading type="area" /></div>
: downloadUrlData?.download_url
? (
<ReadOnlyFilePreview
downloadUrl={downloadUrlData.download_url}
fileName={file.name}
extension={file.extension}
fileSize={file.size}
/>
)
: (
<div className="flex h-full items-center justify-center rounded-xl bg-background-section">
<p className="text-text-tertiary system-xs-regular">
{t('debug.variableInspect.tabArtifacts.previewNotAvailable')}
</p>
</div>
)}
</div>
)
: (
<div className="grow p-2">
<ArtifactsEmpty description={t('debug.variableInspect.tabArtifacts.selectFile')} />
</div>
)}
</div>
</>
)}
</SplitPanel>
)
}
export default ArtifactsTab

View File

@ -1,9 +1,18 @@
import type { CurrentVarInInspect } from './types'
import type { currentVarType } from './variables-tab'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import {
RiArrowRightSLine,
RiDeleteBinLine,
RiFileList3Line,
RiLoader2Line,
// RiErrorWarningFill,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { AtSign } from '@/app/components/base/icons/src/vender/workflow'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarInInspectType } from '@/types/workflow'
@ -13,15 +22,15 @@ import { formatVarTypeLabel } from './utils'
type Props = {
nodeData?: NodeWithVar
currentVar?: CurrentVarInInspect
currentVar?: currentVarType
varType: VarInInspectType
varList: VarInInspect[]
handleSelect: (state: CurrentVarInInspect) => void
handleSelect: (state: currentVarType) => void
handleView?: () => void
handleClear?: () => void
}
export default function Group({
const Group = ({
nodeData,
currentVar,
varType,
@ -29,7 +38,7 @@ export default function Group({
handleSelect,
handleView,
handleClear,
}: Props) {
}: Props) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(false)
@ -95,10 +104,10 @@ export default function Group({
<div className="group flex h-6 items-center gap-0.5">
<div className="h-3 w-3 shrink-0">
{nodeData?.isSingRunRunning && (
<span aria-hidden="true" className="i-ri-loader-2-line h-3 w-3 animate-spin text-text-accent" />
<RiLoader2Line className="h-3 w-3 animate-spin text-text-accent" />
)}
{(!nodeData || !nodeData.isSingRunRunning) && visibleVarList.length > 0 && (
<span aria-hidden="true" className={cn('i-ri-arrow-right-s-line h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} />
<RiArrowRightSLine className={cn('h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} aria-hidden="true" />
)}
</div>
<div className="flex grow cursor-pointer items-center gap-1" onClick={() => setIsCollapsed(!isCollapsed)}>
@ -123,29 +132,15 @@ export default function Group({
</div>
{nodeData && !nodeData.isSingRunRunning && (
<div className="hidden shrink-0 items-center group-hover:flex">
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex">
<ActionButton onClick={handleView}>
<span className="i-ri-file-list-3-line h-4 w-4" />
</ActionButton>
</span>
)}
/>
<TooltipContent>{t('debug.variableInspect.view', { ns: 'workflow' })}</TooltipContent>
<Tooltip popupContent={t('debug.variableInspect.view', { ns: 'workflow' })}>
<ActionButton onClick={handleView}>
<RiFileList3Line className="h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex">
<ActionButton onClick={handleClear}>
<span className="i-ri-delete-bin-line h-4 w-4" />
</ActionButton>
</span>
)}
/>
<TooltipContent>{t('debug.variableInspect.clearNode', { ns: 'workflow' })}</TooltipContent>
<Tooltip popupContent={t('debug.variableInspect.clearNode', { ns: 'workflow' })}>
<ActionButton onClick={handleClear}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
</Tooltip>
</div>
)}
@ -167,7 +162,7 @@ export default function Group({
onClick={() => handleSelectVar(varItem, varType)}
>
{isAgentAliasVar
? <span className="inline-flex size-4 shrink-0 items-center justify-center text-util-colors-violet-violet-600 system-xs-semibold">@</span>
? <AtSign className="size-4 shrink-0 text-util-colors-violet-violet-600" />
: (
<VariableIconWithColor
variableCategory={varType}
@ -185,3 +180,5 @@ export default function Group({
</div>
)
}
export default Group

View File

@ -1,141 +0,0 @@
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { sandboxFileDownloadUrlOptions, sandboxFilesTreeOptions, useDownloadSandboxFile } from '@/service/use-sandbox-file'
import { downloadUrl } from '@/utils/download'
import { buildTreeFromFlatList } from '../../skill/file-tree/artifacts/utils'
import { useStore } from '../../store'
import { WorkflowRunningStatus } from '../../types'
type PathSegment = {
part: string
key: string
isFirst: boolean
isLast: boolean
}
export type ArtifactsInspectStatus = 'loading' | 'empty' | 'split'
export type ArtifactsInspectView = {
downloadUrlData?: { download_url?: string }
handleFileSelect: (node: SandboxFileTreeNode) => void
handleSelectedFileDownload: () => void
handleTreeDownload: (node: SandboxFileTreeNode) => Promise<void>
isDownloadUrlLoading: boolean
isDownloading: boolean
pathSegments: PathSegment[]
selectedFile: SandboxFileTreeNode | null
selectedFilePath?: string
status: ArtifactsInspectStatus
treeData?: SandboxFileTreeNode[]
}
export const useArtifactsInspectView = (): ArtifactsInspectView => {
const appId = useStore(s => s.appId)
const isWorkflowRunning = useStore(
s => s.workflowRunningData?.result?.status === WorkflowRunningStatus.Running,
)
const isResponding = useStore(s => s.isResponding)
const { data: flatData, isLoading } = useQuery({
...sandboxFilesTreeOptions(appId),
refetchInterval: (isWorkflowRunning || isResponding) ? 5000 : false,
})
const treeData = useMemo(() => flatData ? buildTreeFromFlatList(flatData) : undefined, [flatData])
const hasFiles = (flatData?.length ?? 0) > 0
const { mutateAsync: fetchDownloadUrl, isPending: isDownloading } = useDownloadSandboxFile(appId)
const [selectedFile, setSelectedFile] = useState<SandboxFileTreeNode | null>(null)
const selectedFilePath = useMemo(() => {
if (!selectedFile)
return undefined
const selectedExists = flatData?.some(node => !node.is_dir && node.path === selectedFile.path) ?? false
return selectedExists ? selectedFile.path : undefined
}, [flatData, selectedFile])
const { data: downloadUrlData, isLoading: isDownloadUrlLoading } = useQuery({
...sandboxFileDownloadUrlOptions(appId, selectedFilePath),
retry: false,
})
const handleFileSelect = useCallback((node: SandboxFileTreeNode) => {
if (node.node_type === 'file')
setSelectedFile(node)
}, [])
const handleTreeDownload = useCallback(async (node: SandboxFileTreeNode) => {
try {
const ticket = await fetchDownloadUrl(node.path)
downloadUrl({ url: ticket.download_url, fileName: node.name })
}
catch (error) {
console.error('Download failed:', error)
}
}, [fetchDownloadUrl])
const handleSelectedFileDownload = useCallback(() => {
if (downloadUrlData?.download_url && selectedFile)
downloadUrl({ url: downloadUrlData.download_url, fileName: selectedFile.name })
}, [downloadUrlData, selectedFile])
const pathSegments = useMemo(() => {
const parts = selectedFilePath ? selectedFilePath.split('/') : []
let cumPath = ''
return parts.map((part, index) => {
cumPath += `${cumPath ? '/' : ''}${part}`
return {
part,
key: cumPath,
isFirst: index === 0,
isLast: index === parts.length - 1,
}
})
}, [selectedFilePath])
if (isLoading) {
return {
downloadUrlData,
handleFileSelect,
handleSelectedFileDownload,
handleTreeDownload,
isDownloadUrlLoading,
isDownloading,
pathSegments,
selectedFile,
selectedFilePath,
status: 'loading',
treeData,
}
}
if (!hasFiles) {
return {
downloadUrlData,
handleFileSelect,
handleSelectedFileDownload,
handleTreeDownload,
isDownloadUrlLoading,
isDownloading,
pathSegments,
selectedFile,
selectedFilePath,
status: 'empty',
treeData,
}
}
return {
downloadUrlData,
handleFileSelect,
handleSelectedFileDownload,
handleTreeDownload,
isDownloadUrlLoading,
isDownloading,
pathSegments,
selectedFile,
selectedFilePath,
status: 'split',
treeData,
}
}

View File

@ -1,19 +0,0 @@
import { createContext, use } from 'react'
type InspectShellContextValue = {
closeLeftPane: () => void
isNarrow: boolean
onClose: () => void
openLeftPane: () => void
}
export const InspectShellContext = createContext<InspectShellContextValue | null>(null)
export default function useInspectShell() {
const context = use(InspectShellContext)
if (!context)
throw new Error('useInspectShell must be used within InspectShell')
return context
}

View File

@ -1,3 +1,4 @@
import type { FC } from 'react'
import { debounce } from 'es-toolkit/compat'
import {
useCallback,
@ -8,7 +9,7 @@ import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
import { useStore } from '../store'
import Panel from './panel'
export default function VariableInspectPanel() {
const VariableInspectPanel: FC = () => {
const showVariableInspectPanel = useStore(s => s.showVariableInspectPanel)
const workflowCanvasHeight = useStore(s => s.workflowCanvasHeight)
const variableInspectPanelHeight = useStore(s => s.variableInspectPanelHeight)
@ -57,3 +58,5 @@ export default function VariableInspectPanel() {
</div>
)
}
export default VariableInspectPanel

View File

@ -0,0 +1,46 @@
import type { FC, ReactNode } from 'react'
import type { InspectTab } from './types'
import { RiCloseLine } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import TabHeader from './tab-header'
export type InspectHeaderProps = {
activeTab: InspectTab
onTabChange: (tab: InspectTab) => void
onClose: () => void
headerActions?: ReactNode
}
type InspectLayoutProps = InspectHeaderProps & {
children: ReactNode
}
const InspectLayout: FC<InspectLayoutProps> = ({
activeTab,
onTabChange,
onClose,
headerActions,
children,
}) => {
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center">
<div className="flex w-60 shrink-0 items-center">
<TabHeader activeTab={activeTab} onTabChange={onTabChange}>
{headerActions}
</TabHeader>
</div>
<div className="flex min-w-0 flex-1 justify-end pr-2 pt-2">
<ActionButton onClick={onClose} aria-label="Close">
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{children}
</div>
</div>
)
}
export default InspectLayout

View File

@ -1,30 +0,0 @@
import type { ReactNode } from 'react'
import { ScrollArea } from '@/app/components/base/ui/scroll-area'
import { cn } from '@/utils/classnames'
type InspectScrollAreaProps = {
children: ReactNode
className?: string
contentClassName?: string
label?: string
}
export default function InspectScrollArea({
children,
className,
contentClassName,
label,
}: InspectScrollAreaProps) {
return (
<ScrollArea
className={cn('h-full min-h-0', className)}
label={label}
slotClassNames={{
viewport: 'overscroll-contain',
content: cn('min-h-full', contentClassName),
}}
>
{children}
</ScrollArea>
)
}

View File

@ -1,102 +0,0 @@
import type { ReactNode } from 'react'
import type { InspectHeaderProps } from './types'
import { useMemo, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import { cn } from '@/utils/classnames'
import { useStore } from '../store'
import useInspectShell, { InspectShellContext } from './hooks/use-inspect-shell'
import TabHeader from './tab-header'
type InspectShellProps = InspectHeaderProps & {
children: ReactNode
left?: ReactNode
}
function SinglePaneCloseButton() {
const { onClose } = useInspectShell()
return (
<div className="flex min-w-0 flex-1 justify-end pr-2 pt-2">
<ActionButton onClick={onClose} aria-label="Close">
<span className="i-ri-close-line h-4 w-4" aria-hidden="true" />
</ActionButton>
</div>
)
}
export default function InspectShell({
activeTab,
onTabChange,
onClose,
headerActions,
left,
children,
}: InspectShellProps) {
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
const isNarrow = bottomPanelWidth < 488
const [showLeftPane, setShowLeftPane] = useState(true)
const hasLeftPane = !!left
const contextValue = useMemo(() => ({
closeLeftPane: () => setShowLeftPane(false),
isNarrow: hasLeftPane
? isNarrow
: false,
onClose,
openLeftPane: () => setShowLeftPane(true),
}), [hasLeftPane, isNarrow, onClose])
return (
<InspectShellContext value={contextValue}>
{hasLeftPane
? (
<div className="flex h-full">
<div className="relative flex w-60 shrink-0 flex-col border-r border-divider-burn">
<div className="flex shrink-0 items-center">
<TabHeader activeTab={activeTab} onTabChange={onTabChange}>
{headerActions}
</TabHeader>
</div>
{isNarrow && showLeftPane && (
<div
role="presentation"
className="absolute left-0 top-0 h-full w-full"
onClick={() => setShowLeftPane(false)}
/>
)}
<div
className={cn(
'min-h-0 flex-1',
isNarrow
? showLeftPane
? 'absolute left-0 top-0 z-10 h-full w-[217px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm'
: 'hidden'
: '',
)}
>
{left}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
{children}
</div>
</div>
)
: (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center">
<div className="flex w-60 shrink-0 items-center">
<TabHeader activeTab={activeTab} onTabChange={onTabChange}>
{headerActions}
</TabHeader>
</div>
<SinglePaneCloseButton />
</div>
<div className="flex min-h-0 flex-1 flex-col">
{children}
</div>
</div>
)}
</InspectShellContext>
)
}

View File

@ -1,14 +1,18 @@
import type { CurrentVarInInspect } from './types'
import type { currentVarType } from './variables-tab'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import { cn } from '@/utils/classnames'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import { useNodesInteractions } from '../hooks/use-nodes-interactions'
import { useStore } from '../store'
// import ActionButton from '@/app/components/base/action-button'
// import Tooltip from '@/app/components/base/tooltip'
import Group from './group'
type Props = {
currentNodeVar?: CurrentVarInInspect
handleVarSelect: (state: CurrentVarInInspect) => void
currentNodeVar?: currentVarType
handleVarSelect: (state: currentVarType) => void
}
const Left = ({
@ -36,48 +40,55 @@ const Left = ({
}
return (
<div className="py-1">
{environmentVariables.length > 0 && (
<Group
varType={VarInInspectType.environment}
varList={environmentVariables as VarInInspect[]}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
/>
)}
{conversationVars.length > 0 && (
<Group
varType={VarInInspectType.conversation}
varList={conversationVars}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
/>
)}
{systemVars.length > 0 && (
<Group
varType={VarInInspectType.system}
varList={systemVars}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
/>
)}
{showDivider && (
<div className="px-4 py-1">
<div className="h-px bg-divider-subtle"></div>
</div>
)}
{visibleNodesWithInspectVars.length > 0 && visibleNodesWithInspectVars.map(group => (
<Group
key={group.nodeId}
varType={VarInInspectType.node}
varList={group.vars}
nodeData={group}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
handleView={() => handleNodeSelect(group.nodeId, false, true)}
handleClear={() => handleClearNode(group.nodeId)}
/>
))}
<div className={cn('flex h-full flex-col')}>
<div className="grow overflow-y-auto py-1">
{/* group ENV */}
{environmentVariables.length > 0 && (
<Group
varType={VarInInspectType.environment}
varList={environmentVariables as VarInInspect[]}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
/>
)}
{/* group CHAT VAR */}
{conversationVars.length > 0 && (
<Group
varType={VarInInspectType.conversation}
varList={conversationVars}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
/>
)}
{/* group SYSTEM VAR */}
{systemVars.length > 0 && (
<Group
varType={VarInInspectType.system}
varList={systemVars}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
/>
)}
{/* divider */}
{showDivider && (
<div className="px-4 py-1">
<div className="h-px bg-divider-subtle"></div>
</div>
)}
{/* group nodes */}
{visibleNodesWithInspectVars.length > 0 && visibleNodesWithInspectVars.map(group => (
<Group
key={group.nodeId}
varType={VarInInspectType.node}
varList={group.vars}
nodeData={group}
currentVar={currentNodeVar}
handleSelect={handleVarSelect}
handleView={() => handleNodeSelect(group.nodeId, false, true)}
handleClear={() => handleClearNode(group.nodeId)}
/>
))}
</div>
</div>
)
}

View File

@ -1,4 +1,5 @@
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types'
import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types'
@ -7,7 +8,8 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import Button from '@/app/components/base/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator'
@ -73,10 +75,10 @@ export type ListeningProps = {
message?: string
}
export default function Listening({
const Listening: FC<ListeningProps> = ({
onStop,
message,
}: ListeningProps) {
}) => {
const { t } = useTranslation()
const store = useStoreApi()
@ -177,33 +179,28 @@ export default function Listening({
<div className="shrink-0 whitespace-pre-line text-text-tertiary system-xs-regular">
{t('nodes.triggerWebhook.debugUrlTitle', { ns: 'workflow' })}
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' }) || ''}
className={`inline-flex items-center rounded-[6px] border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-components-panel-border ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`}
onClick={() => {
copy(webhookDebugUrl)
setDebugUrlCopied(true)
}}
>
<span className="whitespace-nowrap text-text-primary">
{webhookDebugUrl}
</span>
</button>
)}
/>
<TooltipContent
placement="top"
sideOffset={-4}
popupClassName="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 text-text-primary shadow-lg backdrop-blur-sm system-xs-regular"
<Tooltip
popupContent={debugUrlCopied
? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' })
: t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })}
popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1"
position="top"
offset={{ mainAxis: -4 }}
needsDelay={true}
>
<button
type="button"
aria-label={t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' }) || ''}
className={`inline-flex items-center rounded-[6px] border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-components-panel-border ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`}
onClick={() => {
copy(webhookDebugUrl)
setDebugUrlCopied(true)
}}
>
{debugUrlCopied
? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' })
: t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })}
</TooltipContent>
<span className="whitespace-nowrap text-text-primary">
{webhookDebugUrl}
</span>
</button>
</Tooltip>
</div>
)}
@ -214,10 +211,12 @@ export default function Listening({
variant="primary"
onClick={onStop}
>
<span className="i-ri-stop-circle-line mr-1 size-4" aria-hidden="true" />
<StopCircle className="mr-1 size-4" />
{t('debug.variableInspect.listening.stopButton', { ns: 'workflow' })}
</Button>
</div>
</div>
)
}
export default Listening

View File

@ -1,164 +1,71 @@
import type { ReactNode } from 'react'
import type { InspectTab as InspectTabType } from './types'
import type { FC } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { useFeatures } from '@/app/components/base/features/hooks'
import Loading from '@/app/components/base/loading'
import { sandboxFilesTreeOptions } from '@/service/use-sandbox-file'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import { useStore } from '../store'
import ArtifactsEmptyState from './artifacts-empty-state'
import ArtifactsLeftPane from './artifacts-left-pane'
import ArtifactsRightPane from './artifacts-right-pane'
import Empty from './empty'
import {
useArtifactsInspectView,
} from './hooks/use-artifacts-inspect-state'
import {
useVariablesInspectView,
} from './hooks/use-variables-inspect-state'
import InspectScrollArea from './inspect-scroll-area'
import InspectShell from './inspect-shell'
import Left from './left'
import Listening from './listening'
import Right from './right'
import ArtifactsTab from './artifacts-tab'
import { InspectTab } from './types'
import VariablesTab from './variables-tab'
export default function Panel() {
const VariablesPanel: FC<{ onClose: () => void }> = ({ onClose }) => {
const { t } = useTranslation('workflow')
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
const environmentVariables = useStore(s => s.environmentVariables)
const appId = useStore(s => s.appId)
const sandboxEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
const [activeTab, setActiveTab] = useState<InspectTabType>(InspectTab.Variables)
const {
conversationVars,
systemVars,
nodesWithInspectVars,
deleteAllInspectorVars,
} = useCurrentVars()
const variablesState = useVariablesInspectView()
const artifactsState = useArtifactsInspectView()
const [activeTab, setActiveTab] = useState<InspectTab>(InspectTab.Variables)
const resolvedTab = (!sandboxEnabled && activeTab === InspectTab.Artifacts)
? InspectTab.Variables
: activeTab
const environmentVariables = useStore(s => s.environmentVariables)
const { conversationVars, systemVars, nodesWithInspectVars, deleteAllInspectorVars } = useCurrentVars()
const isVariablesEmpty = useMemo(() => {
return [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars].length === 0
}, [conversationVars, environmentVariables, nodesWithInspectVars, systemVars])
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
const hasArtifacts = artifactsState.status === 'split'
const hasData = !isVariablesEmpty || hasArtifacts
const { data: sandboxFiles } = useQuery(sandboxFilesTreeOptions(sandboxEnabled ? appId : undefined))
const hasArtifacts = (sandboxFiles?.length ?? 0) > 0
const handleClear = useCallback(() => {
deleteAllInspectorVars()
setCurrentFocusNodeId('')
}, [deleteAllInspectorVars, setCurrentFocusNodeId])
const hasData = !isVariablesEmpty || hasArtifacts
const headerActions = hasData
? (
<Button variant="ghost" size="small" onClick={handleClear}>
{t('debug.variableInspect.clearAll')}
</Button>
)
: undefined
const headerProps = {
activeTab: resolvedTab,
onTabChange: setActiveTab,
onClose,
headerActions,
}
return resolvedTab === InspectTab.Variables
? <VariablesTab {...headerProps} />
: <ArtifactsTab {...headerProps} />
}
const Panel: FC = () => {
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
const handleClose = useCallback(() => {
setShowVariableInspectPanel(false)
}, [setShowVariableInspectPanel])
const headerActions = (
<Button
variant="ghost"
size="small"
onClick={handleClear}
className={!hasData ? 'pointer-events-none invisible' : undefined}
aria-hidden={!hasData}
tabIndex={!hasData ? -1 : undefined}
>
{t('debug.variableInspect.clearAll')}
</Button>
)
const headerProps = {
activeTab: resolvedTab,
headerActions,
onClose: handleClose,
onTabChange: setActiveTab,
}
let leftPane: ReactNode | undefined
let body: ReactNode
if (resolvedTab === InspectTab.Variables) {
if (variablesState.status === 'listening') {
body = (
<div className="h-full p-2">
<Listening onStop={variablesState.onStopListening} />
</div>
)
}
else if (variablesState.status === 'empty') {
body = (
<div className="h-full p-2">
<Empty />
</div>
)
}
else {
leftPane = (
<InspectScrollArea>
<Left
currentNodeVar={variablesState.currentNodeVar}
handleVarSelect={variablesState.onSelectVar}
/>
</InspectScrollArea>
)
body = (
<Right
nodeId={variablesState.currentFocusNodeId || ''}
currentNodeVar={variablesState.currentNodeVar}
isValueFetching={variablesState.isValueFetching}
/>
)
}
}
else if (artifactsState.status === 'loading') {
body = (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
else if (artifactsState.status === 'empty') {
body = (
<div className="h-full p-2">
<ArtifactsEmptyState description={t('debug.variableInspect.tabArtifacts.emptyTip')} />
</div>
)
}
else {
leftPane = (
<InspectScrollArea>
<ArtifactsLeftPane
treeData={artifactsState.treeData}
handleTreeDownload={artifactsState.handleTreeDownload}
handleFileSelect={artifactsState.handleFileSelect}
selectedFilePath={artifactsState.selectedFilePath}
isDownloading={artifactsState.isDownloading}
/>
</InspectScrollArea>
)
body = (
<ArtifactsRightPane
downloadUrlData={artifactsState.downloadUrlData}
handleSelectedFileDownload={artifactsState.handleSelectedFileDownload}
isDownloadUrlLoading={artifactsState.isDownloadUrlLoading}
pathSegments={artifactsState.pathSegments}
selectedFile={artifactsState.selectedFile}
selectedFilePath={artifactsState.selectedFilePath}
/>
)
}
return (
<InspectShell {...headerProps} left={leftPane}>
{body}
</InspectShell>
)
return <VariablesPanel onClose={handleClose} />
}
export default Panel

View File

@ -1,6 +1,15 @@
import type { CurrentVarInInspect } from './types'
import type { FC } from 'react'
import type { SplitRightProps } from './split-panel'
import type { VarInspectValue } from './value-types'
import type { currentVarType } from './variables-tab'
import type { GenRes } from '@/service/debug'
import {
RiArrowGoBackLine,
RiCloseLine,
RiFileDownloadFill,
RiMenuLine,
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { produce } from 'immer'
import { useCallback, useMemo } from 'react'
@ -10,7 +19,7 @@ import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Loading from '@/app/components/base/loading'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -26,23 +35,24 @@ import useNodeInfo from '../nodes/_base/hooks/use-node-info'
import { CodeLanguage } from '../nodes/code/types'
import { BlockEnum } from '../types'
import Empty from './empty'
import useInspectShell from './hooks/use-inspect-shell'
import { formatVarTypeLabel } from './utils'
import ValueContent from './value-content'
type Props = {
type Props = SplitRightProps & {
nodeId: string
currentNodeVar?: CurrentVarInInspect
currentNodeVar?: currentVarType
isValueFetching?: boolean
}
export default function Right({
const Right: FC<Props> = ({
nodeId,
currentNodeVar,
isValueFetching,
}: Props) {
isNarrow,
onOpenMenu,
onClose,
}) => {
const { t } = useTranslation()
const { isNarrow, onClose, openLeftPane } = useInspectShell()
const toolIcon = useToolIcon(currentNodeVar?.nodeData)
const currentVar = currentNodeVar?.var
const currentNodeType = currentNodeVar?.nodeType
@ -101,7 +111,7 @@ export default function Right({
return node?.data?.prompt_template?.text || node?.data?.prompt_template?.[0].text
if (blockType === BlockEnum.Code)
return node?.data?.code
}, [blockType, canShowPromptGenerator, node?.data?.code, node?.data?.prompt_template])
}, [canShowPromptGenerator])
const [isShowPromptGenerator, {
setTrue: doShowPromptGenerator,
@ -135,7 +145,7 @@ export default function Right({
payload: res.modified,
})
handleHidePromptGenerator()
}, [eventEmitter, setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator])
}, [setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator])
const schemaType = currentVar?.schemaType
const valueType = currentVar?.value_type
@ -149,13 +159,11 @@ export default function Right({
<>
<div className="flex shrink-0 items-center justify-between gap-1 px-2 pt-2">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isNarrow
? (
<ActionButton className="shrink-0" onClick={openLeftPane} aria-label="Open menu">
<span className="i-ri-menu-line h-4 w-4" aria-hidden="true" />
</ActionButton>
)
: null}
{isNarrow && (
<ActionButton className="shrink-0" onClick={onOpenMenu} aria-label="Open menu">
<RiMenuLine className="h-4 w-4" />
</ActionButton>
)}
<div className="flex w-0 grow items-center gap-1">
{currentVar && (
<>
@ -204,70 +212,44 @@ export default function Right({
{currentVar && (
<>
{canShowPromptGenerator && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="cursor-pointer rounded-md p-1 hover:bg-state-accent-active"
onClick={handleShowPromptGenerator}
>
<span className="i-ri-sparkling-fill size-4 text-components-input-border-active-prompt-1" aria-hidden="true" />
</button>
)}
/>
<TooltipContent>{t('generate.optimizePromptTooltip', { ns: 'appDebug' })}</TooltipContent>
<Tooltip popupContent={t('generate.optimizePromptTooltip', { ns: 'appDebug' })}>
<button
type="button"
className="cursor-pointer rounded-md p-1 hover:bg-state-accent-active"
onClick={handleShowPromptGenerator}
>
<RiSparklingFill className="size-4 text-components-input-border-active-prompt-1" />
</button>
</Tooltip>
)}
{isTruncated && (
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex">
<ActionButton
onClick={() => window.open(fullContent?.download_url, '_blank')}
aria-label={t('debug.variableInspect.exportToolTip', { ns: 'workflow' })}
>
<span className="i-ri-file-download-fill size-4" aria-hidden="true" />
</ActionButton>
</span>
)}
/>
<TooltipContent>{t('debug.variableInspect.exportToolTip', { ns: 'workflow' })}</TooltipContent>
<Tooltip popupContent={t('debug.variableInspect.exportToolTip', { ns: 'workflow' })}>
<ActionButton
onClick={() => window.open(fullContent?.download_url, '_blank')}
aria-label={t('debug.variableInspect.exportToolTip', { ns: 'workflow' })}
>
<RiFileDownloadFill className="size-4" />
</ActionButton>
</Tooltip>
)}
{!isTruncated && currentVar.edited && (
<Badge>
<span className="ml-[2.5px] mr-[4.5px] h-[3px] w-[3px] rounded bg-text-accent-secondary"></span>
<span className="system-2xs-semibold-uppercase">{t('debug.variableInspect.edited', { ns: 'workflow' })}</span>
<span className="system-2xs-semibold-uupercase">{t('debug.variableInspect.edited', { ns: 'workflow' })}</span>
</Badge>
)}
{!isTruncated && currentVar.edited && currentVar.type !== VarInInspectType.conversation && (
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex">
<ActionButton onClick={resetValue}>
<span className="i-ri-arrow-go-back-line h-4 w-4" aria-hidden="true" />
</ActionButton>
</span>
)}
/>
<TooltipContent>{t('debug.variableInspect.reset', { ns: 'workflow' })}</TooltipContent>
<Tooltip popupContent={t('debug.variableInspect.reset', { ns: 'workflow' })}>
<ActionButton onClick={resetValue}>
<RiArrowGoBackLine className="h-4 w-4" />
</ActionButton>
</Tooltip>
)}
{!isTruncated && currentVar.edited && currentVar.type === VarInInspectType.conversation && (
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex">
<ActionButton onClick={handleClear}>
<span className="i-ri-arrow-go-back-line h-4 w-4" aria-hidden="true" />
</ActionButton>
</span>
)}
/>
<TooltipContent>{t('debug.variableInspect.resetConversationVar', { ns: 'workflow' })}</TooltipContent>
<Tooltip popupContent={t('debug.variableInspect.resetConversationVar', { ns: 'workflow' })}>
<ActionButton onClick={handleClear}>
<RiArrowGoBackLine className="h-4 w-4" />
</ActionButton>
</Tooltip>
)}
{currentVar.value_type !== 'secret' && (
@ -278,7 +260,7 @@ export default function Right({
</div>
</div>
<ActionButton className="shrink-0" onClick={onClose} aria-label="Close">
<span className="i-ri-close-line h-4 w-4" aria-hidden="true" />
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<div className="flex min-h-0 flex-1 flex-col">
@ -328,3 +310,5 @@ export default function Right({
</>
)
}
export default Right

View File

@ -0,0 +1,62 @@
import type { FC, ReactNode } from 'react'
import type { InspectHeaderProps } from './inspect-layout'
import { useState } from 'react'
import { cn } from '@/utils/classnames'
import { useStore } from '../store'
import TabHeader from './tab-header'
export type SplitRightProps = {
isNarrow: boolean
onOpenMenu: () => void
onClose: () => void
}
type SplitPanelProps = InspectHeaderProps & {
left: ReactNode
children: (rightProps: SplitRightProps) => ReactNode
}
const SplitPanel: FC<SplitPanelProps> = ({
activeTab,
onTabChange,
onClose,
headerActions,
left,
children,
}) => {
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
const isNarrow = bottomPanelWidth < 488
const [showLeftPanel, setShowLeftPanel] = useState(true)
return (
<div className="flex h-full">
<div className="relative flex w-60 shrink-0 flex-col border-r border-divider-burn">
<div className="flex shrink-0 items-center">
<TabHeader activeTab={activeTab} onTabChange={onTabChange}>
{headerActions}
</TabHeader>
</div>
{isNarrow && showLeftPanel && (
<div role="presentation" className="absolute left-0 top-0 h-full w-full" onClick={() => setShowLeftPanel(false)} />
)}
<div
className={cn(
'min-h-0 flex-1',
isNarrow
? showLeftPanel
? 'absolute left-0 top-0 z-10 h-full w-[217px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm'
: 'hidden'
: '',
)}
>
{left}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
{children({ isNarrow, onOpenMenu: () => setShowLeftPanel(true), onClose })}
</div>
</div>
)
}
export default SplitPanel

View File

@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import type { FC, ReactNode } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useFeatures } from '@/app/components/base/features/hooks'
@ -16,11 +16,11 @@ type TabHeaderProps = {
children?: ReactNode
}
export default function TabHeader({
const TabHeader: FC<TabHeaderProps> = ({
activeTab,
onTabChange,
children,
}: TabHeaderProps) {
}) => {
const { t } = useTranslation('workflow')
const sandboxEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
@ -46,7 +46,9 @@ export default function TabHeader({
{t(tab.labelKey)}
</button>
))}
<div className="ml-auto flex justify-end">{children}</div>
<div className="ml-auto">{children}</div>
</div>
)
}
export default TabHeader

View File

@ -1,8 +1,10 @@
import type { FC } from 'react'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { RiLoader2Line, RiStopCircleFill } from '@remixicon/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Tooltip from '@/app/components/base/tooltip'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -11,7 +13,7 @@ import useCurrentVars from '../hooks/use-inspect-vars-crud'
import { useNodesReadOnly } from '../hooks/use-workflow'
import { useStore } from '../store'
export default function VariableInspectTrigger() {
const VariableInspectTrigger: FC = () => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
@ -102,22 +104,19 @@ export default function VariableInspectTrigger() {
className="flex h-6 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 text-text-accent shadow-lg backdrop-blur-sm system-xs-medium hover:bg-components-actionbar-bg-accent"
onClick={() => setShowVariableInspectPanel(true)}
>
<span className="i-ri-loader-2-line h-4 w-4 animate-spin" aria-hidden="true" />
<RiLoader2Line className="h-4 w-4 animate-spin" />
<span className="text-text-accent">{t('debug.variableInspect.trigger.running', { ns: 'workflow' })}</span>
</div>
{isPreviewRunning && (
<Tooltip>
<TooltipTrigger
render={(
<div
className="flex h-6 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 shadow-lg backdrop-blur-sm hover:bg-components-actionbar-bg-accent"
onClick={handleStop}
>
<span className="i-ri-stop-circle-fill h-4 w-4 text-text-accent" aria-hidden="true" />
</div>
)}
/>
<TooltipContent>{t('debug.variableInspect.trigger.stop', { ns: 'workflow' })}</TooltipContent>
<Tooltip
popupContent={t('debug.variableInspect.trigger.stop', { ns: 'workflow' })}
>
<div
className="flex h-6 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 shadow-lg backdrop-blur-sm hover:bg-components-actionbar-bg-accent"
onClick={handleStop}
>
<RiStopCircleFill className="h-4 w-4 text-text-accent" />
</div>
</Tooltip>
)}
</>
@ -125,3 +124,5 @@ export default function VariableInspectTrigger() {
</div>
)
}
export default VariableInspectTrigger

View File

@ -1,7 +1,3 @@
import type { ReactNode } from 'react'
import type { NodeProps } from '../types'
import type { VarInInspect } from '@/types/workflow'
/* eslint-disable ts/no-redeclare -- const + type share names (erasable enum replacement) */
export const EVENT_WORKFLOW_STOP = 'WORKFLOW_STOP'
@ -24,19 +20,3 @@ export const InspectTab = {
Artifacts: 'artifacts',
} as const
export type InspectTab = typeof InspectTab[keyof typeof InspectTab]
export type InspectHeaderProps = {
activeTab: InspectTab
headerActions?: ReactNode
onClose: () => void
onTabChange: (tab: InspectTab) => void
}
export type CurrentVarInInspect = {
nodeId: string
nodeType: string
title: string
isValueFetched?: boolean
var?: VarInInspect
nodeData?: NodeProps['data']
}

View File

@ -1,25 +1,32 @@
import type { CurrentVarInInspect } from '../types'
import type { FC } from 'react'
import type { NodeProps } from '../types'
import type { InspectHeaderProps } from './inspect-layout'
import type { VarInInspect } from '@/types/workflow'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { VarInInspectType } from '@/types/workflow'
import useCurrentVars from '../../hooks/use-inspect-vars-crud'
import useMatchSchemaType from '../../nodes/_base/components/variable/use-match-schema-type'
import { useStore } from '../../store'
import { EVENT_WORKFLOW_STOP } from '../types'
import { toEnvVarInInspect } from '../utils'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type'
import { useStore } from '../store'
import Empty from './empty'
import InspectLayout from './inspect-layout'
import Left from './left'
import Listening from './listening'
import Right from './right'
import SplitPanel from './split-panel'
import { EVENT_WORKFLOW_STOP } from './types'
import { toEnvVarInInspect } from './utils'
export type VariablesInspectStatus = 'listening' | 'empty' | 'split'
export type VariablesInspectView = {
currentFocusNodeId: string | null
currentNodeVar?: CurrentVarInInspect
isValueFetching: boolean
onSelectVar: (node: CurrentVarInInspect) => void
onStopListening: () => void
status: VariablesInspectStatus
export type currentVarType = {
nodeId: string
nodeType: string
title: string
isValueFetched?: boolean
var?: VarInInspect
nodeData?: NodeProps['data']
}
export const useVariablesInspectView = (): VariablesInspectView => {
const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
const isListening = useStore(s => s.isListening)
const environmentVariables = useStore(s => s.environmentVariables)
const currentFocusNodeId = useStore(s => s.currentFocusNodeId)
@ -37,10 +44,9 @@ export const useVariablesInspectView = (): VariablesInspectView => {
return [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars].length === 0
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
const currentNodeVar = useMemo(() => {
const currentNodeInfo = useMemo(() => {
if (!currentFocusNodeId)
return undefined
return
if (currentFocusNodeId === VarInInspectType.environment) {
const currentVar = environmentVariables.find(v => v.id === currentVarId)
return {
@ -50,7 +56,6 @@ export const useVariablesInspectView = (): VariablesInspectView => {
var: currentVar ? toEnvVarInInspect(currentVar) : undefined,
}
}
if (currentFocusNodeId === VarInInspectType.conversation) {
const currentVar = conversationVars.find(v => v.id === currentVarId)
return {
@ -65,7 +70,6 @@ export const useVariablesInspectView = (): VariablesInspectView => {
: undefined,
}
}
if (currentFocusNodeId === VarInInspectType.system) {
const currentVar = systemVars.find(v => v.id === currentVarId)
return {
@ -80,46 +84,44 @@ export const useVariablesInspectView = (): VariablesInspectView => {
: undefined,
}
}
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId)
if (!targetNode)
return undefined
return
const currentVar = targetNode.vars.find(v => v.id === currentVarId)
return {
nodeId: targetNode.nodeId,
nodeType: targetNode.nodeType,
title: targetNode.title,
isSingRunRunning: targetNode.isSingRunRunning,
isValueFetched: targetNode.isValueFetched,
nodeData: targetNode.nodePayload,
var: currentVar,
}
}, [conversationVars, currentFocusNodeId, currentVarId, environmentVariables, nodesWithInspectVars, systemVars])
}, [currentFocusNodeId, currentVarId, environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
const currentAliasMeta = useMemo(() => {
if (!currentFocusNodeId || !currentVarId)
return undefined
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId)
const targetVar = targetNode?.vars.find(v => v.id === currentVarId)
return targetVar?.aliasMeta
}, [currentFocusNodeId, currentVarId, nodesWithInspectVars])
const fetchNodeId = currentAliasMeta?.extractorNodeId || currentFocusNodeId
const isValueFetching = useMemo(() => {
const isCurrentNodeVarValueFetching = useMemo(() => {
if (!fetchNodeId)
return false
const targetNode = nodesWithInspectVars.find(node => node.nodeId === fetchNodeId)
return targetNode ? !targetNode.isValueFetched : false
if (!targetNode)
return false
return !targetNode.isValueFetched
}, [fetchNodeId, nodesWithInspectVars])
const onSelectVar = useCallback((node: CurrentVarInInspect) => {
const handleNodeVarSelect = useCallback((node: currentVarType) => {
setCurrentFocusNodeId(node.nodeId)
if (node.var)
setCurrentVarId(node.var.id)
}, [setCurrentFocusNodeId])
}, [setCurrentFocusNodeId, setCurrentVarId])
const { isLoading, schemaTypeDefinitions } = useMatchSchemaType()
const { eventEmitter } = useEventEmitterContextContext()
@ -129,42 +131,49 @@ export const useVariablesInspectView = (): VariablesInspectView => {
}, [eventEmitter])
useEffect(() => {
if (!currentFocusNodeId || !currentVarId || isLoading || !fetchNodeId)
return
const targetNode = nodesWithInspectVars.find(node => node.nodeId === fetchNodeId)
if (targetNode && !targetNode.isValueFetched)
fetchInspectVarValue([fetchNodeId], schemaTypeDefinitions!)
}, [currentFocusNodeId, currentVarId, fetchInspectVarValue, fetchNodeId, isLoading, nodesWithInspectVars, schemaTypeDefinitions])
if (currentFocusNodeId && currentVarId && !isLoading && fetchNodeId) {
const targetNode = nodesWithInspectVars.find(node => node.nodeId === fetchNodeId)
if (targetNode && !targetNode.isValueFetched)
fetchInspectVarValue([fetchNodeId], schemaTypeDefinitions!)
}
}, [currentFocusNodeId, currentVarId, nodesWithInspectVars, fetchInspectVarValue, schemaTypeDefinitions, isLoading, fetchNodeId])
if (isListening) {
return {
currentFocusNodeId,
currentNodeVar,
isValueFetching,
onSelectVar,
onStopListening,
status: 'listening',
}
return (
<InspectLayout {...headerProps}>
<div className="h-full p-2"><Listening onStop={onStopListening} /></div>
</InspectLayout>
)
}
if (isEmpty) {
return {
currentFocusNodeId,
currentNodeVar,
isValueFetching,
onSelectVar,
onStopListening,
status: 'empty',
}
return (
<InspectLayout {...headerProps}>
<div className="h-full p-2"><Empty /></div>
</InspectLayout>
)
}
return {
currentFocusNodeId,
currentNodeVar,
isValueFetching,
onSelectVar,
onStopListening,
status: 'split',
}
return (
<SplitPanel
{...headerProps}
left={(
<Left
currentNodeVar={currentNodeInfo as currentVarType}
handleVarSelect={handleNodeVarSelect}
/>
)}
>
{rightProps => (
<Right
{...rightProps}
nodeId={currentFocusNodeId!}
currentNodeVar={currentNodeInfo as currentVarType}
isValueFetching={isCurrentNodeVarValueFetching}
/>
)}
</SplitPanel>
)
}
export default VariablesTab