This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiBracesLine, RiEyeLine } from '@remixicon/react'
import Textarea from '@/app/components/base/textarea'
import { Markdown } from '@/app/components/base/markdown'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import { SegmentedControl } from '@/app/components/base/segmented-control'
import cn from '@/utils/classnames'
import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list'
import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
import type { ParentMode } from '@/models/datasets'
import { ChunkingMode } from '@/models/datasets'
import { PreviewType, ViewMode } from './types'
import type { VarType } from '../types'
type DisplayContentProps = {
previewType: PreviewType
varType: VarType
schemaType?: string
mdString?: string
jsonString?: string
readonly: boolean
handleTextChange?: (value: string) => void
handleEditorChange?: (value: string) => void
className?: string
}
const DisplayContent = (props: DisplayContentProps) => {
const { previewType, varType, schemaType, mdString, jsonString, readonly, handleTextChange, handleEditorChange, className } = props
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Code)
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const chunkType = useMemo(() => {
if (previewType !== PreviewType.Chunks || !schemaType)
return undefined
if (schemaType === 'general_structure')
return ChunkingMode.text
if (schemaType === 'parent_child_structure')
return ChunkingMode.parentChild
if (schemaType === 'qa_structure')
return ChunkingMode.qa
}, [previewType, schemaType])
const parentMode = useMemo(() => {
if (previewType !== PreviewType.Chunks || !schemaType || !jsonString)
return undefined
if (schemaType === 'parent_child_structure')
return JSON.parse(jsonString!)?.parent_mode as ParentMode
return undefined
}, [previewType, schemaType, jsonString])
return (
<div className={cn('flex h-full flex-col rounded-[10px] bg-components-input-bg-normal', isFocused && 'bg-components-input-bg-active outline outline-1 outline-components-input-border-active', className)}>
<div className='flex shrink-0 items-center justify-end p-1'>
{previewType === PreviewType.Markdown && (
<div className='system-xs-semibold-uppercase flex grow items-center px-2 py-0.5 text-text-secondary'>
{previewType.toUpperCase()}
</div>
)}
{previewType === PreviewType.Chunks && (
<div className='system-xs-semibold-uppercase flex grow items-center px-2 py-0.5 text-text-secondary'>
{varType.toUpperCase()}{schemaType ? `(${schemaType})` : ''}
</div>
)}
<SegmentedControl
options={[
{ value: ViewMode.Code, text: t('workflow.nodes.templateTransform.code'), Icon: RiBracesLine },
{ value: ViewMode.Preview, text: t('workflow.common.preview'), Icon: RiEyeLine },
]}
value={viewMode}
onChange={setViewMode}
size='small'
padding='with'
activeClassName='!text-text-accent-light-mode-only'
btnClassName='!pl-1.5 !pr-0.5 gap-[3px]'
className='shrink-0'
/>
</div>
<div className='flex flex-1 overflow-auto rounded-b-[10px] pl-3 pr-1'>
{viewMode === ViewMode.Code && (
previewType === PreviewType.Markdown
? <Textarea
readOnly={readonly}
disabled={readonly}
className='h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none'
value={mdString as any}
onChange={e => handleTextChange?.(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
: <SchemaEditor
readonly={readonly}
className='overflow-y-auto bg-transparent'
hideTopMenu
schema={jsonString!}
onUpdate={handleEditorChange!}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
)}
{viewMode === ViewMode.Preview && (
previewType === PreviewType.Markdown
? <Markdown className='grow overflow-auto rounded-lg px-4 py-3' content={(mdString ?? '') as string} />
: <ChunkCardList
chunkType={chunkType!}
parentMode={parentMode}
chunkInfo={JSON.parse(jsonString!) as ChunkInfo}
/>
)}
</div>
</div>
)
}
export default React.memo(DisplayContent)

View File

@@ -0,0 +1,28 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
const Empty: FC = () => {
const { t } = useTranslation()
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'>
<Variable02 className='h-5 w-5 text-text-accent' />
</div>
<div className='flex flex-col gap-1'>
<div className='system-sm-semibold text-text-secondary'>{t('workflow.debug.variableInspect.title')}</div>
<div className='system-xs-regular text-text-tertiary'>{t('workflow.debug.variableInspect.emptyTip')}</div>
<a
className='system-xs-regular cursor-pointer text-text-accent'
href='https://docs.dify.ai/en/guides/workflow/debug-and-preview/variable-inspect'
target='_blank'
rel='noopener noreferrer'>
{t('workflow.debug.variableInspect.emptyLink')}
</a>
</div>
</div>
)
}
export default Empty

View File

