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:
yyh
2026-01-27 15:05:11 +08:00
parent a29f569e08
commit d098e72c13
11 changed files with 503 additions and 258 deletions

View File

@ -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}
/>
))}

View 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

View File

@ -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>

View File

@ -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 && (

View File

@ -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>
)

View File

@ -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 */}

View File

@ -11,3 +11,8 @@ export enum PreviewType {
Markdown = 'markdown',
Chunks = 'chunks',
}
export enum InspectTab {
Variables = 'variables',
Artifacts = 'artifacts',
}

View 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

View File

@ -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

View File

@ -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",

View File

@ -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": "清除",