mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 08:28:03 +08:00
feat(variable-inspect): add Artifacts tab with sandbox file tree browser
Refactor the variable inspect panel into a tabbed layout with Variables and Artifacts tabs. Extract variable logic into VariablesTab, add new ArtifactsTab with sandbox file tree selection and preview pane, and improve accessibility across tree nodes and interactive elements.
This commit is contained in:
@ -16,6 +16,8 @@ const INDENT_SIZE = 20
|
||||
type ArtifactsTreeProps = {
|
||||
data: SandboxFileTreeNode[] | undefined
|
||||
onDownload: (node: SandboxFileTreeNode) => void
|
||||
onSelect?: (node: SandboxFileTreeNode) => void
|
||||
selectedPath?: string
|
||||
isDownloading?: boolean
|
||||
}
|
||||
|
||||
@ -23,6 +25,8 @@ type ArtifactsTreeNodeProps = {
|
||||
node: SandboxFileTreeNode
|
||||
depth: number
|
||||
onDownload: (node: SandboxFileTreeNode) => void
|
||||
onSelect?: (node: SandboxFileTreeNode) => void
|
||||
selectedPath?: string
|
||||
isDownloading?: boolean
|
||||
}
|
||||
|
||||
@ -30,16 +34,24 @@ const ArtifactsTreeNode: FC<ArtifactsTreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
onDownload,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
isDownloading,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const isFolder = node.node_type === 'folder'
|
||||
const hasChildren = isFolder && node.children.length > 0
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isFolder)
|
||||
const isSelected = !isFolder && selectedPath === node.path
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isFolder) {
|
||||
setIsExpanded(prev => !prev)
|
||||
}, [isFolder])
|
||||
}
|
||||
else {
|
||||
onSelect?.(node)
|
||||
}
|
||||
}, [isFolder, node, onSelect])
|
||||
|
||||
const handleDownload = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -51,21 +63,20 @@ const ArtifactsTreeNode: FC<ArtifactsTreeNodeProps> = ({
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role={isFolder ? 'button' : undefined}
|
||||
tabIndex={isFolder ? 0 : undefined}
|
||||
aria-label={isFolder ? `${node.name} folder` : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={isFolder ? `${node.name} folder` : node.name}
|
||||
aria-expanded={isFolder ? isExpanded : undefined}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={isFolder
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handleToggle()
|
||||
}
|
||||
: undefined}
|
||||
aria-selected={isSelected}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handleClick()
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex h-6 items-center rounded-md px-2',
|
||||
isFolder && 'cursor-pointer hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-active',
|
||||
!isFolder && 'hover:bg-state-base-hover',
|
||||
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
|
||||
'hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-active',
|
||||
isSelected && 'bg-state-base-hover',
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + depth * INDENT_SIZE}px` }}
|
||||
>
|
||||
@ -110,6 +121,8 @@ const ArtifactsTreeNode: FC<ArtifactsTreeNodeProps> = ({
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onDownload={onDownload}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
isDownloading={isDownloading}
|
||||
/>
|
||||
))}
|
||||
@ -122,6 +135,8 @@ const ArtifactsTreeNode: FC<ArtifactsTreeNodeProps> = ({
|
||||
const ArtifactsTree: FC<ArtifactsTreeProps> = ({
|
||||
data,
|
||||
onDownload,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
isDownloading,
|
||||
}) => {
|
||||
if (!data || data.length === 0)
|
||||
@ -135,6 +150,8 @@ const ArtifactsTree: FC<ArtifactsTreeProps> = ({
|
||||
node={node}
|
||||
depth={0}
|
||||
onDownload={onDownload}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
isDownloading={isDownloading}
|
||||
/>
|
||||
))}
|
||||
|
||||
192
web/app/components/workflow/variable-inspect/artifacts-tab.tsx
Normal file
192
web/app/components/workflow/variable-inspect/artifacts-tab.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
|
||||
import {
|
||||
RiDownloadLine,
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ArtifactsTree from '@/app/components/workflow/skill/file-tree/artifacts-tree'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useStore } from '../store'
|
||||
|
||||
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]}`
|
||||
}
|
||||
|
||||
type ArtifactsPreviewPaneProps = {
|
||||
file: SandboxFileTreeNode | null
|
||||
onDownload: (node: SandboxFileTreeNode) => void
|
||||
isDownloading: boolean
|
||||
onOpenMenu: () => void
|
||||
}
|
||||
|
||||
const ArtifactsPreviewPane = memo<ArtifactsPreviewPaneProps>(({
|
||||
file,
|
||||
onDownload,
|
||||
isDownloading,
|
||||
onOpenMenu,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('debug.variableInspect.tabArtifacts.selectFile')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pathParts = file.path.split('/')
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-between gap-1 px-2 pt-2">
|
||||
{bottomPanelWidth < 488 && (
|
||||
<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">
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
{pathParts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="system-sm-regular text-text-quaternary">/</span>}
|
||||
<span className={cn(
|
||||
'system-sm-semibold truncate',
|
||||
i === pathParts.length - 1 ? 'text-text-secondary' : 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="system-xs-medium shrink-0 text-text-tertiary">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<CopyFeedback content={file.path} />
|
||||
<ActionButton
|
||||
onClick={() => onDownload(file)}
|
||||
disabled={isDownloading}
|
||||
aria-label={`Download ${file.name}`}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-auto p-2">
|
||||
<div className="flex h-full items-center justify-center rounded-xl bg-background-section">
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('debug.variableInspect.tabArtifacts.previewNotAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ArtifactsTab: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const sandboxId = userProfile?.id
|
||||
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
|
||||
|
||||
const { data: treeData, hasFiles, isLoading } = useSandboxFilesTree(sandboxId, {
|
||||
enabled: !!sandboxId,
|
||||
})
|
||||
const downloadMutation = useDownloadSandboxFile(sandboxId)
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<SandboxFileTreeNode | null>(null)
|
||||
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
||||
|
||||
const handleFileSelect = useCallback((node: SandboxFileTreeNode) => {
|
||||
if (node.node_type === 'file')
|
||||
setSelectedFile(node)
|
||||
}, [])
|
||||
|
||||
const { mutateAsync: downloadFile } = downloadMutation
|
||||
const handleDownload = useCallback(async (node: SandboxFileTreeNode) => {
|
||||
try {
|
||||
const ticket = await downloadFile(node.path)
|
||||
window.open(ticket.download_url, '_blank')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Download failed:', error)
|
||||
}
|
||||
}, [downloadFile])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasFiles) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<div className="rounded-lg bg-background-section p-3">
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('skillSidebar.artifacts.emptyState', { ns: 'workflow' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex h-full')}>
|
||||
{bottomPanelWidth < 488 && showLeftPanel && (
|
||||
<div role="presentation" className="absolute left-0 top-0 h-full w-full" onClick={() => setShowLeftPanel(false)} />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-60 shrink-0 border-r border-divider-burn',
|
||||
bottomPanelWidth < 488
|
||||
? 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'
|
||||
: 'block',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="grow overflow-y-auto py-1">
|
||||
<ArtifactsTree
|
||||
data={treeData}
|
||||
onDownload={handleDownload}
|
||||
onSelect={handleFileSelect}
|
||||
selectedPath={selectedFile?.path}
|
||||
isDownloading={downloadMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-0 grow">
|
||||
<ArtifactsPreviewPane
|
||||
file={selectedFile}
|
||||
onDownload={handleDownload}
|
||||
isDownloading={downloadMutation.isPending}
|
||||
onOpenMenu={() => setShowLeftPanel(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtifactsTab
|
||||
@ -1,4 +1,4 @@
|
||||
import type { currentVarType } from './panel'
|
||||
import type { currentVarType } from './variables-tab'
|
||||
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
@ -107,7 +107,7 @@ const Group = ({
|
||||
<RiLoader2Line className="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')} onClick={() => setIsCollapsed(!isCollapsed)} />
|
||||
<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)}>
|
||||
@ -152,10 +152,11 @@ const Group = ({
|
||||
const isAgentAliasVar = typeof varItem.name === 'string' && varItem.name.startsWith('@')
|
||||
const displayName = isAgentAliasVar ? varItem.name.slice(1) : varItem.name
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={varItem.id}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 hover:bg-state-base-hover',
|
||||
'relative flex w-full cursor-pointer items-center gap-1 rounded-md px-3 py-1 text-left hover:bg-state-base-hover',
|
||||
varItem.id === currentVar?.var?.id && 'bg-state-base-hover-alt hover:bg-state-base-hover-alt',
|
||||
)}
|
||||
onClick={() => handleSelectVar(varItem, varType)}
|
||||
@ -171,7 +172,7 @@ const Group = ({
|
||||
)}
|
||||
<div className="system-sm-medium grow truncate text-text-secondary">{displayName}</div>
|
||||
<div className="system-xs-regular shrink-0 text-text-tertiary">{formatVarTypeLabel(varItem.value_type)}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import type { currentVarType } from './panel'
|
||||
import type { currentVarType } from './variables-tab'
|
||||
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
// import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { VarInInspectType } from '@/types/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import useCurrentVars from '../hooks/use-inspect-vars-crud'
|
||||
@ -22,8 +19,6 @@ const Left = ({
|
||||
currentNodeVar,
|
||||
handleVarSelect,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const environmentVariables = useStore(s => s.environmentVariables)
|
||||
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
|
||||
|
||||
@ -31,7 +26,6 @@ const Left = ({
|
||||
conversationVars,
|
||||
systemVars,
|
||||
nodesWithInspectVars,
|
||||
deleteAllInspectorVars,
|
||||
deleteNodeInspectorVars,
|
||||
} = useCurrentVars()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
@ -40,11 +34,6 @@ const Left = ({
|
||||
|
||||
const showDivider = environmentVariables.length > 0 || conversationVars.length > 0 || systemVars.length > 0
|
||||
|
||||
const handleClearAll = () => {
|
||||
deleteAllInspectorVars()
|
||||
setCurrentFocusNodeId('')
|
||||
}
|
||||
|
||||
const handleClearNode = (nodeId: string) => {
|
||||
deleteNodeInspectorVars(nodeId)
|
||||
setCurrentFocusNodeId('')
|
||||
@ -52,12 +41,6 @@ const Left = ({
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col')}>
|
||||
{/* header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-1 pl-4 pr-1 pt-2">
|
||||
<div className="system-sm-semibold-uppercase truncate text-text-primary">{t('debug.variableInspect.title', { ns: 'workflow' })}</div>
|
||||
<Button variant="ghost" size="small" className="shrink-0" onClick={handleClearAll}>{t('debug.variableInspect.clearAll', { ns: 'workflow' })}</Button>
|
||||
</div>
|
||||
{/* content */}
|
||||
<div className="grow overflow-y-auto py-1">
|
||||
{/* group ENV */}
|
||||
{environmentVariables.length > 0 && (
|
||||
|
||||
@ -1,217 +1,83 @@
|
||||
import type { FC } from 'react'
|
||||
import type { NodeProps } from '../types'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import {
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { VarInInspectType } from '@/types/workflow'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { cn } from '@/utils/classnames'
|
||||
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 Left from './left'
|
||||
import Listening from './listening'
|
||||
import Right from './right'
|
||||
import { toEnvVarInInspect } from './utils'
|
||||
import { InspectTab } from './types'
|
||||
import VariablesTab from './variables-tab'
|
||||
|
||||
export type currentVarType = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
title: string
|
||||
isValueFetched?: boolean
|
||||
var?: VarInInspect
|
||||
nodeData?: NodeProps['data']
|
||||
}
|
||||
const ArtifactsTab = lazy(() => import('./artifacts-tab'))
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{ value: InspectTab.Variables, labelKey: 'debug.variableInspect.tab.variables' },
|
||||
{ value: InspectTab.Artifacts, labelKey: 'debug.variableInspect.tab.artifacts' },
|
||||
] as const
|
||||
|
||||
const Panel: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
|
||||
const { t } = useTranslation('workflow')
|
||||
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
|
||||
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
||||
const isListening = useStore(s => s.isListening)
|
||||
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
|
||||
const [activeTab, setActiveTab] = useState<InspectTab>(InspectTab.Variables)
|
||||
|
||||
const environmentVariables = useStore(s => s.environmentVariables)
|
||||
const currentFocusNodeId = useStore(s => s.currentFocusNodeId)
|
||||
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
|
||||
const [currentVarId, setCurrentVarId] = useState('')
|
||||
const { conversationVars, systemVars, nodesWithInspectVars, deleteAllInspectorVars } = useCurrentVars()
|
||||
|
||||
const {
|
||||
conversationVars,
|
||||
systemVars,
|
||||
nodesWithInspectVars,
|
||||
fetchInspectVarValue,
|
||||
} = useCurrentVars()
|
||||
|
||||
const isEmpty = useMemo(() => {
|
||||
const allVars = [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars]
|
||||
return allVars.length === 0
|
||||
const isVariablesEmpty = useMemo(() => {
|
||||
return [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars].length === 0
|
||||
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
|
||||
|
||||
const currentNodeInfo = useMemo(() => {
|
||||
if (!currentFocusNodeId)
|
||||
return
|
||||
if (currentFocusNodeId === VarInInspectType.environment) {
|
||||
const currentVar = environmentVariables.find(v => v.id === currentVarId)
|
||||
return {
|
||||
nodeId: VarInInspectType.environment,
|
||||
title: VarInInspectType.environment,
|
||||
nodeType: VarInInspectType.environment,
|
||||
var: currentVar ? toEnvVarInInspect(currentVar) : undefined,
|
||||
}
|
||||
}
|
||||
if (currentFocusNodeId === VarInInspectType.conversation) {
|
||||
const currentVar = conversationVars.find(v => v.id === currentVarId)
|
||||
const res = {
|
||||
nodeId: VarInInspectType.conversation,
|
||||
title: VarInInspectType.conversation,
|
||||
nodeType: VarInInspectType.conversation,
|
||||
var: currentVar
|
||||
? {
|
||||
...currentVar,
|
||||
type: VarInInspectType.conversation,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
return res
|
||||
}
|
||||
if (currentFocusNodeId === VarInInspectType.system) {
|
||||
const currentVar = systemVars.find(v => v.id === currentVarId)
|
||||
const res = {
|
||||
nodeId: VarInInspectType.system,
|
||||
title: VarInInspectType.system,
|
||||
nodeType: VarInInspectType.system,
|
||||
var: currentVar
|
||||
? {
|
||||
...currentVar,
|
||||
type: VarInInspectType.system,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
return res
|
||||
}
|
||||
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId)
|
||||
if (!targetNode)
|
||||
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,
|
||||
}
|
||||
}, [currentFocusNodeId, currentVarId, environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
|
||||
const handleClear = useCallback(() => {
|
||||
deleteAllInspectorVars()
|
||||
setCurrentFocusNodeId('')
|
||||
}, [deleteAllInspectorVars, setCurrentFocusNodeId])
|
||||
|
||||
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(() => {
|
||||
if (!fetchNodeId)
|
||||
return false
|
||||
const targetNode = nodesWithInspectVars.find(node => node.nodeId === fetchNodeId)
|
||||
if (!targetNode)
|
||||
return false
|
||||
return !targetNode.isValueFetched
|
||||
}, [fetchNodeId, nodesWithInspectVars])
|
||||
|
||||
const handleNodeVarSelect = useCallback((node: currentVarType) => {
|
||||
setCurrentFocusNodeId(node.nodeId)
|
||||
if (node.var)
|
||||
setCurrentVarId(node.var.id)
|
||||
}, [setCurrentFocusNodeId, setCurrentVarId])
|
||||
|
||||
const { isLoading, schemaTypeDefinitions } = useMatchSchemaType()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const handleStopListening = useCallback(() => {
|
||||
eventEmitter?.emit({ type: EVENT_WORKFLOW_STOP } as any)
|
||||
}, [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 (isListening) {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col')}>
|
||||
<div className="flex shrink-0 items-center justify-between pl-4 pr-2 pt-2">
|
||||
<div className="system-sm-semibold-uppercase text-text-primary">{t('debug.variableInspect.title', { ns: 'workflow' })}</div>
|
||||
<ActionButton onClick={() => setShowVariableInspectPanel(false)}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="grow p-2">
|
||||
<Listening
|
||||
onStop={handleStopListening}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col')}>
|
||||
<div className="flex shrink-0 items-center justify-between pl-4 pr-2 pt-2">
|
||||
<div className="system-sm-semibold-uppercase text-text-primary">{t('debug.variableInspect.title', { ns: 'workflow' })}</div>
|
||||
<ActionButton onClick={() => setShowVariableInspectPanel(false)}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="grow p-2">
|
||||
<Empty />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const handleClose = useCallback(() => {
|
||||
setShowVariableInspectPanel(false)
|
||||
}, [setShowVariableInspectPanel])
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex h-full')}>
|
||||
{/* left */}
|
||||
{bottomPanelWidth < 488 && showLeftPanel && <div className="absolute left-0 top-0 h-full w-full" onClick={() => setShowLeftPanel(false)}></div>}
|
||||
<div
|
||||
className={cn(
|
||||
'w-60 shrink-0 border-r border-divider-burn',
|
||||
bottomPanelWidth < 488
|
||||
? 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'
|
||||
: 'block',
|
||||
)}
|
||||
>
|
||||
<Left
|
||||
currentNodeVar={currentNodeInfo as currentVarType}
|
||||
handleVarSelect={handleNodeVarSelect}
|
||||
/>
|
||||
<div className={cn('flex h-full flex-col')}>
|
||||
<div className="flex shrink-0 items-center justify-between gap-1 pl-3 pr-2 pt-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{TAB_ITEMS.map(tab => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={cn(
|
||||
'system-sm-semibold rounded-md px-2 py-1 transition-colors',
|
||||
activeTab === tab.value
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-tertiary hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
{activeTab === InspectTab.Variables && !isVariablesEmpty && (
|
||||
<Button variant="ghost" size="small" onClick={handleClear}>
|
||||
{t('debug.variableInspect.clearAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ActionButton onClick={handleClose}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
{/* right */}
|
||||
<div className="w-0 grow">
|
||||
<Right
|
||||
nodeId={currentFocusNodeId!}
|
||||
isValueFetching={isCurrentNodeVarValueFetching}
|
||||
currentNodeVar={currentNodeInfo as currentVarType}
|
||||
handleOpenMenu={() => setShowLeftPanel(true)}
|
||||
/>
|
||||
<div className="min-h-0 flex-1">
|
||||
{activeTab === InspectTab.Variables && <VariablesTab />}
|
||||
{activeTab === InspectTab.Artifacts && (
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loading /></div>}>
|
||||
<ArtifactsTab />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { currentVarType } from './panel'
|
||||
import type { currentVarType } from './variables-tab'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import {
|
||||
RiArrowGoBackLine,
|
||||
RiCloseLine,
|
||||
RiFileDownloadFill,
|
||||
RiMenuLine,
|
||||
RiSparklingFill,
|
||||
@ -52,8 +51,6 @@ const Right = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
|
||||
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
|
||||
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
|
||||
const toolIcon = useToolIcon(currentNodeVar?.nodeData)
|
||||
const currentVar = currentNodeVar?.var
|
||||
const currentNodeType = currentNodeVar?.nodeType
|
||||
@ -82,11 +79,6 @@ const Right = ({
|
||||
resetToLastRunVar(currentNodeVar.nodeId, currentVar.id)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setShowVariableInspectPanel(false)
|
||||
setCurrentFocusNodeId('')
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (!currentNodeVar || !currentVar)
|
||||
return
|
||||
@ -179,7 +171,7 @@ const Right = ({
|
||||
{/* header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-1 px-2 pt-2">
|
||||
{bottomPanelWidth < 488 && (
|
||||
<ActionButton className="shrink-0" onClick={handleOpenMenu}>
|
||||
<ActionButton className="shrink-0" onClick={handleOpenMenu} aria-label="Open menu">
|
||||
<RiMenuLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
@ -233,23 +225,22 @@ const Right = ({
|
||||
<>
|
||||
{canShowPromptGenerator && (
|
||||
<Tooltip popupContent={t('generate.optimizePromptTooltip', { ns: 'appDebug' })}>
|
||||
<div
|
||||
<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" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isTruncated && (
|
||||
<Tooltip popupContent={t('debug.variableInspect.exportToolTip', { ns: 'workflow' })}>
|
||||
<ActionButton>
|
||||
<a
|
||||
href={fullContent?.download_url}
|
||||
target="_blank"
|
||||
>
|
||||
<RiFileDownloadFill className="size-4" />
|
||||
</a>
|
||||
<ActionButton
|
||||
onClick={() => window.open(fullContent?.download_url, '_blank')}
|
||||
aria-label={t('debug.variableInspect.exportToolTip', { ns: 'workflow' })}
|
||||
>
|
||||
<RiFileDownloadFill className="size-4" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
@ -278,9 +269,6 @@ const Right = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ActionButton onClick={handleClose}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{/* content */}
|
||||
|
||||
@ -11,3 +11,8 @@ export enum PreviewType {
|
||||
Markdown = 'markdown',
|
||||
Chunks = 'chunks',
|
||||
}
|
||||
|
||||
export enum InspectTab {
|
||||
Variables = 'variables',
|
||||
Artifacts = 'artifacts',
|
||||
}
|
||||
|
||||
190
web/app/components/workflow/variable-inspect/variables-tab.tsx
Normal file
190
web/app/components/workflow/variable-inspect/variables-tab.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import type { FC } from 'react'
|
||||
import type { NodeProps } from '../types'
|
||||
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 { cn } from '@/utils/classnames'
|
||||
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 Left from './left'
|
||||
import Listening from './listening'
|
||||
import Right from './right'
|
||||
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']
|
||||
}
|
||||
|
||||
const VariablesTab: FC = () => {
|
||||
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
|
||||
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
||||
const isListening = useStore(s => s.isListening)
|
||||
|
||||
const environmentVariables = useStore(s => s.environmentVariables)
|
||||
const currentFocusNodeId = useStore(s => s.currentFocusNodeId)
|
||||
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
|
||||
const [currentVarId, setCurrentVarId] = useState('')
|
||||
|
||||
const {
|
||||
conversationVars,
|
||||
systemVars,
|
||||
nodesWithInspectVars,
|
||||
fetchInspectVarValue,
|
||||
} = useCurrentVars()
|
||||
|
||||
const isEmpty = useMemo(() => {
|
||||
const allVars = [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars]
|
||||
return allVars.length === 0
|
||||
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
|
||||
|
||||
const currentNodeInfo = useMemo(() => {
|
||||
if (!currentFocusNodeId)
|
||||
return
|
||||
if (currentFocusNodeId === VarInInspectType.environment) {
|
||||
const currentVar = environmentVariables.find(v => v.id === currentVarId)
|
||||
return {
|
||||
nodeId: VarInInspectType.environment,
|
||||
title: VarInInspectType.environment,
|
||||
nodeType: VarInInspectType.environment,
|
||||
var: currentVar ? toEnvVarInInspect(currentVar) : undefined,
|
||||
}
|
||||
}
|
||||
if (currentFocusNodeId === VarInInspectType.conversation) {
|
||||
const currentVar = conversationVars.find(v => v.id === currentVarId)
|
||||
return {
|
||||
nodeId: VarInInspectType.conversation,
|
||||
title: VarInInspectType.conversation,
|
||||
nodeType: VarInInspectType.conversation,
|
||||
var: currentVar
|
||||
? {
|
||||
...currentVar,
|
||||
type: VarInInspectType.conversation,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
if (currentFocusNodeId === VarInInspectType.system) {
|
||||
const currentVar = systemVars.find(v => v.id === currentVarId)
|
||||
return {
|
||||
nodeId: VarInInspectType.system,
|
||||
title: VarInInspectType.system,
|
||||
nodeType: VarInInspectType.system,
|
||||
var: currentVar
|
||||
? {
|
||||
...currentVar,
|
||||
type: VarInInspectType.system,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId)
|
||||
if (!targetNode)
|
||||
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,
|
||||
}
|
||||
}, [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 isCurrentNodeVarValueFetching = useMemo(() => {
|
||||
if (!fetchNodeId)
|
||||
return false
|
||||
const targetNode = nodesWithInspectVars.find(node => node.nodeId === fetchNodeId)
|
||||
if (!targetNode)
|
||||
return false
|
||||
return !targetNode.isValueFetched
|
||||
}, [fetchNodeId, nodesWithInspectVars])
|
||||
|
||||
const handleNodeVarSelect = useCallback((node: currentVarType) => {
|
||||
setCurrentFocusNodeId(node.nodeId)
|
||||
if (node.var)
|
||||
setCurrentVarId(node.var.id)
|
||||
}, [setCurrentFocusNodeId, setCurrentVarId])
|
||||
|
||||
const { isLoading, schemaTypeDefinitions } = useMatchSchemaType()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const handleStopListening = useCallback(() => {
|
||||
// eslint-disable-next-line ts/no-explicit-any -- EventEmitter is typed as string but project-wide convention passes { type } objects
|
||||
eventEmitter?.emit({ type: EVENT_WORKFLOW_STOP } as any)
|
||||
}, [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 (isListening) {
|
||||
return (
|
||||
<div className="grow p-2">
|
||||
<Listening onStop={handleStopListening} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="h-full p-2">
|
||||
<Empty />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex h-full')}>
|
||||
{bottomPanelWidth < 488 && showLeftPanel && <div role="presentation" className="absolute left-0 top-0 h-full w-full" onClick={() => setShowLeftPanel(false)}></div>}
|
||||
<div
|
||||
className={cn(
|
||||
'w-60 shrink-0 border-r border-divider-burn',
|
||||
bottomPanelWidth < 488
|
||||
? 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'
|
||||
: 'block',
|
||||
)}
|
||||
>
|
||||
<Left
|
||||
currentNodeVar={currentNodeInfo as currentVarType}
|
||||
handleVarSelect={handleNodeVarSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-0 grow">
|
||||
<Right
|
||||
nodeId={currentFocusNodeId!}
|
||||
isValueFetching={isCurrentNodeVarValueFetching}
|
||||
currentNodeVar={currentNodeInfo as currentVarType}
|
||||
handleOpenMenu={() => setShowLeftPanel(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariablesTab
|
||||
@ -3958,11 +3958,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow/variable-inspect/panel.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/variable-inspect/right.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -290,6 +290,10 @@
|
||||
"debug.variableInspect.reset": "Reset to last run value",
|
||||
"debug.variableInspect.resetConversationVar": "Reset conversation variable to default value",
|
||||
"debug.variableInspect.systemNode": "System",
|
||||
"debug.variableInspect.tab.artifacts": "Artifacts",
|
||||
"debug.variableInspect.tab.variables": "Variables",
|
||||
"debug.variableInspect.tabArtifacts.previewNotAvailable": "Preview not available. Click download to view this file.",
|
||||
"debug.variableInspect.tabArtifacts.selectFile": "Select a file to preview",
|
||||
"debug.variableInspect.title": "Variable Inspect",
|
||||
"debug.variableInspect.trigger.cached": "View cached variables",
|
||||
"debug.variableInspect.trigger.clear": "Clear",
|
||||
|
||||
@ -288,6 +288,10 @@
|
||||
"debug.variableInspect.reset": "还原至上一次运行",
|
||||
"debug.variableInspect.resetConversationVar": "重置会话变量为默认值",
|
||||
"debug.variableInspect.systemNode": "系统变量",
|
||||
"debug.variableInspect.tab.artifacts": "产物",
|
||||
"debug.variableInspect.tab.variables": "变量",
|
||||
"debug.variableInspect.tabArtifacts.previewNotAvailable": "暂不支持预览,请点击下载查看此文件。",
|
||||
"debug.variableInspect.tabArtifacts.selectFile": "选择文件进行预览",
|
||||
"debug.variableInspect.title": "变量检查",
|
||||
"debug.variableInspect.trigger.cached": "查看缓存",
|
||||
"debug.variableInspect.trigger.clear": "清除",
|
||||
|
||||
Reference in New Issue
Block a user