@@ -0,0 +1,172 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
RiDeleteBinLine,
RiFileList3Line,
RiLoader2Line,
// RiErrorWarningFill,
} from '@remixicon/react'
// import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import type { currentVarType } from './panel'
import { VarInInspectType } from '@/types/workflow'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import cn from '@/utils/classnames'
import { useToolIcon } from '../hooks'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
type Props = {
nodeData?: NodeWithVar
currentVar?: currentVarType
varType: VarInInspectType
varList: VarInInspect[]
handleSelect: (state: any) => void
handleView?: () => void
handleClear?: () => void
}
const Group = ({
nodeData,
currentVar,
varType,
varList,
handleSelect,
handleView,
handleClear,
}: Props) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(false)
const toolIcon = useToolIcon(nodeData?.nodePayload)
const isEnv = varType === VarInInspectType.environment
const isChatVar = varType === VarInInspectType.conversation
const isSystem = varType === VarInInspectType.system
const visibleVarList = isEnv ? varList : varList.filter(v => v.visible)
const handleSelectVar = (varItem: any, type?: string) => {
if (type === VarInInspectType.environment) {
handleSelect({
nodeId: VarInInspectType.environment,
title: VarInInspectType.environment,
nodeType: VarInInspectType.environment,
var: {
...varItem,
type: VarInInspectType.environment,
...(varItem.value_type === 'secret' ? { value: '******************' } : {}),
},
})
return
}
if (type === VarInInspectType.conversation) {
handleSelect({
nodeId: VarInInspectType.conversation,
nodeType: VarInInspectType.conversation,
title: VarInInspectType.conversation,
var: {
...varItem,
type: VarInInspectType.conversation,
},
})
return
}
if (type === VarInInspectType.system) {
handleSelect({
nodeId: VarInInspectType.system,
nodeType: VarInInspectType.system,
title: VarInInspectType.system,
var: {
...varItem,
type: VarInInspectType.system,
},
})
return
}
if (!nodeData) return
handleSelect({
nodeId: nodeData.nodeId,
nodeType: nodeData.nodeType,
title: nodeData.title,
var: varItem,
})
}
return (
<div className='p-0.5'>
{/* node item */}
<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' />
)}
{(!nodeData || !nodeData.isSingRunRunning) && visibleVarList.length > 0 && (
<RiArrowRightSLine className={cn('h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} onClick={() => setIsCollapsed(!isCollapsed)} />
)}
</div>
<div className='flex grow cursor-pointer items-center gap-1' onClick={() => setIsCollapsed(!isCollapsed)}>
{nodeData && (
<>
<BlockIcon
className='shrink-0'
type={nodeData.nodeType}
toolIcon={toolIcon || ''}
size='xs'
/>
<div className='system-xs-medium-uppercase truncate text-text-tertiary'>{nodeData.title}</div>
</>
)}
{!nodeData && (
<div className='system-xs-medium-uppercase truncate text-text-tertiary'>
{isEnv && t('workflow.debug.variableInspect.envNode')}
{isChatVar && t('workflow.debug.variableInspect.chatNode')}
{isSystem && t('workflow.debug.variableInspect.systemNode')}
</div>
)}
</div>
{nodeData && !nodeData.isSingRunRunning && (
<div className='hidden shrink-0 items-center group-hover:flex'>
<Tooltip popupContent={t('workflow.debug.variableInspect.view')}>
<ActionButton onClick={handleView}>
<RiFileList3Line className='h-4 w-4' />
</ActionButton>
</Tooltip>
<Tooltip popupContent={t('workflow.debug.variableInspect.clearNode')}>
<ActionButton onClick={handleClear}>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
</Tooltip>
</div>
)}
</div>
{/* var item list */}
{!isCollapsed && !nodeData?.isSingRunRunning && (
<div className='px-0.5'>
{visibleVarList.length > 0 && visibleVarList.map(varItem => (
<div
key={varItem.id}
className={cn(
'relative flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 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)}
>
<VariableIconWithColor
variableCategory={varType}
isExceptionVariable={['error_type', 'error_message'].includes(varItem.name)}
className='size-4'
/>
<div className='system-sm-medium grow truncate text-text-secondary'>{varItem.name}</div>
<div className='system-xs-regular shrink-0 text-text-tertiary'>{varItem.value_type}</div>
</div>
))}
</div>
)}
</div>
)
}
export default Group

View File

@@ -0,0 +1,61 @@
import type { FC } from 'react'
import {
useCallback,
useMemo,
} from 'react'
import { debounce } from 'lodash-es'
import { useStore } from '../store'
import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
import Panel from './panel'
import cn from '@/utils/classnames'
const VariableInspectPanel: FC = () => {
const showVariableInspectPanel = useStore(s => s.showVariableInspectPanel)
const workflowCanvasHeight = useStore(s => s.workflowCanvasHeight)
const variableInspectPanelHeight = useStore(s => s.variableInspectPanelHeight)
const setVariableInspectPanelHeight = useStore(s => s.setVariableInspectPanelHeight)
const maxHeight = useMemo(() => {
if (!workflowCanvasHeight)
return 480
return workflowCanvasHeight - 60
}, [workflowCanvasHeight])
const handleResize = useCallback((width: number, height: number) => {
localStorage.setItem('workflow-variable-inpsect-panel-height', `${height}`)
setVariableInspectPanelHeight(height)
}, [setVariableInspectPanelHeight])
const {
triggerRef,
containerRef,
} = useResizePanel({
direction: 'vertical',
triggerDirection: 'top',
minHeight: 120,
maxHeight,
onResize: debounce(handleResize),
})
if (!showVariableInspectPanel)
return null
return (
<div className={cn('relative pb-1')}>
<div
ref={triggerRef}
className='absolute -top-1 left-0 flex h-1 w-full cursor-row-resize resize-y items-center justify-center'>
<div className='h-0.5 w-10 rounded-sm bg-state-base-handle hover:w-full hover:bg-state-accent-solid active:w-full active:bg-state-accent-solid'></div>
</div>
<div
ref={containerRef}
className={cn('overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl')}
style={{ height: `${variableInspectPanelHeight}px` }}
>
<Panel />
</div>
</div>
)
}
export default VariableInspectPanel

View File

@@ -0,0 +1,33 @@
'use client'
import { RiInformation2Fill } from '@remixicon/react'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {
textHasNoExport?: boolean
downloadUrl?: string
className?: string
}
const LargeDataAlert: FC<Props> = ({
textHasNoExport,
downloadUrl,
className,
}) => {
const { t } = useTranslation()
const text = textHasNoExport ? t('workflow.debug.variableInspect.largeDataNoExport') : t('workflow.debug.variableInspect.largeData')
return (
<div className={cn('flex h-8 items-center justify-between rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-2 shadow-xs', className)}>
<div className='flex h-full w-0 grow items-center space-x-1'>
<RiInformation2Fill className='size-4 shrink-0 text-text-accent' />
<div className='system-xs-regular w-0 grow truncate text-text-primary'>{text}</div>
</div>
{downloadUrl && (
<div className='system-xs-medium-uppercase ml-1 shrink-0 cursor-pointer text-text-accent'>{t('workflow.debug.variableInspect.export')}</div>
)}
</div>
)
}
export default React.memo(LargeDataAlert)

View File

@@ -0,0 +1,111 @@
// import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '../store'
import Button from '@/app/components/base/button'
// import ActionButton from '@/app/components/base/action-button'
// import Tooltip from '@/app/components/base/tooltip'
import Group from './group'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import { useNodesInteractions } from '../hooks/use-nodes-interactions'
import type { currentVarType } from './panel'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import cn from '@/utils/classnames'
type Props = {
currentNodeVar?: currentVarType
handleVarSelect: (state: any) => void
}
const Left = ({
currentNodeVar,
handleVarSelect,
}: Props) => {
const { t } = useTranslation()
const environmentVariables = useStore(s => s.environmentVariables)
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
const {
conversationVars,
systemVars,
nodesWithInspectVars,
deleteAllInspectorVars,
deleteNodeInspectorVars,
} = useCurrentVars()
const { handleNodeSelect } = useNodesInteractions()
const showDivider = environmentVariables.length > 0 || conversationVars.length > 0 || systemVars.length > 0
const handleClearAll = () => {
deleteAllInspectorVars()
setCurrentFocusNodeId('')
}
const handleClearNode = (nodeId: string) => {
deleteNodeInspectorVars(nodeId)
setCurrentFocusNodeId('')
}
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('workflow.debug.variableInspect.title')}</div>
<Button variant='ghost' size='small' className='shrink-0' onClick={handleClearAll}>{t('workflow.debug.variableInspect.clearAll')}</Button>
</div>
{/* content */}
<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 */}
{nodesWithInspectVars.length > 0 && nodesWithInspectVars.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>
)
}
export default Left

