feat: llm support struct output (#17994)

Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
This commit is contained in:
Joel
2025-04-18 16:53:43 +08:00
committed by GitHub
parent da9269ca97
commit 775dc47abe
82 changed files with 4190 additions and 276 deletions

View File

@@ -4,10 +4,16 @@ import Collapse from '.'
type FieldCollapseProps = {
title: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
}
const FieldCollapse = ({
title,
children,
collapsed,
onCollapse,
operations,
}: FieldCollapseProps) => {
return (
<div className='py-4'>
@@ -15,6 +21,9 @@ const FieldCollapse = ({
trigger={
<div className='system-sm-semibold-uppercase flex h-6 cursor-pointer items-center text-text-secondary'>{title}</div>
}
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>
<div className='px-4'>
{children}

View File

@@ -1,15 +1,18 @@
import { useState } from 'react'
import { RiArrowDropRightLine } from '@remixicon/react'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import cn from '@/utils/classnames'
export { default as FieldCollapse } from './field-collapse'
type CollapseProps = {
disabled?: boolean
trigger: React.JSX.Element
trigger: React.JSX.Element | ((collapseIcon: React.JSX.Element | null) => React.JSX.Element)
children: React.JSX.Element
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
hideCollapseIcon?: boolean
}
const Collapse = ({
disabled,
@@ -17,34 +20,44 @@ const Collapse = ({
children,
collapsed,
onCollapse,
operations,
hideCollapseIcon,
}: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
const collapseIcon = useMemo(() => {
if (disabled)
return null
return (
<ArrowDownRoundFill
className={cn(
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapsedMerged && 'rotate-[270deg]',
)}
/>
)
}, [collapsedMerged, disabled])
return (
<>
<div
className='flex items-center'
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)
onCollapse?.(!collapsedMerged)
}
}}
>
<div className='h-4 w-4 shrink-0'>
{
!disabled && (
<RiArrowDropRightLine
className={cn(
'h-4 w-4 text-text-tertiary',
!collapsedMerged && 'rotate-90',
)}
/>
)
}
<div className='group/collapse flex items-center'>
<div
className='ml-4 flex grow items-center'
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)
onCollapse?.(!collapsedMerged)
}
}}
>
{typeof trigger === 'function' ? trigger(collapseIcon) : trigger}
{!hideCollapseIcon && (
<div className='h-4 w-4 shrink-0'>
{collapseIcon}
</div>
)}
</div>
{trigger}
{operations}
</div>
{
!collapsedMerged && children

View File

@@ -49,20 +49,23 @@ const ErrorHandle = ({
disabled={!error_strategy}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
trigger={
<div className='flex grow items-center justify-between pr-4'>
<div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
{t('workflow.nodes.common.errorHandle.title')}
collapseIcon => (
<div className='flex grow items-center justify-between pr-4'>
<div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
{t('workflow.nodes.common.errorHandle.title')}
</div>
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} />
{collapseIcon}
</div>
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} />
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
}
)}
>
<>
{

View File

@@ -50,6 +50,7 @@ const ErrorHandleTypeSelector = ({
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}>
<Button
@@ -68,6 +69,7 @@ const ErrorHandleTypeSelector = ({
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onSelected(option.value)
setOpen(false)
}}

View File

@@ -3,20 +3,33 @@ import type { FC, ReactNode } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
import TreeIndentLine from './variable/object-child-tree-panel/tree-indent-line'
import cn from '@/utils/classnames'
type Props = {
className?: string
title?: string
children: ReactNode
operations?: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}
const OutputVars: FC<Props> = ({
title,
children,
operations,
collapsed,
onCollapse,
}) => {
const { t } = useTranslation()
return (
<FieldCollapse title={title || t('workflow.nodes.common.outputVars')}>
<FieldCollapse
title={title || t('workflow.nodes.common.outputVars')}
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>
{children}
</FieldCollapse>
)
@@ -30,6 +43,7 @@ type VarItemProps = {
type: string
description: string
}[]
isIndent?: boolean
}
export const VarItem: FC<VarItemProps> = ({
@@ -37,27 +51,33 @@ export const VarItem: FC<VarItemProps> = ({
type,
description,
subItems,
isIndent,
}) => {
return (
<div className='py-1'>
<div className='flex items-center leading-[18px]'>
<div className='code-sm-semibold text-text-secondary'>{name}</div>
<div className='system-xs-regular ml-2 capitalize text-text-tertiary'>{type}</div>
</div>
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
{description}
{subItems && (
<div className='ml-2 border-l border-divider-regular pl-2'>
{subItems.map((item, index) => (
<VarItem
key={index}
name={item.name}
type={item.type}
description={item.description}
/>
))}
<div className={cn('flex', isIndent && 'relative left-[-7px]')}>
{isIndent && <TreeIndentLine depth={1} />}
<div className='py-1'>
<div className='flex'>
<div className='flex items-center leading-[18px]'>
<div className='code-sm-semibold text-text-secondary'>{name}</div>
<div className='system-xs-regular ml-2 text-text-tertiary'>{type}</div>
</div>
)}
</div>
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
{description}
{subItems && (
<div className='ml-2 border-l border-gray-200 pl-2'>
{subItems.map((item, index) => (
<VarItem
key={index}
name={item.name}
type={item.type}
description={item.description}
/>
))}
</div>
)}
</div>
</div>
</div>
)

View File

@@ -35,6 +35,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
type Props = {
className?: string
@@ -144,6 +145,8 @@ const Editor: FC<Props> = ({
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
}
const getVarType = useWorkflowVariableType()
return (
<Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}>
<div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}>
@@ -251,6 +254,7 @@ const Editor: FC<Props> = ({
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
getVarType,
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,

View File

@@ -9,6 +9,7 @@ import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './variab
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { RiMoreLine } from '@remixicon/react'
type Props = {
nodeId: string
value: string
@@ -45,6 +46,7 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({
const isChatVar = isConversationVar(value)
const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data
const varName = `${isSystem ? 'sys.' : ''}${value[value.length - 1]}`
const isShowAPart = value.length > 2
return (<span key={index}>
<span className='relative top-[-3px] leading-[16px]'>{str}</span>
@@ -61,6 +63,12 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({
<Line3 className='mr-0.5'></Line3>
</div>
)}
{isShowAPart && (
<div className='flex items-center'>
<RiMoreLine className='h-3 w-3 text-text-secondary' />
<Line3 className='mr-0.5 text-divider-deep'></Line3>
</div>
)}
<div className='flex items-center text-text-accent'>
{!isEnv && !isChatVar && <Variable02 className='h-3.5 w-3.5 shrink-0' />}
{isEnv && <Env className='h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600' />}

View File

@@ -0,0 +1,77 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import type { Field as FieldType } from '../../../../../llm/types'
import cn from '@/utils/classnames'
import TreeIndentLine from '../tree-indent-line'
import { RiMoreFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import type { ValueSelector } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
const MAX_DEPTH = 10
type Props = {
valueSelector: ValueSelector
name: string,
payload: FieldType,
depth?: number
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
}
const Field: FC<Props> = ({
valueSelector,
name,
payload,
depth = 1,
readonly,
onSelect,
}) => {
const { t } = useTranslation()
const isLastFieldHighlight = readonly
const hasChildren = payload.type === Type.object && payload.properties
const isHighlight = isLastFieldHighlight && !hasChildren
if (depth > MAX_DEPTH + 1)
return null
return (
<div>
<Tooltip popupContent={t('app.structOutput.moreFillTip')} disabled={depth !== MAX_DEPTH + 1}>
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onClick={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className='flex grow items-stretch'>
<TreeIndentLine depth={depth} />
{depth === MAX_DEPTH + 1 ? (
<RiMoreFill className='h-3 w-3 text-text-tertiary' />
) : (<div className={cn('system-sm-medium h-6 w-0 grow truncate leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
</div>
{depth < MAX_DEPTH + 1 && (
<div className='system-xs-regular ml-2 shrink-0 text-text-tertiary'>{getFieldType(payload)}</div>
)}
</div>
</Tooltip>
{depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (
<div>
{Object.keys(payload.properties).map(propName => (
<Field
key={propName}
name={propName}
payload={payload.properties?.[propName] as FieldType}
depth={depth + 1}
readonly={readonly}
valueSelector={[...valueSelector, name]}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,82 @@
'use client'
import type { FC } from 'react'
import React, { useRef } from 'react'
import type { StructuredOutput } from '../../../../../llm/types'
import Field from './field'
import cn from '@/utils/classnames'
import { useHover } from 'ahooks'
import type { ValueSelector } from '@/app/components/workflow/types'
type Props = {
className?: string
root: { nodeId?: string, nodeName?: string, attrName: string }
payload: StructuredOutput
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
onHovering?: (value: boolean) => void
}
export const PickerPanelMain: FC<Props> = ({
className,
root,
payload,
readonly,
onHovering,
onSelect,
}) => {
const ref = useRef<HTMLDivElement>(null)
useHover(ref, {
onChange: (hovering) => {
if (hovering) {
onHovering?.(true)
}
else {
setTimeout(() => {
onHovering?.(false)
}, 100)
}
},
})
const schema = payload.schema
const fieldNames = Object.keys(schema.properties)
return (
<div className={cn(className)} ref={ref}>
{/* Root info */}
<div className='flex items-center justify-between px-2 py-1'>
<div className='flex'>
{root.nodeName && (
<>
<div className='system-sm-medium max-w-[100px] truncate text-text-tertiary'>{root.nodeName}</div>
<div className='system-sm-medium text-text-tertiary'>.</div>
</>
)}
<div className='system-sm-medium text-text-secondary'>{root.attrName}</div>
</div>
{/* It must be object */}
<div className='system-xs-regular ml-2 shrink-0 text-text-tertiary'>object</div>
</div>
{fieldNames.map(name => (
<Field
key={name}
name={name}
payload={schema.properties[name]}
readonly={readonly}
valueSelector={[root.nodeId!, root.attrName]}
onSelect={onSelect}
/>
))}
</div>
)
}
const PickerPanel: FC<Props> = ({
className,
...props
}) => {
return (
<div className={cn('w-[296px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pb-0 shadow-lg backdrop-blur-[5px]', className)}>
<PickerPanelMain {...props} />
</div>
)
}
export default React.memo(PickerPanel)

View File

@@ -0,0 +1,74 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import type { Field as FieldType } from '../../../../../llm/types'
import cn from '@/utils/classnames'
import TreeIndentLine from '../tree-indent-line'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { RiArrowDropDownLine } from '@remixicon/react'
type Props = {
name: string,
payload: FieldType,
required: boolean,
depth?: number,
rootClassName?: string
}
const Field: FC<Props> = ({
name,
payload,
depth = 1,
required,
rootClassName,
}) => {
const { t } = useTranslation()
const isRoot = depth === 1
const hasChildren = payload.type === Type.object && payload.properties
const [fold, {
toggle: toggleFold,
}] = useBoolean(false)
return (
<div>
<div className={cn('flex pr-2')}>
<TreeIndentLine depth={depth} />
<div className='w-0 grow'>
<div className='relative flex select-none'>
{hasChildren && (
<RiArrowDropDownLine
className={cn('absolute left-[-18px] top-[50%] h-4 w-4 translate-y-[-50%] cursor-pointer bg-components-panel-bg text-text-tertiary', fold && 'rotate-[270deg] text-text-accent')}
onClick={toggleFold}
/>
)}
<div className={cn('system-sm-medium ml-[7px] h-6 truncate leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div>
<div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>{getFieldType(payload)}</div>
{required && <div className='system-2xs-medium-uppercase ml-3 leading-6 text-text-warning'>{t('app.structOutput.required')}</div>}
</div>
{payload.description && (
<div className='ml-[7px] flex'>
<div className='system-xs-regular w-0 grow truncate text-text-tertiary'>{payload.description}</div>
</div>
)}
</div>
</div>
{hasChildren && !fold && (
<div>
{Object.keys(payload.properties!).map(name => (
<Field
key={name}
name={name}
payload={payload.properties?.[name] as FieldType}
depth={depth + 1}
required={!!payload.required?.includes(name)}
/>
))}
</div>
)}
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,39 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { StructuredOutput } from '../../../../../llm/types'
import Field from './field'
import { useTranslation } from 'react-i18next'
type Props = {
payload: StructuredOutput
rootClassName?: string
}
const ShowPanel: FC<Props> = ({
payload,
rootClassName,
}) => {
const { t } = useTranslation()
const schema = {
...payload,
schema: {
...payload.schema,
description: t('app.structOutput.LLMResponse'),
},
}
return (
<div className='relative left-[-7px]'>
{Object.keys(schema.schema.properties!).map(name => (
<Field
key={name}
name={name}
payload={schema.schema.properties![name]}
required={!!schema.schema.required?.includes(name)}
rootClassName={rootClassName}
/>
))}
</div>
)
}
export default React.memo(ShowPanel)

View File

@@ -0,0 +1,24 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
type Props = {
depth?: number,
className?: string,
}
const TreeIndentLine: FC<Props> = ({
depth = 1,
className,
}) => {
const depthArray = Array.from({ length: depth }, (_, index) => index)
return (
<div className={cn('flex', className)}>
{depthArray.map(d => (
<div key={d} className={cn('ml-2.5 mr-2.5 w-px bg-divider-regular')}></div>
))}
</div>
)
}
export default React.memo(TreeIndentLine)

View File

@@ -319,12 +319,19 @@ const formatItem = (
const outputSchema: any[] = []
Object.keys(output_schema.properties).forEach((outputKey) => {
const output = output_schema.properties[outputKey]
const dataType = output.type
outputSchema.push({
variable: outputKey,
type: output.type === 'array'
type: dataType === 'array'
? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]`
: `${output.type.slice(0, 1).toLocaleLowerCase()}${output.type.slice(1)}`,
description: output.description,
children: output.type === 'object' ? {
schema: {
type: 'object',
properties: output.properties,
},
} : undefined,
})
})
res.vars = [

View File

@@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { Field, StructuredOutput, TypeWithArray } from '../../../llm/types'
import { Type } from '../../../llm/types'
import { PickerPanelMain as Panel } from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
type Props = {
nodeName: string
path: string[]
varType: TypeWithArray
nodeType?: BlockEnum
}
const VarFullPathPanel: FC<Props> = ({
nodeName,
path,
varType,
nodeType = BlockEnum.LLM,
}) => {
const schema: StructuredOutput = (() => {
const schema: StructuredOutput['schema'] = {
type: Type.object,
properties: {} as { [key: string]: Field },
required: [],
additionalProperties: false,
}
let current = schema
for (let i = 1; i < path.length; i++) {
const isLast = i === path.length - 1
const name = path[i]
current.properties[name] = {
type: isLast ? varType : Type.object,
properties: {},
} as Field
current = current.properties[name] as { type: Type.object; properties: { [key: string]: Field; }; required: never[]; additionalProperties: false; }
}
return {
schema,
}
})()
return (
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-0 shadow-lg backdrop-blur-[5px]'>
<div className='flex space-x-1 border-b-[0.5px] border-divider-subtle p-3 pb-2 '>
<BlockIcon size='xs' type={nodeType} />
<div className='system-xs-medium w-0 grow truncate text-text-secondary'>{nodeName}</div>
</div>
<Panel
className='px-1 pb-3 pt-2'
root={{ attrName: path[0] }}
payload={schema}
readonly
/>
</div>
)
}
export default React.memo(VarFullPathPanel)

View File

@@ -6,13 +6,14 @@ import {
RiArrowDownSLine,
RiCloseLine,
RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react'
import produce from 'immer'
import { useStoreApi } from 'reactflow'
import RemoveButton from '../remove-button'
import useAvailableVarList from '../../hooks/use-available-var-list'
import VarReferencePopup from './var-reference-popup'
import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './utils'
import { getNodeInfoById, isConversationVar, isENV, isSystemVar, varTypeToStructType } from './utils'
import ConstantField from './constant-field'
import cn from '@/utils/classnames'
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
@@ -37,6 +38,7 @@ import AddButton from '@/app/components/base/button/add-button'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from './var-full-path-panel'
import { noop } from 'lodash-es'
const TRIGGER_DEFAULT_WIDTH = 227
@@ -173,16 +175,15 @@ const VarReferencePicker: FC<Props> = ({
return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
const varName = useMemo(() => {
if (hasValue) {
const isSystem = isSystemVar(value as ValueSelector)
let varName = ''
if (Array.isArray(value))
varName = value.length >= 3 ? (value as ValueSelector).slice(-2).join('.') : value[value.length - 1]
const isShowAPart = (value as ValueSelector).length > 2
return `${isSystem ? 'sys.' : ''}${varName}`
}
return ''
const varName = useMemo(() => {
if (!hasValue)
return ''
const isSystem = isSystemVar(value as ValueSelector)
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
return `${isSystem ? 'sys.' : ''}${varName}`
}, [hasValue, value])
const varKindTypes = [
@@ -270,6 +271,22 @@ const VarReferencePicker: FC<Props> = ({
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const tooltipPopup = useMemo(() => {
if (isValidVar && isShowAPart) {
return (
<VarFullPathPanel
nodeName={outputVarNode?.title}
path={(value as ValueSelector).slice(1)}
varType={varTypeToStructType(type)}
nodeType={outputVarNode?.type}
/>)
}
if (!isValidVar && hasValue)
return t('workflow.errorMsg.invalidVariable')
return null
}, [isValidVar, isShowAPart, hasValue, t, outputVarNode?.title, outputVarNode?.type, value, type])
return (
<div className={cn(className, !readonly && 'cursor-pointer')}>
<PortalToFollowElem
@@ -334,7 +351,7 @@ const VarReferencePicker: FC<Props> = ({
className='h-full grow'
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip popupContent={!isValidVar && hasValue && t('workflow.errorMsg.invalidVariable')}>
<Tooltip noDecoration={isShowAPart} popupContent={tooltipPopup}>
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
@@ -353,6 +370,12 @@ const VarReferencePicker: FC<Props> = ({
<Line3 className='mr-0.5'></Line3>
</div>
)}
{isShowAPart && (
<div className='flex items-center'>
<RiMoreLine className='h-3 w-3 text-text-secondary' />
<Line3 className='mr-0.5 text-divider-deep'></Line3>
</div>
)}
<div className='flex items-center text-text-accent'>
{!hasValue && <Variable02 className='h-3.5 w-3.5' />}
{isEnv && <Env className='h-3.5 w-3.5 text-util-colors-violet-violet-600' />}

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHover } from 'ahooks'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
@@ -15,6 +15,11 @@ import {
import Input from '@/app/components/base/input'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { checkKeys } from '@/utils/var'
import type { StructuredOutput } from '../../../llm/types'
import { Type } from '../../../llm/types'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { varTypeToStructType } from './utils'
import type { Field } from '@/app/components/workflow/nodes/llm/types'
import { FILE_STRUCT } from '@/app/components/workflow/constants'
import { Loop } from '@/app/components/base/icons/src/vender/workflow'
import { noop } from 'lodash-es'
@@ -52,16 +57,41 @@ const Item: FC<ItemProps> = ({
itemData,
onChange,
onHovering,
itemWidth,
isSupportFileVar,
isException,
isLoopVar,
}) => {
const isFile = itemData.type === VarType.file
const isObj = (objVarTypes.includes(itemData.type) && itemData.children && itemData.children.length > 0)
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isFile = itemData.type === VarType.file && !isStructureOutput
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
const objStructuredOutput: StructuredOutput | null = useMemo(() => {
if (!isObj) return null
const properties: Record<string, Field> = {};
(isFile ? FILE_STRUCT : (itemData.children as Var[])).forEach((c) => {
properties[c.variable] = {
type: varTypeToStructType(c.type),
}
})
return {
schema: {
type: Type.object,
properties,
required: [],
additionalProperties: false,
},
}
}, [isFile, isObj, itemData.children])
const structuredOutput = (() => {
if (isStructureOutput)
return itemData.children as StructuredOutput
return objStructuredOutput
})()
const itemRef = useRef<HTMLDivElement>(null)
const [isItemHovering, setIsItemHovering] = useState(false)
useHover(itemRef, {
@@ -70,7 +100,7 @@ const Item: FC<ItemProps> = ({
setIsItemHovering(true)
}
else {
if (isObj) {
if (isObj || isStructureOutput) {
setTimeout(() => {
setIsItemHovering(false)
}, 100)
@@ -83,7 +113,7 @@ const Item: FC<ItemProps> = ({
})
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering
const open = isObj && isHovering
const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -110,8 +140,8 @@ const Item: FC<ItemProps> = ({
<div
ref={itemRef}
className={cn(
isObj ? ' pr-1' : 'pr-[18px]',
isHovering && (isObj ? 'bg-primary-50' : 'bg-state-base-hover'),
(isObj || isStructureOutput) ? ' pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-primary-50' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3')
}
onClick={handleChosen}
@@ -133,42 +163,28 @@ const Item: FC<ItemProps> = ({
)}
</div>
<div className='ml-1 shrink-0 text-xs font-normal capitalize text-text-tertiary'>{itemData.type}</div>
{isObj && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)}
</div>
</PortalToFollowElemTrigger>
{
(isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)
}
</div >
</PortalToFollowElemTrigger >
<PortalToFollowElemContent style={{
zIndex: 100,
}}>
{(isObj && !isFile) && (
// eslint-disable-next-line ts/no-use-before-define
<ObjectChildren
nodeId={nodeId}
title={title}
objPath={[...objPath, itemData.variable]}
data={itemData.children as Var[]}
onChange={onChange}
{(isStructureOutput || isObj) && (
<PickerStructurePanel
root={{ nodeId, nodeName: title, attrName: itemData.variable }}
payload={structuredOutput!}
onHovering={setIsChildrenHovering}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
/>
)}
{isFile && (
// eslint-disable-next-line ts/no-use-before-define
<ObjectChildren
nodeId={nodeId}
title={title}
objPath={[...objPath, itemData.variable]}
data={FILE_STRUCT}
onChange={onChange}
onHovering={setIsChildrenHovering}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
onSelect={(valueSelector) => {
onChange(valueSelector, itemData)
}}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PortalToFollowElem >
)
}
@@ -331,7 +347,7 @@ const VarReferenceVars: FC<Props> = ({
}
</div>
: <div className='pl-3 text-xs font-medium uppercase leading-[18px] text-gray-500'>{t('workflow.common.noVar')}</div>}
</ >
</>
)
}
export default React.memo(VarReferenceVars)