mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
Refactor variable inspect panel layout and scrolling
This commit is contained in:
@ -17,17 +17,33 @@ 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
|
||||
@ -89,6 +105,21 @@ 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: {
|
||||
@ -97,6 +128,17 @@ 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',
|
||||
@ -124,6 +166,20 @@ 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: [],
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import type { SandboxFileNode } from '@/types/sandbox-file'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ArtifactsTab from './artifacts-tab'
|
||||
import { InspectTab } from './types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useArtifactsInspectView } from './hooks/use-artifacts-inspect-state'
|
||||
|
||||
type MockStoreState = {
|
||||
appId: string | undefined
|
||||
@ -11,7 +10,6 @@ type MockStoreState = {
|
||||
}
|
||||
}
|
||||
isResponding: boolean
|
||||
bottomPanelWidth: number
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@ -19,7 +17,6 @@ const mocks = vi.hoisted(() => ({
|
||||
appId: 'app-1',
|
||||
workflowRunningData: undefined,
|
||||
isResponding: false,
|
||||
bottomPanelWidth: 640,
|
||||
} as MockStoreState,
|
||||
flatData: [] as SandboxFileNode[],
|
||||
isLoading: false,
|
||||
@ -41,7 +38,7 @@ vi.mock('../store', () => ({
|
||||
|
||||
vi.mock('@tanstack/react-query', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@tanstack/react-query')>(),
|
||||
useQuery: (options: unknown) => mocks.mockUseQuery(options),
|
||||
useQuery: (options: { queryKey?: unknown }) => mocks.mockUseQuery(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-sandbox-file', async importOriginal => ({
|
||||
@ -54,20 +51,6 @@ 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(),
|
||||
}))
|
||||
@ -81,13 +64,12 @@ const createFlatFileNode = (overrides: Partial<SandboxFileNode> = {}): SandboxFi
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ArtifactsTab', () => {
|
||||
describe('useArtifactsInspectState', () => {
|
||||
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
|
||||
@ -99,6 +81,7 @@ describe('ArtifactsTab', () => {
|
||||
isLoading: mocks.isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@ -107,23 +90,24 @@ describe('ArtifactsTab', () => {
|
||||
})
|
||||
|
||||
it('should stop using stale file path for download url query after files are cleared', async () => {
|
||||
const headerProps = {
|
||||
activeTab: InspectTab.Artifacts,
|
||||
onTabChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
const { result, rerender } = renderHook(() => useArtifactsInspectView())
|
||||
|
||||
const { rerender } = render(<ArtifactsTab {...headerProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'a.txt' }))
|
||||
act(() => {
|
||||
result.current.handleFileSelect({
|
||||
name: 'a.txt',
|
||||
path: 'a.txt',
|
||||
node_type: 'file',
|
||||
size: 128,
|
||||
extension: 'txt',
|
||||
} as never)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.mockDownloadUrlOptions).toHaveBeenCalledWith('app-1', 'a.txt')
|
||||
})
|
||||
|
||||
mocks.flatData = []
|
||||
|
||||
rerender(<ArtifactsTab {...headerProps} />)
|
||||
rerender()
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = mocks.mockDownloadUrlOptions.mock.calls.at(-1)
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
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
|
||||
@ -1,18 +1,9 @@
|
||||
import type { currentVarType } from './variables-tab'
|
||||
import type { CurrentVarInInspect } from './types'
|
||||
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 { AtSign } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/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'
|
||||
@ -22,15 +13,15 @@ import { formatVarTypeLabel } from './utils'
|
||||
|
||||
type Props = {
|
||||
nodeData?: NodeWithVar
|
||||
currentVar?: currentVarType
|
||||
currentVar?: CurrentVarInInspect
|
||||
varType: VarInInspectType
|
||||
varList: VarInInspect[]
|
||||
handleSelect: (state: currentVarType) => void
|
||||
handleSelect: (state: CurrentVarInInspect) => void
|
||||
handleView?: () => void
|
||||
handleClear?: () => void
|
||||
}
|
||||
|
||||
const Group = ({
|
||||
export default function Group({
|
||||
nodeData,
|
||||
currentVar,
|
||||
varType,
|
||||
@ -38,7 +29,7 @@ const Group = ({
|
||||
handleSelect,
|
||||
handleView,
|
||||
handleClear,
|
||||
}: Props) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
|
||||
@ -104,10 +95,10 @@ const Group = ({
|
||||
<div className="group flex h-6 items-center gap-0.5">
|
||||
<div className="h-3 w-3 shrink-0">
|
||||
{nodeData?.isSingRunRunning && (
|
||||
<RiLoader2Line className="h-3 w-3 animate-spin text-text-accent" />
|
||||
<span aria-hidden="true" className="i-ri-loader-2-line h-3 w-3 animate-spin text-text-accent" />
|
||||
)}
|
||||
{(!nodeData || !nodeData.isSingRunRunning) && visibleVarList.length > 0 && (
|
||||
<RiArrowRightSLine className={cn('h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} aria-hidden="true" />
|
||||
<span aria-hidden="true" className={cn('i-ri-arrow-right-s-line h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow cursor-pointer items-center gap-1" onClick={() => setIsCollapsed(!isCollapsed)}>
|
||||
@ -132,15 +123,29 @@ const Group = ({
|
||||
</div>
|
||||
{nodeData && !nodeData.isSingRunRunning && (
|
||||
<div className="hidden shrink-0 items-center group-hover:flex">
|
||||
<Tooltip popupContent={t('debug.variableInspect.view', { ns: 'workflow' })}>
|
||||
<ActionButton onClick={handleView}>
|
||||
<RiFileList3Line className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<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>
|
||||
<Tooltip popupContent={t('debug.variableInspect.clearNode', { ns: 'workflow' })}>
|
||||
<ActionButton onClick={handleClear}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@ -162,7 +167,7 @@ const Group = ({
|
||||
onClick={() => handleSelectVar(varItem, varType)}
|
||||
>
|
||||
{isAgentAliasVar
|
||||
? <AtSign className="size-4 shrink-0 text-util-colors-violet-violet-600" />
|
||||
? <span className="inline-flex size-4 shrink-0 items-center justify-center text-util-colors-violet-violet-600 system-xs-semibold">@</span>
|
||||
: (
|
||||
<VariableIconWithColor
|
||||
variableCategory={varType}
|
||||
@ -180,5 +185,3 @@ const Group = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Group
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
||||
@ -1,32 +1,25 @@
|
||||
import type { FC } from 'react'
|
||||
import type { NodeProps } from '../types'
|
||||
import type { InspectHeaderProps } from './inspect-layout'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import type { CurrentVarInInspect } from '../types'
|
||||
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 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'
|
||||
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'
|
||||
|
||||
export type currentVarType = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
title: string
|
||||
isValueFetched?: boolean
|
||||
var?: VarInInspect
|
||||
nodeData?: NodeProps['data']
|
||||
export type VariablesInspectStatus = 'listening' | 'empty' | 'split'
|
||||
|
||||
export type VariablesInspectView = {
|
||||
currentFocusNodeId: string | null
|
||||
currentNodeVar?: CurrentVarInInspect
|
||||
isValueFetching: boolean
|
||||
onSelectVar: (node: CurrentVarInInspect) => void
|
||||
onStopListening: () => void
|
||||
status: VariablesInspectStatus
|
||||
}
|
||||
|
||||
const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
|
||||
export const useVariablesInspectView = (): VariablesInspectView => {
|
||||
const isListening = useStore(s => s.isListening)
|
||||
const environmentVariables = useStore(s => s.environmentVariables)
|
||||
const currentFocusNodeId = useStore(s => s.currentFocusNodeId)
|
||||
@ -44,9 +37,10 @@ const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
|
||||
return [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars].length === 0
|
||||
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
|
||||
|
||||
const currentNodeInfo = useMemo(() => {
|
||||
const currentNodeVar = useMemo(() => {
|
||||
if (!currentFocusNodeId)
|
||||
return
|
||||
return undefined
|
||||
|
||||
if (currentFocusNodeId === VarInInspectType.environment) {
|
||||
const currentVar = environmentVariables.find(v => v.id === currentVarId)
|
||||
return {
|
||||
@ -56,6 +50,7 @@ const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
|
||||
var: currentVar ? toEnvVarInInspect(currentVar) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFocusNodeId === VarInInspectType.conversation) {
|
||||
const currentVar = conversationVars.find(v => v.id === currentVarId)
|
||||
return {
|
||||
@ -70,6 +65,7 @@ const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFocusNodeId === VarInInspectType.system) {
|
||||
const currentVar = systemVars.find(v => v.id === currentVarId)
|
||||
return {
|
||||
@ -84,44 +80,46 @@ const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId)
|
||||
if (!targetNode)
|
||||
return
|
||||
return undefined
|
||||
|
||||
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,
|
||||
}
|
||||
}, [currentFocusNodeId, currentVarId, environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
|
||||
}, [conversationVars, currentFocusNodeId, currentVarId, environmentVariables, nodesWithInspectVars, systemVars])
|
||||
|
||||
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 isCurrentNodeVarValueFetching = useMemo(() => {
|
||||
const isValueFetching = useMemo(() => {
|
||||
if (!fetchNodeId)
|
||||
return false
|
||||
|
||||
const targetNode = nodesWithInspectVars.find(node => node.nodeId === fetchNodeId)
|
||||
if (!targetNode)
|
||||
return false
|
||||
return !targetNode.isValueFetched
|
||||
return targetNode ? !targetNode.isValueFetched : false
|
||||
}, [fetchNodeId, nodesWithInspectVars])
|
||||
|
||||
const handleNodeVarSelect = useCallback((node: currentVarType) => {
|
||||
const onSelectVar = useCallback((node: CurrentVarInInspect) => {
|
||||
setCurrentFocusNodeId(node.nodeId)
|
||||
if (node.var)
|
||||
setCurrentVarId(node.var.id)
|
||||
}, [setCurrentFocusNodeId, setCurrentVarId])
|
||||
}, [setCurrentFocusNodeId])
|
||||
|
||||
const { isLoading, schemaTypeDefinitions } = useMatchSchemaType()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
@ -131,49 +129,42 @@ const VariablesTab: FC<InspectHeaderProps> = (headerProps) => {
|
||||
}, [eventEmitter])
|
||||
|
||||
useEffect(() => {
|
||||
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 (!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 (isListening) {
|
||||
return (
|
||||
<InspectLayout {...headerProps}>
|
||||
<div className="h-full p-2"><Listening onStop={onStopListening} /></div>
|
||||
</InspectLayout>
|
||||
)
|
||||
return {
|
||||
currentFocusNodeId,
|
||||
currentNodeVar,
|
||||
isValueFetching,
|
||||
onSelectVar,
|
||||
onStopListening,
|
||||
status: 'listening',
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<InspectLayout {...headerProps}>
|
||||
<div className="h-full p-2"><Empty /></div>
|
||||
</InspectLayout>
|
||||
)
|
||||
return {
|
||||
currentFocusNodeId,
|
||||
currentNodeVar,
|
||||
isValueFetching,
|
||||
onSelectVar,
|
||||
onStopListening,
|
||||
status: 'empty',
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitPanel
|
||||
{...headerProps}
|
||||
left={(
|
||||
<Left
|
||||
currentNodeVar={currentNodeInfo as currentVarType}
|
||||
handleVarSelect={handleNodeVarSelect}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{rightProps => (
|
||||
<Right
|
||||
{...rightProps}
|
||||
nodeId={currentFocusNodeId!}
|
||||
currentNodeVar={currentNodeInfo as currentVarType}
|
||||
isValueFetching={isCurrentNodeVarValueFetching}
|
||||
/>
|
||||
)}
|
||||
</SplitPanel>
|
||||
)
|
||||
return {
|
||||
currentFocusNodeId,
|
||||
currentNodeVar,
|
||||
isValueFetching,
|
||||
onSelectVar,
|
||||
onStopListening,
|
||||
status: 'split',
|
||||
}
|
||||
}
|
||||
|
||||
export default VariablesTab
|
||||
@ -1,4 +1,3 @@
|
||||
import type { FC } from 'react'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import {
|
||||
useCallback,
|
||||
@ -9,7 +8,7 @@ import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
|
||||
import { useStore } from '../store'
|
||||
import Panel from './panel'
|
||||
|
||||
const VariableInspectPanel: FC = () => {
|
||||
export default function VariableInspectPanel() {
|
||||
const showVariableInspectPanel = useStore(s => s.showVariableInspectPanel)
|
||||
const workflowCanvasHeight = useStore(s => s.workflowCanvasHeight)
|
||||
const variableInspectPanelHeight = useStore(s => s.variableInspectPanelHeight)
|
||||
@ -58,5 +57,3 @@ const VariableInspectPanel: FC = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariableInspectPanel
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
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
|
||||
@ -0,0 +1,30 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
102
web/app/components/workflow/variable-inspect/inspect-shell.tsx
Normal file
102
web/app/components/workflow/variable-inspect/inspect-shell.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,18 +1,14 @@
|
||||
import type { currentVarType } from './variables-tab'
|
||||
|
||||
import type { CurrentVarInInspect } from './types'
|
||||
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?: currentVarType
|
||||
handleVarSelect: (state: currentVarType) => void
|
||||
currentNodeVar?: CurrentVarInInspect
|
||||
handleVarSelect: (state: CurrentVarInInspect) => void
|
||||
}
|
||||
|
||||
const Left = ({
|
||||
@ -40,55 +36,48 @@ const Left = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
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'
|
||||
@ -8,8 +7,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/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'
|
||||
@ -75,10 +73,10 @@ export type ListeningProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Listening: FC<ListeningProps> = ({
|
||||
export default function Listening({
|
||||
onStop,
|
||||
message,
|
||||
}) => {
|
||||
}: ListeningProps) {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
|
||||
@ -179,28 +177,33 @@ const Listening: FC<ListeningProps> = ({
|
||||
<div className="shrink-0 whitespace-pre-line text-text-tertiary system-xs-regular">
|
||||
{t('nodes.triggerWebhook.debugUrlTitle', { ns: 'workflow' })}
|
||||
</div>
|
||||
<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)
|
||||
}}
|
||||
<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"
|
||||
>
|
||||
<span className="whitespace-nowrap text-text-primary">
|
||||
{webhookDebugUrl}
|
||||
</span>
|
||||
</button>
|
||||
{debugUrlCopied
|
||||
? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' })
|
||||
: t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@ -211,12 +214,10 @@ const Listening: FC<ListeningProps> = ({
|
||||
variant="primary"
|
||||
onClick={onStop}
|
||||
>
|
||||
<StopCircle className="mr-1 size-4" />
|
||||
<span className="i-ri-stop-circle-line mr-1 size-4" aria-hidden="true" />
|
||||
{t('debug.variableInspect.listening.stopButton', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Listening
|
||||
|
||||
@ -1,71 +1,164 @@
|
||||
import type { FC } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { InspectTab as InspectTabType } from './types'
|
||||
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 { sandboxFilesTreeOptions } from '@/service/use-sandbox-file'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useCurrentVars from '../hooks/use-inspect-vars-crud'
|
||||
import { useStore } from '../store'
|
||||
import ArtifactsTab from './artifacts-tab'
|
||||
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 { InspectTab } from './types'
|
||||
import VariablesTab from './variables-tab'
|
||||
|
||||
const VariablesPanel: FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
export default function Panel() {
|
||||
const { t } = useTranslation('workflow')
|
||||
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
|
||||
const appId = useStore(s => s.appId)
|
||||
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
|
||||
const environmentVariables = useStore(s => s.environmentVariables)
|
||||
const sandboxEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
|
||||
const [activeTab, setActiveTab] = useState<InspectTab>(InspectTab.Variables)
|
||||
const [activeTab, setActiveTab] = useState<InspectTabType>(InspectTab.Variables)
|
||||
|
||||
const {
|
||||
conversationVars,
|
||||
systemVars,
|
||||
nodesWithInspectVars,
|
||||
deleteAllInspectorVars,
|
||||
} = useCurrentVars()
|
||||
|
||||
const variablesState = useVariablesInspectView()
|
||||
const artifactsState = useArtifactsInspectView()
|
||||
|
||||
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
|
||||
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
|
||||
}, [conversationVars, environmentVariables, nodesWithInspectVars, systemVars])
|
||||
|
||||
const { data: sandboxFiles } = useQuery(sandboxFilesTreeOptions(sandboxEnabled ? appId : undefined))
|
||||
const hasArtifacts = (sandboxFiles?.length ?? 0) > 0
|
||||
const hasArtifacts = artifactsState.status === 'split'
|
||||
const hasData = !isVariablesEmpty || hasArtifacts
|
||||
|
||||
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])
|
||||
|
||||
return <VariablesPanel onClose={handleClose} />
|
||||
}
|
||||
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>
|
||||
)
|
||||
|
||||
export default Panel
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SplitRightProps } from './split-panel'
|
||||
import type { CurrentVarInInspect } from './types'
|
||||
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'
|
||||
@ -19,7 +10,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 from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/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'
|
||||
@ -35,24 +26,23 @@ 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 = SplitRightProps & {
|
||||
type Props = {
|
||||
nodeId: string
|
||||
currentNodeVar?: currentVarType
|
||||
currentNodeVar?: CurrentVarInInspect
|
||||
isValueFetching?: boolean
|
||||
}
|
||||
|
||||
const Right: FC<Props> = ({
|
||||
export default function Right({
|
||||
nodeId,
|
||||
currentNodeVar,
|
||||
isValueFetching,
|
||||
isNarrow,
|
||||
onOpenMenu,
|
||||
onClose,
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { isNarrow, onClose, openLeftPane } = useInspectShell()
|
||||
const toolIcon = useToolIcon(currentNodeVar?.nodeData)
|
||||
const currentVar = currentNodeVar?.var
|
||||
const currentNodeType = currentNodeVar?.nodeType
|
||||
@ -111,7 +101,7 @@ const Right: FC<Props> = ({
|
||||
return node?.data?.prompt_template?.text || node?.data?.prompt_template?.[0].text
|
||||
if (blockType === BlockEnum.Code)
|
||||
return node?.data?.code
|
||||
}, [canShowPromptGenerator])
|
||||
}, [blockType, canShowPromptGenerator, node?.data?.code, node?.data?.prompt_template])
|
||||
|
||||
const [isShowPromptGenerator, {
|
||||
setTrue: doShowPromptGenerator,
|
||||
@ -145,7 +135,7 @@ const Right: FC<Props> = ({
|
||||
payload: res.modified,
|
||||
})
|
||||
handleHidePromptGenerator()
|
||||
}, [setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator])
|
||||
}, [eventEmitter, setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator])
|
||||
|
||||
const schemaType = currentVar?.schemaType
|
||||
const valueType = currentVar?.value_type
|
||||
@ -159,11 +149,13 @@ const Right: FC<Props> = ({
|
||||
<>
|
||||
<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">
|
||||
<RiMenuLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{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}
|
||||
<div className="flex w-0 grow items-center gap-1">
|
||||
{currentVar && (
|
||||
<>
|
||||
@ -212,44 +204,70 @@ const Right: FC<Props> = ({
|
||||
{currentVar && (
|
||||
<>
|
||||
{canShowPromptGenerator && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{isTruncated && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!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-uupercase">{t('debug.variableInspect.edited', { ns: 'workflow' })}</span>
|
||||
<span className="system-2xs-semibold-uppercase">{t('debug.variableInspect.edited', { ns: 'workflow' })}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{!isTruncated && currentVar.edited && currentVar.type !== VarInInspectType.conversation && (
|
||||
<Tooltip popupContent={t('debug.variableInspect.reset', { ns: 'workflow' })}>
|
||||
<ActionButton onClick={resetValue}>
|
||||
<RiArrowGoBackLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<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>
|
||||
)}
|
||||
{!isTruncated && currentVar.edited && currentVar.type === VarInInspectType.conversation && (
|
||||
<Tooltip popupContent={t('debug.variableInspect.resetConversationVar', { ns: 'workflow' })}>
|
||||
<ActionButton onClick={handleClear}>
|
||||
<RiArrowGoBackLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<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>
|
||||
)}
|
||||
{currentVar.value_type !== 'secret' && (
|
||||
@ -260,7 +278,7 @@ const Right: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton className="shrink-0" onClick={onClose} aria-label="Close">
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
<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">
|
||||
@ -310,5 +328,3 @@ const Right: FC<Props> = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Right
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
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
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { 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
|
||||
}
|
||||
|
||||
const TabHeader: FC<TabHeaderProps> = ({
|
||||
export default function TabHeader({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children,
|
||||
}) => {
|
||||
}: TabHeaderProps) {
|
||||
const { t } = useTranslation('workflow')
|
||||
const sandboxEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
|
||||
|
||||
@ -46,9 +46,7 @@ const TabHeader: FC<TabHeaderProps> = ({
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto">{children}</div>
|
||||
<div className="ml-auto flex justify-end">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabHeader
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
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 from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/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'
|
||||
@ -13,7 +11,7 @@ import useCurrentVars from '../hooks/use-inspect-vars-crud'
|
||||
import { useNodesReadOnly } from '../hooks/use-workflow'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const VariableInspectTrigger: FC = () => {
|
||||
export default function VariableInspectTrigger() {
|
||||
const { t } = useTranslation()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
@ -104,19 +102,22 @@ const VariableInspectTrigger: FC = () => {
|
||||
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)}
|
||||
>
|
||||
<RiLoader2Line className="h-4 w-4 animate-spin" />
|
||||
<span className="i-ri-loader-2-line h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
<span className="text-text-accent">{t('debug.variableInspect.trigger.running', { ns: 'workflow' })}</span>
|
||||
</div>
|
||||
{isPreviewRunning && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
@ -124,5 +125,3 @@ const VariableInspectTrigger: FC = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariableInspectTrigger
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
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'
|
||||
|
||||
@ -20,3 +24,19 @@ 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']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user