View File

@@ -0,0 +1,219 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { type Node, useStoreApi } from 'reactflow'
import Button from '@/app/components/base/button'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useStore } from '../store'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
import type { TFunction } from 'i18next'
import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator'
import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types'
import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types'
import Tooltip from '@/app/components/base/tooltip'
import copy from 'copy-to-clipboard'
const resolveListeningDescription = (
message: string | undefined,
triggerNode: Node | undefined,
triggerType: BlockEnum,
t: TFunction,
): string => {
if (message)
return message
if (triggerType === BlockEnum.TriggerSchedule) {
const scheduleData = triggerNode?.data as ScheduleTriggerNodeType | undefined
const nextTriggerTime = scheduleData ? getNextExecutionTime(scheduleData) : ''
return t('workflow.debug.variableInspect.listening.tipSchedule', {
nextTriggerTime: nextTriggerTime || t('workflow.debug.variableInspect.listening.defaultScheduleTime'),
})
}
if (triggerType === BlockEnum.TriggerPlugin) {
const pluginName = (triggerNode?.data as { provider_name?: string; title?: string })?.provider_name
|| (triggerNode?.data as { title?: string })?.title
|| t('workflow.debug.variableInspect.listening.defaultPluginName')
return t('workflow.debug.variableInspect.listening.tipPlugin', { pluginName })
}
if (triggerType === BlockEnum.TriggerWebhook) {
const nodeName = (triggerNode?.data as { title?: string })?.title || t('workflow.debug.variableInspect.listening.defaultNodeName')
return t('workflow.debug.variableInspect.listening.tip', { nodeName })
}
const nodeDescription = (triggerNode?.data as { desc?: string })?.desc
if (nodeDescription)
return nodeDescription
return t('workflow.debug.variableInspect.listening.tipFallback')
}
const resolveMultipleListeningDescription = (
nodes: Node[],
t: TFunction,
): string => {
if (!nodes.length)
return t('workflow.debug.variableInspect.listening.tipFallback')
const titles = nodes
.map(node => (node.data as { title?: string })?.title)
.filter((title): title is string => Boolean(title))
if (titles.length)
return t('workflow.debug.variableInspect.listening.tip', { nodeName: titles.join(', ') })
return t('workflow.debug.variableInspect.listening.tipFallback')
}
export type ListeningProps = {
onStop: () => void
message?: string
}
const Listening: FC<ListeningProps> = ({
onStop,
message,
}) => {
const { t } = useTranslation()
const store = useStoreApi()
// Get the current trigger type and node ID from store
const listeningTriggerType = useStore(s => s.listeningTriggerType)
const listeningTriggerNodeId = useStore(s => s.listeningTriggerNodeId)
const listeningTriggerNodeIds = useStore(s => s.listeningTriggerNodeIds)
const listeningTriggerIsAll = useStore(s => s.listeningTriggerIsAll)
const getToolIcon = useGetToolIcon()
// Get the trigger node data to extract icon information
const { getNodes } = store.getState()
const nodes = getNodes()
const triggerNode = listeningTriggerNodeId
? nodes.find(node => node.id === listeningTriggerNodeId)
: undefined
const inferredTriggerType = (triggerNode?.data as { type?: BlockEnum })?.type
const triggerType = listeningTriggerType || inferredTriggerType || BlockEnum.TriggerWebhook
const webhookDebugUrl = triggerType === BlockEnum.TriggerWebhook
? (triggerNode?.data as WebhookTriggerNodeType | undefined)?.webhook_debug_url
: undefined
const [debugUrlCopied, setDebugUrlCopied] = useState(false)
useEffect(() => {
if (!debugUrlCopied)
return
const timer = window.setTimeout(() => {
setDebugUrlCopied(false)
}, 2000)
return () => {
window.clearTimeout(timer)
}
}, [debugUrlCopied])
let displayNodes: Node[] = []
if (listeningTriggerIsAll) {
if (listeningTriggerNodeIds.length > 0) {
displayNodes = nodes.filter(node => listeningTriggerNodeIds.includes(node.id))
}
else {
displayNodes = nodes.filter((node) => {
const nodeType = (node.data as { type?: BlockEnum })?.type
return nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerPlugin
})
}
}
else if (triggerNode) {
displayNodes = [triggerNode]
}
const iconsToRender = displayNodes.map((node) => {
const blockType = (node.data as { type?: BlockEnum })?.type || BlockEnum.TriggerWebhook
const icon = getToolIcon(node.data as any)
return {
key: node.id,
type: blockType,
toolIcon: icon,
}
})
if (iconsToRender.length === 0) {
iconsToRender.push({
key: 'default',
type: listeningTriggerIsAll ? BlockEnum.TriggerWebhook : triggerType,
toolIcon: !listeningTriggerIsAll && triggerNode ? getToolIcon(triggerNode.data as any) : undefined,
})
}
const description = listeningTriggerIsAll
? resolveMultipleListeningDescription(displayNodes, t)
: resolveListeningDescription(message, triggerNode, triggerType, t)
return (
<div className='flex h-full flex-col gap-4 rounded-xl bg-background-section p-8'>
<div className='flex flex-row flex-wrap items-center gap-3'>
{iconsToRender.map(icon => (
<BlockIcon
key={icon.key}
type={icon.type}
toolIcon={icon.toolIcon}
size="md"
className="!h-10 !w-10 !rounded-xl [&_svg]:!h-7 [&_svg]:!w-7"
/>
))}
</div>
<div className='flex flex-col gap-1'>
<div className='system-sm-semibold text-text-secondary'>{t('workflow.debug.variableInspect.listening.title')}</div>
<div className='system-xs-regular whitespace-pre-line text-text-tertiary'>{description}</div>
</div>
{webhookDebugUrl && (
<div className='flex items-center gap-2'>
<div className='system-xs-regular shrink-0 whitespace-pre-line text-text-tertiary'>
{t('workflow.nodes.triggerWebhook.debugUrlTitle')}
</div>
<Tooltip
popupContent={debugUrlCopied
? t('workflow.nodes.triggerWebhook.debugUrlCopied')
: t('workflow.nodes.triggerWebhook.debugUrlCopy')}
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('workflow.nodes.triggerWebhook.debugUrlCopy') || ''}
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>
</Tooltip>
</div>
)}
<div>
<Button
size='medium'
className='px-3'
variant='primary'
onClick={onStop}
>
<StopCircle className='mr-1 size-4' />
{t('workflow.debug.variableInspect.listening.stopButton')}
</Button>
</div>
</div>
)
}
export default Listening

View File

@@ -0,0 +1,222 @@
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
} from '@remixicon/react'
import { useStore } from '../store'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import Empty from './empty'
import Listening from './listening'
import Left from './left'
import Right from './right'
import ActionButton from '@/app/components/base/action-button'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import cn from '@/utils/classnames'
import type { NodeProps } from '../types'
import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
export type currentVarType = {
nodeId: string
nodeType: string
title: string
isValueFetched?: boolean
var: VarInInspect
nodeData: NodeProps['data']
}
const Panel: FC = () => {
const { t } = useTranslation()
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
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)
const res = {
nodeId: VarInInspectType.environment,
title: VarInInspectType.environment,
nodeType: VarInInspectType.environment,
}
if (currentVar) {
return {
...res,
var: {
...currentVar,
type: VarInInspectType.environment,
visible: true,
...(currentVar.value_type === 'secret' ? { value: '******************' } : {}),
},
}
}
return res
}
if (currentFocusNodeId === VarInInspectType.conversation) {
const currentVar = conversationVars.find(v => v.id === currentVarId)
const res = {
nodeId: VarInInspectType.conversation,
title: VarInInspectType.conversation,
nodeType: VarInInspectType.conversation,
}
if (currentVar) {
return {
...res,
var: {
...currentVar,
type: VarInInspectType.conversation,
},
}
}
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,
}
if (currentVar) {
return {
...res,
var: {
...currentVar,
type: VarInInspectType.system,
},
}
}
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,
...(currentVar ? { var: currentVar } : {}),
}
}, [currentFocusNodeId, currentVarId, environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
const isCurrentNodeVarValueFetching = useMemo(() => {
if (!currentNodeInfo) return false
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentNodeInfo.nodeId)
if (!targetNode) return false
return !targetNode.isValueFetched
}, [currentNodeInfo, nodesWithInspectVars])
const handleNodeVarSelect = useCallback((node: currentVarType) => {
setCurrentFocusNodeId(node.nodeId)
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) {
const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId)
if (targetNode && !targetNode.isValueFetched)
fetchInspectVarValue([currentFocusNodeId], schemaTypeDefinitions!)
}
}, [currentFocusNodeId, currentVarId, nodesWithInspectVars, fetchInspectVarValue, schemaTypeDefinitions, isLoading])
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('workflow.debug.variableInspect.title')}</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('workflow.debug.variableInspect.title')}</div>
<ActionButton onClick={() => setShowVariableInspectPanel(false)}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
<div className='grow p-2'>
<Empty />
</div>
</div>
)
}
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>
{/* right */}
<div className='w-0 grow'>
<Right
nodeId={currentFocusNodeId!}
isValueFetching={isCurrentNodeVarValueFetching}
currentNodeVar={currentNodeInfo as currentVarType}
handleOpenMenu={() => setShowLeftPanel(true)}
/>
</div>
</div>
)
}
export default Panel

View File

@@ -0,0 +1,308 @@
import { useTranslation } from 'react-i18next'
import {
RiArrowGoBackLine,
RiCloseLine,
RiFileDownloadFill,
RiMenuLine,
RiSparklingFill,
} from '@remixicon/react'
import { useStore } from '../store'
import { BlockEnum } from '../types'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import Empty from './empty'
import ValueContent from './value-content'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import Loading from '@/app/components/base/loading'
import type { currentVarType } from './panel'
import { VarInInspectType } from '@/types/workflow'
import cn from '@/utils/classnames'
import useNodeInfo from '../nodes/_base/hooks/use-node-info'
import { useBoolean } from 'ahooks'
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
import GetCodeGeneratorResModal from '../../app/configuration/config/code-generator/get-code-generator-res'
import { AppModeEnum } from '@/types/app'
import { useHooksStore } from '../hooks-store'
import { useCallback, useMemo } from 'react'
import { useNodesInteractions, useToolIcon } from '../hooks'
import { CodeLanguage } from '../nodes/code/types'
import useNodeCrud from '../nodes/_base/hooks/use-node-crud'
import type { GenRes } from '@/service/debug'
import { produce } from 'immer'
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '../../base/prompt-editor/plugins/update-block'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
type Props = {
nodeId: string
currentNodeVar?: currentVarType
handleOpenMenu: () => void
isValueFetching?: boolean
}
const Right = ({
nodeId,
currentNodeVar,
handleOpenMenu,
isValueFetching,
}: 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 isTruncated = currentNodeVar?.var.is_truncated
const fullContent = currentNodeVar?.var.full_content
const {
resetConversationVar,
resetToLastRunVar,
editInspectVarValue,
} = useCurrentVars()
const handleValueChange = (varId: string, value: any) => {
if (!currentNodeVar) return
editInspectVarValue(currentNodeVar.nodeId, varId, value)
}
const resetValue = () => {
if (!currentNodeVar) return
resetToLastRunVar(currentNodeVar.nodeId, currentNodeVar.var.id)
}
const handleClose = () => {
setShowVariableInspectPanel(false)
setCurrentFocusNodeId('')
}
const handleClear = () => {
if (!currentNodeVar) return
resetConversationVar(currentNodeVar.var.id)
}
const getCopyContent = () => {
const value = currentNodeVar?.var.value
if (value === null || value === undefined)
return ''
if (typeof value === 'object')
return JSON.stringify(value)
return String(value)
}
const configsMap = useHooksStore(s => s.configsMap)
const { eventEmitter } = useEventEmitterContextContext()
const { handleNodeSelect } = useNodesInteractions()
const { node } = useNodeInfo(nodeId)
const { setInputs } = useNodeCrud(nodeId, node?.data)
const blockType = node?.data?.type
const isCodeBlock = blockType === BlockEnum.Code
const canShowPromptGenerator = [BlockEnum.LLM, BlockEnum.Code].includes(blockType)
const currentPrompt = useMemo(() => {
if (!canShowPromptGenerator)
return ''
if (blockType === BlockEnum.LLM)
return node?.data?.prompt_template?.text || node?.data?.prompt_template?.[0].text
// if (blockType === BlockEnum.Agent) {
// return node?.data?.agent_parameters?.instruction?.value
// }
if (blockType === BlockEnum.Code)
return node?.data?.code
}, [canShowPromptGenerator])
const [isShowPromptGenerator, {
setTrue: doShowPromptGenerator,
setFalse: handleHidePromptGenerator,
}] = useBoolean(false)
const handleShowPromptGenerator = useCallback(() => {
handleNodeSelect(nodeId)
doShowPromptGenerator()
}, [doShowPromptGenerator, handleNodeSelect, nodeId])
const handleUpdatePrompt = useCallback((res: GenRes) => {
const newInputs = produce(node?.data, (draft: any) => {
switch (blockType) {
case BlockEnum.LLM:
if (draft?.prompt_template) {
if (Array.isArray(draft.prompt_template))
draft.prompt_template[0].text = res.modified
else
draft.prompt_template.text = res.modified
}
break
// Agent is a plugin, may has many instructions, can not locate which one to update
// case BlockEnum.Agent:
// if (draft?.agent_parameters?.instruction) {
// draft.agent_parameters.instruction.value = res.modified
// }
// break
case BlockEnum.Code:
draft.code = res.modified
break
}
})
setInputs(newInputs)
eventEmitter?.emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: `${nodeId}-chat-workflow-llm-prompt-editor`,
payload: res.modified,
} as any)
handleHidePromptGenerator()
}, [setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator])
const displaySchemaType = currentNodeVar?.var.schemaType ? (`(${currentNodeVar.var.schemaType})`) : ''
return (
<div className={cn('flex h-full flex-col')}>
{/* header */}
<div className='flex shrink-0 items-center justify-between gap-1 px-2 pt-2'>
{bottomPanelWidth < 488 && (
<ActionButton className='shrink-0' onClick={handleOpenMenu}>
<RiMenuLine className='h-4 w-4' />
</ActionButton>
)}
<div className='flex w-0 grow items-center gap-1'>
{currentNodeVar?.var && (
<>
{
[VarInInspectType.environment, VarInInspectType.conversation, VarInInspectType.system].includes(currentNodeVar.nodeType as VarInInspectType) && (
<VariableIconWithColor
variableCategory={currentNodeVar.nodeType as VarInInspectType}
className='size-4'
/>
)
}
{currentNodeVar.nodeType !== VarInInspectType.environment
&& currentNodeVar.nodeType !== VarInInspectType.conversation
&& currentNodeVar.nodeType !== VarInInspectType.system
&& (
<>
<BlockIcon
className='shrink-0'
type={currentNodeVar.nodeType as BlockEnum}
size='xs'
toolIcon={toolIcon}
/>
<div className='system-sm-regular shrink-0 text-text-secondary'>{currentNodeVar.title}</div>
<div className='system-sm-regular shrink-0 text-text-quaternary'>/</div>
</>
)}
<div title={currentNodeVar.var.name} className='system-sm-semibold truncate text-text-secondary'>{currentNodeVar.var.name}</div>
<div className='system-xs-medium ml-1 shrink-0 space-x-2 text-text-tertiary'>
<span>{`${currentNodeVar.var.value_type}${displaySchemaType}`}</span>
{isTruncated && (
<>
<span>·</span>
<span>{((fullContent?.size_bytes || 0) / 1024 / 1024).toFixed(1)}MB</span>
</>
)}
</div>
</>
)}
</div>
<div className='flex shrink-0 items-center gap-1'>
{currentNodeVar && (
<>
{canShowPromptGenerator && (
<Tooltip popupContent={t('appDebug.generate.optimizePromptTooltip')}>
<div
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>
</Tooltip>
)}
{isTruncated && (
<Tooltip popupContent={t('workflow.debug.variableInspect.exportToolTip')}>
<ActionButton>
<a
href={fullContent?.download_url}
target='_blank'
>
<RiFileDownloadFill className='size-4' />
</a>
</ActionButton>
</Tooltip>
)}
{!isTruncated && currentNodeVar.var.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('workflow.debug.variableInspect.edited')}</span>
</Badge>
)}
{!isTruncated && currentNodeVar.var.edited && currentNodeVar.var.type !== VarInInspectType.conversation && (
<Tooltip popupContent={t('workflow.debug.variableInspect.reset')}>
<ActionButton onClick={resetValue}>
<RiArrowGoBackLine className='h-4 w-4' />
</ActionButton>
</Tooltip>
)}
{!isTruncated && currentNodeVar.var.edited && currentNodeVar.var.type === VarInInspectType.conversation && (
<Tooltip popupContent={t('workflow.debug.variableInspect.resetConversationVar')}>
<ActionButton onClick={handleClear}>
<RiArrowGoBackLine className='h-4 w-4' />
</ActionButton>
</Tooltip>
)}
{currentNodeVar.var.value_type !== 'secret' && (
<CopyFeedback content={getCopyContent()} />
)}
</>
)}
<ActionButton onClick={handleClose}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</div>
{/* content */}
<div className='grow p-2'>
{!currentNodeVar?.var && <Empty />}
{isValueFetching && (
<div className='flex h-full items-center justify-center'>
<Loading />
</div>
)}
{currentNodeVar?.var && !isValueFetching && (
<ValueContent
key={`${currentNodeVar.nodeId}-${currentNodeVar.var.id}`}
currentVar={currentNodeVar.var}
handleValueChange={handleValueChange}
isTruncated={!!isTruncated}
/>
)}
</div>
{isShowPromptGenerator && (
isCodeBlock
? <GetCodeGeneratorResModal
isShow
mode={AppModeEnum.CHAT}
onClose={handleHidePromptGenerator}
flowId={configsMap?.flowId || ''}
nodeId={nodeId}
currentCode={currentPrompt}
codeLanguages={node?.data?.code_languages || CodeLanguage.python3}
onFinished={handleUpdatePrompt}
/>
: <GetAutomaticResModal
mode={AppModeEnum.CHAT}
isShow
onClose={handleHidePromptGenerator}
onFinished={handleUpdatePrompt}
flowId={configsMap?.flowId || ''}
nodeId={nodeId}
currentPrompt={currentPrompt}
/>
)}
</div>
)
}
export default Right

View File

@@ -0,0 +1,135 @@
import type { FC } from 'react'
import { useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useTranslation } from 'react-i18next'
import { RiLoader2Line, RiStopCircleFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { useStore } from '../store'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import cn from '@/utils/classnames'
import { useNodesReadOnly } from '../hooks/use-workflow'
const VariableInspectTrigger: FC = () => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const showVariableInspectPanel = useStore(s => s.showVariableInspectPanel)
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
const environmentVariables = useStore(s => s.environmentVariables)
const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId)
const {
conversationVars,
systemVars,
nodesWithInspectVars,
deleteAllInspectorVars,
} = useCurrentVars()
const currentVars = useMemo(() => {
const allVars = [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars]
return allVars
}, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars])
const {
nodesReadOnly,
getNodesReadOnly,
} = useNodesReadOnly()
const workflowRunningData = useStore(s => s.workflowRunningData)
const nodes = useNodes<CommonNodeType>()
const isStepRunning = useMemo(() => nodes.some(node => node.data._singleRunningStatus === NodeRunningStatus.Running), [nodes])
const isPreviewRunning = useMemo(() => {
if (!workflowRunningData)
return false
return workflowRunningData.result.status === WorkflowRunningStatus.Running
}, [workflowRunningData])
const isRunning = useMemo(() => isPreviewRunning || isStepRunning, [isPreviewRunning, isStepRunning])
const handleStop = () => {
eventEmitter?.emit({
type: EVENT_WORKFLOW_STOP,
} as any)
}
const handleClearAll = () => {
deleteAllInspectorVars()
setCurrentFocusNodeId('')
}
if (showVariableInspectPanel)
return null
return (
<div className={cn('flex items-center gap-1')}>
{!isRunning && !currentVars.length && (
<div
className={cn('system-2xs-semibold-uppercase flex h-5 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 text-text-tertiary shadow-lg backdrop-blur-sm hover:bg-background-default-hover',
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => {
if (getNodesReadOnly())
return
setShowVariableInspectPanel(true)
}}
>
{t('workflow.debug.variableInspect.trigger.normal')}
</div>
)}
{!isRunning && currentVars.length > 0 && (
<>
<div
className={cn('system-xs-medium 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 hover:bg-components-actionbar-bg-accent',
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => {
if (getNodesReadOnly())
return
setShowVariableInspectPanel(true)
}}
>
{t('workflow.debug.variableInspect.trigger.cached')}
</div>
<div
className={cn('system-xs-medium flex h-6 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 text-text-tertiary shadow-lg backdrop-blur-sm hover:bg-components-actionbar-bg-accent hover:text-text-accent',
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => {
if (getNodesReadOnly())
return
handleClearAll()
}}
>
{t('workflow.debug.variableInspect.trigger.clear')}
</div>
</>
)}
{isRunning && (
<>
<div
className='system-xs-medium 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 hover:bg-components-actionbar-bg-accent'
onClick={() => setShowVariableInspectPanel(true)}
>
<RiLoader2Line className='h-4 w-4 animate-spin' />
<span className='text-text-accent'>{t('workflow.debug.variableInspect.trigger.running')}</span>
</div>
{isPreviewRunning && (
<Tooltip
popupContent={t('workflow.debug.variableInspect.trigger.stop')}
>
<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>
)}
</>
)}
</div>
)
}
export default VariableInspectTrigger

View File

@@ -0,0 +1,13 @@
export const EVENT_WORKFLOW_STOP = 'WORKFLOW_STOP'
export const CHUNK_SCHEMA_TYPES = ['general_structure', 'parent_child_structure', 'qa_structure']
export enum ViewMode {
Code = 'code',
Preview = 'preview',
}
export enum PreviewType {
Markdown = 'markdown',
Chunks = 'chunks',
}

View File

@@ -0,0 +1,33 @@
import { z } from 'zod'
const arrayStringSchemaParttern = z.array(z.string())
const arrayNumberSchemaParttern = z.array(z.number())
// # jsonSchema from https://zod.dev/?id=json-type
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
type Literal = z.infer<typeof literalSchema>
type Json = Literal | { [key: string]: Json } | Json[]
const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]))
const arrayJsonSchema: z.ZodType<Json[]> = z.lazy(() => z.array(jsonSchema))
export const validateJSONSchema = (schema: any, type: string) => {
if (type === 'array[string]') {
const result = arrayStringSchemaParttern.safeParse(schema)
return result
}
else if (type === 'array[number]') {
const result = arrayNumberSchemaParttern.safeParse(schema)
return result
}
else if (type === 'object') {
const result = jsonSchema.safeParse(schema)
return result
}
else if (type === 'array[object]') {
const result = arrayJsonSchema.safeParse(schema)
return result
}
else {
return { success: true } as any
}
}

View File

@@ -0,0 +1,306 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useDebounceFn } from 'ahooks'
import Textarea from '@/app/components/base/textarea'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import {
checkJsonSchemaDepth,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
} from '@/app/components/workflow/nodes/llm/utils'
import {
validateJSONSchema,
} from '@/app/components/workflow/variable-inspect/utils'
import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { TransferMethod } from '@/types/app'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import cn from '@/utils/classnames'
import LargeDataAlert from './large-data-alert'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import { useStore } from '@/app/components/workflow/store'
import { PreviewMode } from '../../base/features/types'
import DisplayContent from './display-content'
import { CHUNK_SCHEMA_TYPES, PreviewType } from './types'
type Props = {
currentVar: VarInInspect
handleValueChange: (varId: string, value: any) => void
isTruncated: boolean
}
const ValueContent = ({
currentVar,
handleValueChange,
isTruncated,
}: Props) => {
const contentContainerRef = useRef<HTMLDivElement>(null)
const errorMessageRef = useRef<HTMLDivElement>(null)
const [editorHeight, setEditorHeight] = useState(0)
const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number'
const showBoolEditor = typeof currentVar.value === 'boolean'
const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor = !isSysFiles && (currentVar.value_type === 'object' || currentVar.value_type === 'array[string]' || currentVar.value_type === 'array[number]' || currentVar.value_type === 'array[object]' || currentVar.value_type === 'array[any]')
const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === 'array[any]'
const fileUploadConfig = useStore(s => s.fileUploadConfig)
const hasChunks = useMemo(() => {
if (!currentVar.schemaType)
return false
return CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
}, [currentVar.schemaType])
const formatFileValue = (value: VarInInspect) => {
if (value.value_type === 'file')
return value.value ? getProcessedFilesFromResponse([value.value]) : []
if (value.value_type === 'array[file]' || (value.type === VarInInspectType.system && currentVar.name === 'files'))
return value.value && value.value.length > 0 ? getProcessedFilesFromResponse(value.value) : []
return []
}
const [value, setValue] = useState<any>()
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const [fileValue, setFileValue] = useState<any>(() => formatFileValue(currentVar))
const { run: debounceValueChange } = useDebounceFn(handleValueChange, { wait: 500 })
// update default value when id changed
useEffect(() => {
if (showTextEditor) {
if (currentVar.value_type === 'number')
return setValue(JSON.stringify(currentVar.value))
if (!currentVar.value)
return setValue('')
setValue(currentVar.value)
}
if (showJSONEditor)
setJson(currentVar.value != null ? JSON.stringify(currentVar.value, null, 2) : '')
if (showFileEditor)
setFileValue(formatFileValue(currentVar))
}, [currentVar.id, currentVar.value])
const handleTextChange = (value: string) => {
if (isTruncated)
return
if (currentVar.value_type === 'string')
setValue(value)
if (currentVar.value_type === 'number') {
if (/^-?\d+(\.)?(\d+)?$/.test(value))
setValue(Number.parseFloat(value))
}
const newValue = currentVar.value_type === 'number' ? Number.parseFloat(value) : value
debounceValueChange(currentVar.id, newValue)
}
const jsonValueValidate = (value: string, type: string) => {
try {
const newJSONSchema = JSON.parse(value)
setParseError(null)
const result = validateJSONSchema(newJSONSchema, type)
if (!result.success) {
setValidationError(result.error.message)
return false
}
if (type === 'object' || type === 'array[object]') {
const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return false
}
const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return false
}
}
setValidationError('')
return true
}
catch (error) {
setValidationError('')
if (error instanceof Error) {
setParseError(error)
return false
}
else {
setParseError(new Error('Invalid JSON'))
return false
}
}
}
const handleEditorChange = (value: string) => {
if (isTruncated)
return
setJson(value)
if (jsonValueValidate(value, currentVar.value_type)) {
const parsed = JSON.parse(value)
debounceValueChange(currentVar.id, parsed)
}
}
const fileValueValidate = (fileList: any[]) => fileList.every(file => file.upload_file_id)
const handleFileChange = (value: any[]) => {
setFileValue(value)
// check every file upload progress
// invoke update api after every file uploaded
if (!fileValueValidate(value))
return
if (currentVar.value_type === 'file')
debounceValueChange(currentVar.id, value[0])
if (currentVar.value_type === 'array[file]' || isSysFiles)
debounceValueChange(currentVar.id, value)
}
// get editor height
useEffect(() => {
if (contentContainerRef.current && errorMessageRef.current) {
const errorMessageObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]
const height = (contentContainerRef.current as any).clientHeight - inlineSize
setEditorHeight(height)
}
})
errorMessageObserver.observe(errorMessageRef.current)
return () => {
errorMessageObserver.disconnect()
}
}
}, [setEditorHeight])
return (
<div
ref={contentContainerRef}
className='flex h-full flex-col'
>
<div className={cn('relative grow')} style={{ height: `${editorHeight}px` }}>
{showTextEditor && (
<>
{isTruncated && <LargeDataAlert className='absolute left-3 right-3 top-1' />}
{
currentVar.value_type === 'string' ? (
<DisplayContent
previewType={PreviewType.Markdown}
varType={currentVar.value_type}
mdString={value as any}
readonly={textEditorDisabled}
handleTextChange={handleTextChange}
className={cn(isTruncated && 'pt-[36px]')}
/>
) : (
<Textarea
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={value as any}
onChange={e => handleTextChange(e.target.value)}
/>
)
}
</>
)}
{showBoolEditor && (
<div className='w-[295px]'>
<BoolValue
value={currentVar.value as boolean}
onChange={(newValue) => {
setValue(newValue)
debounceValueChange(currentVar.id, newValue)
}}
/>
</div>
)}
{
showBoolArrayEditor && (
<div className='w-[295px] space-y-1'>
{currentVar.value.map((v: boolean, i: number) => (
<BoolValue
key={i}
value={v}
onChange={(newValue) => {
const newArray = [...(currentVar.value as boolean[])]
newArray[i] = newValue
setValue(newArray)
debounceValueChange(currentVar.id, newArray)
}}
/>
))}
</div>
)
}
{showJSONEditor && (
hasChunks
? (
<DisplayContent
previewType={PreviewType.Chunks}
varType={currentVar.value_type}
schemaType={currentVar.schemaType ?? ''}
jsonString={json ?? '{}'}
readonly={JSONEditorDisabled}
handleEditorChange={handleEditorChange}
/>
)
: (
<SchemaEditor
readonly={JSONEditorDisabled || isTruncated}
className='overflow-y-auto'
hideTopMenu
schema={json}
onUpdate={handleEditorChange}
isTruncated={isTruncated}
/>
)
)}
{showFileEditor && (
<div className='max-w-[460px]'>
<FileUploaderInAttachmentWrapper
value={fileValue}
onChange={files => handleFileChange(getProcessedFiles(files))}
fileConfig={{
allowed_file_types: [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
],
allowed_file_extensions: [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
fileUploadConfig,
preview_config: {
mode: PreviewMode.NewPage,
file_type_list: ['application/pdf'],
},
}}
isDisabled={textEditorDisabled}
/>
</div>
)}
</div>
<div ref={errorMessageRef} className='shrink-0'>
{parseError && <ErrorMessage className='mt-1' message={parseError.message} />}
{validationError && <ErrorMessage className='mt-1' message={validationError} />}
</div>
</div >
)
}
export default React.memo(ValueContent)