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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 4190 additions and 276 deletions

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="arrow-down-round-fill">
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="#101828"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,36 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "arrow-down-round-fill"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"d": "M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
},
"name": "ArrowDownRoundFill"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './ArrowDownRoundFill.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'ArrowDownRoundFill'
export default Icon

View File

@ -1,4 +1,5 @@
export { default as AnswerTriangle } from './AnswerTriangle'
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'
export { default as CheckCircle } from './CheckCircle'
export { default as CheckDone01 } from './CheckDone01'
export { default as Download02 } from './Download02'

View File

@ -14,7 +14,7 @@ export class HistoryBlockNode extends DecoratorNode<React.JSX.Element> {
}
static clone(node: HistoryBlockNode): HistoryBlockNode {
return new HistoryBlockNode(node.__roleName, node.__onEditRole)
return new HistoryBlockNode(node.__roleName, node.__onEditRole, node.__key)
}
constructor(roleName: RoleName, onEditRole: () => void, key?: NodeKey) {

View File

@ -11,6 +11,7 @@ import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react'
import { useSelectOrDelete } from '../../hooks'
import type { WorkflowNodesMap } from './node'
@ -27,26 +28,35 @@ import { Line3 } from '@/app/components/base/icons/src/public/common'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector } from '@/app/components/workflow/types'
type WorkflowVariableBlockComponentProps = {
nodeKey: string
variables: string[]
workflowNodesMap: WorkflowNodesMap
getVarType?: (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
}
const WorkflowVariableBlockComponent = ({
nodeKey,
variables,
workflowNodesMap = {},
getVarType,
}: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
const variablesLength = variables.length
const isShowAPart = variablesLength > 2
const varName = (
() => {
const isSystem = isSystemVar(variables)
const varName = variablesLength >= 3 ? (variables).slice(-2).join('.') : variables[variablesLength - 1]
const varName = variables[variablesLength - 1]
return `${isSystem ? 'sys.' : ''}${varName}`
}
)()
@ -76,7 +86,7 @@ const WorkflowVariableBlockComponent = ({
const Item = (
<div
className={cn(
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border pl-0.5 pr-[3px]',
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border pl-0.5 pr-[3px] hover:border-state-accent-solid hover:bg-state-accent-hover',
isSelected ? ' border-state-accent-solid bg-state-accent-hover' : ' border-components-panel-border-subtle bg-components-badge-white-to-dark',
!node && !isEnv && !isChatVar && '!border-state-destructive-solid !bg-state-destructive-hover',
)}
@ -99,6 +109,13 @@ const WorkflowVariableBlockComponent = ({
<Line3 className='mr-0.5 text-divider-deep'></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={cn('h-3.5 w-3.5 shrink-0', isException && 'text-text-warning')} />}
{isEnv && <Env className='h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600' />}
@ -126,7 +143,27 @@ const WorkflowVariableBlockComponent = ({
)
}
return Item
if (!node)
return null
return (
<Tooltip
noDecoration
popupContent={
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
varType={getVarType ? getVarType({
nodeId: variables[0],
valueSelector: variables,
}) : Type.string}
nodeType={node?.type}
/>}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
)
}
export default memo(WorkflowVariableBlockComponent)

View File

@ -9,7 +9,7 @@ import {
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type { WorkflowVariableBlockType } from '../../types'
import type { GetVarType, WorkflowVariableBlockType } from '../../types'
import {
$createWorkflowVariableBlockNode,
WorkflowVariableBlockNode,
@ -25,11 +25,13 @@ export type WorkflowVariableBlockProps = {
getWorkflowNode: (nodeId: string) => Node
onInsert?: () => void
onDelete?: () => void
getVarType: GetVarType
}
const WorkflowVariableBlock = memo(({
workflowNodesMap,
onInsert,
onDelete,
getVarType,
}: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext()
@ -48,7 +50,7 @@ const WorkflowVariableBlock = memo(({
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => {
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
$insertNodes([workflowVariableBlockNode])
if (onInsert)
@ -69,7 +71,7 @@ const WorkflowVariableBlock = memo(({
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete, workflowNodesMap])
}, [editor, onInsert, onDelete, workflowNodesMap, getVarType])
return null
})

View File

@ -2,34 +2,39 @@ import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import type { WorkflowVariableBlockType } from '../../types'
import WorkflowVariableBlockComponent from './component'
import type { GetVarType } from '../../types'
export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
export type SerializedNode = SerializedLexicalNode & {
variables: string[]
workflowNodesMap: WorkflowNodesMap
getVarType?: GetVarType
}
export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> {
__variables: string[]
__workflowNodesMap: WorkflowNodesMap
__getVarType?: GetVarType
static getType(): string {
return 'workflow-variable-block'
}
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__key)
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key)
}
isInline(): boolean {
return true
}
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, key?: NodeKey) {
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey) {
super(key)
this.__variables = variables
this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType
}
createDOM(): HTMLElement {
@ -48,12 +53,13 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
nodeKey={this.getKey()}
variables={this.__variables}
workflowNodesMap={this.__workflowNodesMap}
getVarType={this.__getVarType!}
/>
)
}
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap)
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType)
return node
}
@ -64,6 +70,7 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
version: 1,
variables: this.getVariables(),
workflowNodesMap: this.getWorkflowNodesMap(),
getVarType: this.getVarType(),
}
}
@ -77,12 +84,17 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
return self.__workflowNodesMap
}
getVarType(): any {
const self = this.getLatest()
return self.__getVarType
}
getTextContent(): string {
return `{{#${this.getVariables().join('.')}#}}`
}
}
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap)
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
}
export function $isWorkflowVariableBlockNode(

View File

@ -16,6 +16,7 @@ import { VAR_REGEX as REGEX, resetReg } from '@/config'
const WorkflowVariableBlockReplacementBlock = ({
workflowNodesMap,
getVarType,
onInsert,
}: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext()
@ -30,8 +31,8 @@ const WorkflowVariableBlockReplacementBlock = ({
onInsert()
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap))
}, [onInsert, workflowNodesMap])
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType))
}, [onInsert, workflowNodesMap, getVarType])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)

View File

@ -1,8 +1,10 @@
import type { Type } from '../../workflow/nodes/llm/types'
import type { Dataset } from './plugins/context-block'
import type { RoleName } from './plugins/history-block'
import type {
Node,
NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types'
export type Option = {
@ -54,12 +56,18 @@ export type ExternalToolBlockType = {
onAddExternalTool?: () => void
}
export type GetVarType = (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
export type WorkflowVariableBlockType = {
show?: boolean
variables?: NodeOutPutVar[]
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>>
onInsert?: () => void
onDelete?: () => void
getVarType?: GetVarType
}
export type MenuTextMatch = {

View File

@ -0,0 +1,68 @@
import React from 'react'
import classNames from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
import Divider from '../divider'
// Updated generic type to allow enum values
type SegmentedControlProps<T extends string | number | symbol> = {
options: { Icon: RemixiconComponentType, text: string, value: T }[]
value: T
onChange: (value: T) => void
className?: string
}
export const SegmentedControl = <T extends string | number | symbol>({
options,
value,
onChange,
className,
}: SegmentedControlProps<T>): JSX.Element => {
const selectedOptionIndex = options.findIndex(option => option.value === value)
return (
<div className={classNames(
'flex items-center rounded-lg bg-components-segmented-control-bg-normal gap-x-[1px] p-0.5',
className,
)}>
{options.map((option, index) => {
const { Icon } = option
const isSelected = index === selectedOptionIndex
const isNextSelected = index === selectedOptionIndex - 1
const isLast = index === options.length - 1
return (
<button
type='button'
key={String(option.value)}
className={classNames(
'flex items-center justify-center relative px-2 py-1 rounded-lg gap-x-0.5 group border-0.5 border-transparent',
isSelected
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
: 'hover:bg-state-base-hover',
)}
onClick={() => onChange(option.value)}
>
<span className='flex h-5 w-5 items-center justify-center'>
<Icon className={classNames(
'w-4 h-4 text-text-tertiary',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)} />
</span>
<span className={classNames(
'p-0.5 text-text-tertiary system-sm-medium',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)}>
{option.text}
</span>
{!isLast && !isSelected && !isNextSelected && (
<div className='absolute right-[-1px] top-0 flex h-full items-center'>
<Divider type='vertical' className='mx-0 h-3.5' />
</div>
)}
</button>
)
})}
</div>
)
}
export default React.memo(SegmentedControl) as typeof SegmentedControl

View File

@ -8,8 +8,9 @@ const textareaVariants = cva(
{
variants: {
size: {
regular: 'px-3 radius-md system-sm-regular',
large: 'px-4 radius-lg system-md-regular',
small: 'py-1 rounded-md system-xs-regular',
regular: 'px-3 rounded-md system-sm-regular',
large: 'px-4 rounded-lg system-md-regular',
},
},
defaultVariants: {

View File

@ -60,6 +60,7 @@ export enum ModelFeatureEnum {
video = 'video',
document = 'document',
audio = 'audio',
StructuredOutput = 'structured-output',
}
export enum ModelFeatureTextEnum {

View File

@ -376,6 +376,7 @@ function Form<
tooltip={tooltip?.[language] || tooltip?.en_US}
value={value[variable] || []}
onChange={item => handleFormChange(variable, item as any)}
supportCollapse
/>
{fieldMoreInfo?.(formSchema)}
{validating && changeKey === variable && <ValidatingTip />}

View File

@ -10,6 +10,7 @@ import Slider from '@/app/components/base/slider'
import Radio from '@/app/components/base/radio'
import { SimpleSelect } from '@/app/components/base/select'
import TagInput from '@/app/components/base/tag-input'
import { useTranslation } from 'react-i18next'
export type ParameterValue = number | string | string[] | boolean | undefined
@ -27,6 +28,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
onSwitch,
isInWorkflow,
}) => {
const { t } = useTranslation()
const language = useLanguage()
const [localValue, setLocalValue] = useState(value)
const numberInputRef = useRef<HTMLInputElement>(null)

View File

@ -2,7 +2,6 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
RiArrowDropDownLine,
RiQuestionLine,
} from '@remixicon/react'
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
@ -13,6 +12,7 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { Node } from 'reactflow'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
type Props = {
disabled?: boolean
@ -98,14 +98,12 @@ const MultipleToolSelector = ({
</Tooltip>
)}
{supportCollapse && (
<div className='absolute -left-4 top-1'>
<RiArrowDropDownLine
className={cn(
'h-4 w-4 text-text-tertiary',
collapse && '-rotate-90',
)}
/>
</div>
<ArrowDownRoundFill
className={cn(
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapse && 'rotate-[270deg]',
)}
/>
)}
</div>
{value.length > 0 && (

View File

@ -8,6 +8,8 @@ import type {
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { useIsChatMode } from './use-workflow'
import { useStoreApi } from 'reactflow'
export const useWorkflowVariables = () => {
const { t } = useTranslation()
@ -75,3 +77,37 @@ export const useWorkflowVariables = () => {
getCurrentVariableType,
}
}
export const useWorkflowVariableType = () => {
const store = useStoreApi()
const {
getNodes,
} = store.getState()
const { getCurrentVariableType } = useWorkflowVariables()
const isChatMode = useIsChatMode()
const getVarType = ({
nodeId,
valueSelector,
}: {
nodeId: string,
valueSelector: ValueSelector,
}) => {
const node = getNodes().find(n => n.id === nodeId)
const isInIteration = !!node?.data.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null
const availableNodes = [node]
const type = getCurrentVariableType({
parentNode: iterationNode,
valueSelector,
availableNodes,
isChatMode,
isConstant: false,
})
return type
}
return getVarType
}

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)

View File

@ -39,7 +39,8 @@ const MetadataFilter = ({
disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual}
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={
hideCollapseIcon
trigger={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'>
@ -52,6 +53,7 @@ const MetadataFilter = ({
</div>
)}
/>
{collapseIcon}
</div>
<div className='flex items-center'>
<MetadataFilterSelector
@ -67,7 +69,7 @@ const MetadataFilter = ({
}
</div>
</div>
}
)}
>
<>
{

View File

@ -0,0 +1,140 @@
import React, { type FC, useCallback, useEffect, useRef } from 'react'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import classNames from '@/utils/classnames'
import { Editor } from '@monaco-editor/react'
import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import Tooltip from '@/app/components/base/tooltip'
import { useTranslation } from 'react-i18next'
type CodeEditorProps = {
value: string
onUpdate?: (value: string) => void
showFormatButton?: boolean
editorWrapperClassName?: string
readOnly?: boolean
} & React.HTMLAttributes<HTMLDivElement>
const CodeEditor: FC<CodeEditorProps> = ({
value,
onUpdate,
showFormatButton = true,
editorWrapperClassName,
readOnly = false,
className,
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const monacoRef = useRef<any>(null)
const editorRef = useRef<any>(null)
useEffect(() => {
if (monacoRef.current) {
if (theme === Theme.light)
monacoRef.current.editor.setTheme('light-theme')
else
monacoRef.current.editor.setTheme('dark-theme')
}
}, [theme])
const handleEditorDidMount = useCallback((editor: any, monaco: any) => {
editorRef.current = editor
monacoRef.current = monaco
monaco.editor.defineTheme('light-theme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000000',
'focusBorder': '#00000000',
},
})
monaco.editor.defineTheme('dark-theme', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000000',
'focusBorder': '#00000000',
},
})
monaco.editor.setTheme('light-theme')
}, [])
const formatJsonContent = useCallback(() => {
if (editorRef.current)
editorRef.current.getAction('editor.action.formatDocument')?.run()
}, [])
const handleEditorChange = useCallback((value: string | undefined) => {
if (value !== undefined)
onUpdate?.(value)
}, [onUpdate])
return (
<div className={classNames('flex flex-col h-full bg-components-input-bg-normal overflow-hidden', className)}>
<div className='flex items-center justify-between pl-2 pr-1 pt-1'>
<div className='system-xs-semibold-uppercase py-0.5 text-text-secondary'>
<span className='px-1 py-0.5'>JSON</span>
</div>
<div className='flex items-center gap-x-0.5'>
{showFormatButton && (
<Tooltip popupContent={t('common.operation.format')}>
<button
type='button'
className='flex h-6 w-6 items-center justify-center'
onClick={formatJsonContent}
>
<RiIndentIncrease className='h-4 w-4 text-text-tertiary' />
</button>
</Tooltip>
)}
<Tooltip popupContent={t('common.operation.copy')}>
<button
type='button'
className='flex h-6 w-6 items-center justify-center'
onClick={() => copy(value)}>
<RiClipboardLine className='h-4 w-4 text-text-tertiary' />
</button>
</Tooltip>
</div>
</div>
<div className={classNames('relative', editorWrapperClassName)}>
<Editor
height='100%'
defaultLanguage='json'
value={value}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
options={{
readOnly,
domReadOnly: true,
minimap: { enabled: false },
tabSize: 2,
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'same',
// Add these options
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
renderLineHighlightOnlyWhenFocus: false,
renderLineHighlight: 'none',
// Hide scrollbar borders
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
verticalScrollbarSize: 0,
horizontalScrollbarSize: 0,
alwaysConsumeMouseWheel: false,
},
}}
/>
</div>
</div>
)
}
export default React.memo(CodeEditor)

View File

@ -0,0 +1,27 @@
import React from 'react'
import type { FC } from 'react'
import { RiErrorWarningFill } from '@remixicon/react'
import classNames from '@/utils/classnames'
type ErrorMessageProps = {
message: string
} & React.HTMLAttributes<HTMLDivElement>
const ErrorMessage: FC<ErrorMessageProps> = ({
message,
className,
}) => {
return (
<div className={classNames(
'flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg',
className,
)}>
<RiErrorWarningFill className='h-4 w-4 shrink-0 text-text-destructive' />
<div className='system-xs-medium max-h-12 grow overflow-y-auto break-words text-text-primary'>
{message}
</div>
</div>
)
}
export default React.memo(ErrorMessage)

View File

@ -0,0 +1,34 @@
import React, { type FC } from 'react'
import Modal from '../../../../../base/modal'
import type { SchemaRoot } from '../../types'
import JsonSchemaConfig from './json-schema-config'
type JsonSchemaConfigModalProps = {
isShow: boolean
defaultSchema?: SchemaRoot
onSave: (schema: SchemaRoot) => void
onClose: () => void
}
const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
isShow,
defaultSchema,
onSave,
onClose,
}) => {
return (
<Modal
isShow={isShow}
onClose={onClose}
className='h-[800px] max-w-[960px] p-0'
>
<JsonSchemaConfig
defaultSchema={defaultSchema}
onSave={onSave}
onClose={onClose}
/>
</Modal>
)
}
export default JsonSchemaConfigModal

View File

@ -0,0 +1,136 @@
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import { checkJsonDepth } from '../../utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import CodeEditor from './code-editor'
import ErrorMessage from './error-message'
import { useVisualEditorStore } from './visual-editor/store'
import { useMittContext } from './visual-editor/context'
type JsonImporterProps = {
onSubmit: (schema: any) => void
updateBtnWidth: (width: number) => void
}
const JsonImporter: FC<JsonImporterProps> = ({
onSubmit,
updateBtnWidth,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<any>(null)
const importBtnRef = useRef<HTMLButtonElement>(null)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const { emit } = useMittContext()
useEffect(() => {
if (importBtnRef.current) {
const rect = importBtnRef.current.getBoundingClientRect()
updateBtnWidth(rect.width)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
if (advancedEditing || isAddingNewField)
emit('quitEditing', {})
setOpen(!open)
}, [open, advancedEditing, isAddingNewField, emit])
const onClose = useCallback(() => {
setOpen(false)
}, [])
const handleSubmit = useCallback(() => {
try {
const parsedJSON = JSON.parse(json)
if (typeof parsedJSON !== 'object' || Array.isArray(parsedJSON)) {
setParseError(new Error('Root must be an object, not an array or primitive value.'))
return
}
const maxDepth = checkJsonDepth(parsedJSON)
if (maxDepth > JSON_SCHEMA_MAX_DEPTH) {
setParseError({
type: 'error',
message: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`,
})
return
}
onSubmit(parsedJSON)
setParseError(null)
setOpen(false)
}
catch (e: any) {
if (e instanceof Error)
setParseError(e)
else
setParseError(new Error('Invalid JSON'))
}
}, [onSubmit, json])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 16,
}}
>
<PortalToFollowElemTrigger ref={importBtnRef} onClick={handleTrigger}>
<button
type='button'
className={cn(
'system-xs-medium flex shrink-0 rounded-md px-1.5 py-1 text-text-tertiary hover:bg-components-button-ghost-bg-hover',
open && 'bg-components-button-ghost-bg-hover',
)}
>
<span className='px-0.5'>{t('workflow.nodes.llm.jsonSchema.import')}</span>
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
<div className='flex w-[400px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
{/* Title */}
<div className='relative px-3 pb-1 pt-3.5'>
<div className='absolute bottom-0 right-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
{t('workflow.nodes.llm.jsonSchema.import')}
</div>
</div>
{/* Content */}
<div className='px-4 py-2'>
<CodeEditor
className='rounded-lg'
editorWrapperClassName='h-[340px]'
value={json}
onUpdate={setJson}
showFormatButton={false}
/>
{parseError && <ErrorMessage message={parseError.message} />}
</div>
{/* Footer */}
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onClose}>
{t('common.operation.cancel')}
</Button>
<Button variant='primary' onClick={handleSubmit}>
{t('common.operation.submit')}
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default JsonImporter

View File

@ -0,0 +1,301 @@
import React, { type FC, useCallback, useState } from 'react'
import { type SchemaRoot, Type } from '../../types'
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
import { SegmentedControl } from '../../../../../base/segmented-control'
import JsonSchemaGenerator from './json-schema-generator'
import Divider from '@/app/components/base/divider'
import JsonImporter from './json-importer'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import VisualEditor from './visual-editor'
import SchemaEditor from './schema-editor'
import {
checkJsonSchemaDepth,
convertBooleanToString,
getValidationErrorMessage,
jsonToSchema,
preValidateSchema,
validateSchemaAgainstDraft7,
} from '../../utils'
import { MittProvider, VisualEditorContextProvider, useMittContext } from './visual-editor/context'
import ErrorMessage from './error-message'
import { useVisualEditorStore } from './visual-editor/store'
import Toast from '@/app/components/base/toast'
import { useGetLanguage } from '@/context/i18n'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
type JsonSchemaConfigProps = {
defaultSchema?: SchemaRoot
onSave: (schema: SchemaRoot) => void
onClose: () => void
}
enum SchemaView {
VisualEditor = 'visualEditor',
JsonSchema = 'jsonSchema',
}
const VIEW_TABS = [
{ Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor },
{ Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema },
]
const DEFAULT_SCHEMA: SchemaRoot = {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}
const HELP_DOC_URL = {
zh_Hans: 'https://docs.dify.ai/zh-hans/guides/workflow/structured-outputs',
en_US: 'https://docs.dify.ai/guides/workflow/structured-outputs',
ja_JP: 'https://docs.dify.ai/ja-jp/guides/workflow/structured-outputs',
}
type LocaleKey = keyof typeof HELP_DOC_URL
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
defaultSchema,
onSave,
onClose,
}) => {
const { t } = useTranslation()
const locale = useGetLanguage() as LocaleKey
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
const [btnWidth, setBtnWidth] = useState(0)
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const { emit } = useMittContext()
const updateBtnWidth = useCallback((width: number) => {
setBtnWidth(width + 32)
}, [])
const handleTabChange = useCallback((value: SchemaView) => {
if (currentTab === value) return
if (currentTab === SchemaView.JsonSchema) {
try {
const schema = JSON.parse(json)
setParseError(null)
const result = preValidateSchema(schema)
if (!result.success) {
setValidationError(result.error.message)
return
}
const schemaDepth = checkJsonSchemaDepth(schema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return
}
convertBooleanToString(schema)
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
}
setJsonSchema(schema)
setValidationError('')
}
catch (error) {
setValidationError('')
if (error instanceof Error)
setParseError(error)
else
setParseError(new Error('Invalid JSON'))
return
}
}
else if (currentTab === SchemaView.VisualEditor) {
if (advancedEditing || isAddingNewField)
emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
else
setJson(JSON.stringify(jsonSchema, null, 2))
}
setCurrentTab(value)
}, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit])
const handleApplySchema = useCallback((schema: SchemaRoot) => {
if (currentTab === SchemaView.VisualEditor)
setJsonSchema(schema)
else if (currentTab === SchemaView.JsonSchema)
setJson(JSON.stringify(schema, null, 2))
}, [currentTab])
const handleSubmit = useCallback((schema: any) => {
const jsonSchema = jsonToSchema(schema) as SchemaRoot
if (currentTab === SchemaView.VisualEditor)
setJsonSchema(jsonSchema)
else if (currentTab === SchemaView.JsonSchema)
setJson(JSON.stringify(jsonSchema, null, 2))
}, [currentTab])
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
setJsonSchema(schema)
}, [])
const handleSchemaEditorUpdate = useCallback((schema: string) => {
setJson(schema)
}, [])
const handleResetDefaults = useCallback(() => {
if (currentTab === SchemaView.VisualEditor) {
setHoveringProperty(null)
advancedEditing && setAdvancedEditing(false)
isAddingNewField && setIsAddingNewField(false)
}
setJsonSchema(DEFAULT_SCHEMA)
setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2))
}, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty])
const handleCancel = useCallback(() => {
onClose()
}, [onClose])
const handleSave = useCallback(() => {
let schema = jsonSchema
if (currentTab === SchemaView.JsonSchema) {
try {
schema = JSON.parse(json)
setParseError(null)
const result = preValidateSchema(schema)
if (!result.success) {
setValidationError(result.error.message)
return
}
const schemaDepth = checkJsonSchemaDepth(schema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return
}
convertBooleanToString(schema)
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
}
setJsonSchema(schema)
setValidationError('')
}
catch (error) {
setValidationError('')
if (error instanceof Error)
setParseError(error)
else
setParseError(new Error('Invalid JSON'))
return
}
}
else if (currentTab === SchemaView.VisualEditor) {
if (advancedEditing || isAddingNewField) {
Toast.notify({
type: 'warning',
message: t('workflow.nodes.llm.jsonSchema.warningTips.saveSchema'),
})
return
}
}
onSave(schema)
onClose()
}, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t])
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='relative flex p-6 pb-3 pr-14'>
<div className='title-2xl-semi-bold grow truncate text-text-primary'>
{t('workflow.nodes.llm.jsonSchema.title')}
</div>
<div className='absolute right-5 top-5 flex h-8 w-8 items-center justify-center p-1.5' onClick={onClose}>
<RiCloseLine className='h-[18px] w-[18px] text-text-tertiary' />
</div>
</div>
{/* Content */}
<div className='flex items-center justify-between px-6 py-2'>
{/* Tab */}
<SegmentedControl<SchemaView>
options={VIEW_TABS}
value={currentTab}
onChange={handleTabChange}
/>
<div className='flex items-center gap-x-0.5'>
{/* JSON Schema Generator */}
<JsonSchemaGenerator
crossAxisOffset={btnWidth}
onApply={handleApplySchema}
/>
<Divider type='vertical' className='h-3' />
{/* JSON Schema Importer */}
<JsonImporter
updateBtnWidth={updateBtnWidth}
onSubmit={handleSubmit}
/>
</div>
</div>
<div className='flex grow flex-col gap-y-1 overflow-hidden px-6'>
{currentTab === SchemaView.VisualEditor && (
<VisualEditor
schema={jsonSchema}
onChange={handleVisualEditorUpdate}
/>
)}
{currentTab === SchemaView.JsonSchema && (
<SchemaEditor
schema={json}
onUpdate={handleSchemaEditorUpdate}
/>
)}
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</div>
{/* Footer */}
<div className='flex items-center gap-x-2 p-6 pt-5'>
<a
className='flex grow items-center gap-x-1 text-text-accent'
href={HELP_DOC_URL[locale]}
target='_blank'
rel='noopener noreferrer'
>
<span className='system-xs-regular'>{t('workflow.nodes.llm.jsonSchema.doc')}</span>
<RiExternalLinkLine className='h-3 w-3' />
</a>
<div className='flex items-center gap-x-3'>
<div className='flex items-center gap-x-2'>
<Button variant='secondary' onClick={handleResetDefaults}>
{t('workflow.nodes.llm.jsonSchema.resetDefaults')}
</Button>
<Divider type='vertical' className='ml-1 mr-0 h-4' />
</div>
<div className='flex items-center gap-x-2'>
<Button variant='secondary' onClick={handleCancel}>
{t('common.operation.cancel')}
</Button>
<Button variant='primary' onClick={handleSave}>
{t('common.operation.save')}
</Button>
</div>
</div>
</div>
</div>
)
}
const JsonSchemaConfigWrapper: FC<JsonSchemaConfigProps> = (props) => {
return (
<MittProvider>
<VisualEditorContextProvider>
<JsonSchemaConfig {...props} />
</VisualEditorContextProvider>
</MittProvider>
)
}
export default JsonSchemaConfigWrapper

View File

@ -0,0 +1,7 @@
import SchemaGeneratorLight from './schema-generator-light'
import SchemaGeneratorDark from './schema-generator-dark'
export {
SchemaGeneratorLight,
SchemaGeneratorDark,
}

View File

@ -0,0 +1,15 @@
const SchemaGeneratorDark = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.33329 2.95825C10.2308 2.95825 10.9583 2.23071 10.9583 1.33325H11.7083C11.7083 2.23071 12.4358 2.95825 13.3333 2.95825V3.70825C12.4358 3.70825 11.7083 4.43579 11.7083 5.33325H10.9583C10.9583 4.43579 10.2308 3.70825 9.33329 3.70825V2.95825ZM0.666626 7.33325C2.87577 7.33325 4.66663 5.54239 4.66663 3.33325H5.99996C5.99996 5.54239 7.79083 7.33325 9.99996 7.33325V8.66659C7.79083 8.66659 5.99996 10.4575 5.99996 12.6666H4.66663C4.66663 10.4575 2.87577 8.66659 0.666626 8.66659V7.33325ZM11.5 9.33325C11.5 10.5299 10.5299 11.4999 9.33329 11.4999V12.4999C10.5299 12.4999 11.5 13.47 11.5 14.6666H12.5C12.5 13.47 13.47 12.4999 14.6666 12.4999V11.4999C13.47 11.4999 12.5 10.5299 12.5 9.33325H11.5Z" fill="url(#paint0_linear_13059_32065)" fillOpacity="0.95" />
<defs>
<linearGradient id="paint0_linear_13059_32065" x1="14.9996" y1="15" x2="-2.55847" y2="16.6207" gradientUnits="userSpaceOnUse">
<stop stopColor="#36BFFA" />
<stop offset="1" stopColor="#296DFF" />
</linearGradient>
</defs>
</svg>
)
}
export default SchemaGeneratorDark

View File

@ -0,0 +1,15 @@
const SchemaGeneratorLight = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.33329 2.95837C10.2308 2.95837 10.9583 2.23083 10.9583 1.33337H11.7083C11.7083 2.23083 12.4358 2.95837 13.3333 2.95837V3.70837C12.4358 3.70837 11.7083 4.43591 11.7083 5.33337H10.9583C10.9583 4.43591 10.2308 3.70837 9.33329 3.70837V2.95837ZM0.666626 7.33337C2.87577 7.33337 4.66663 5.54251 4.66663 3.33337H5.99996C5.99996 5.54251 7.79083 7.33337 9.99996 7.33337V8.66671C7.79083 8.66671 5.99996 10.4576 5.99996 12.6667H4.66663C4.66663 10.4576 2.87577 8.66671 0.666626 8.66671V7.33337ZM11.5 9.33337C11.5 10.53 10.5299 11.5 9.33329 11.5V12.5C10.5299 12.5 11.5 13.4701 11.5 14.6667H12.5C12.5 13.4701 13.47 12.5 14.6666 12.5V11.5C13.47 11.5 12.5 10.53 12.5 9.33337H11.5Z" fill="url(#paint0_linear_13059_18704)" fillOpacity="0.95" />
<defs>
<linearGradient id="paint0_linear_13059_18704" x1="14.9996" y1="15.0001" x2="-2.55847" y2="16.6209" gradientUnits="userSpaceOnUse">
<stop stopColor="#0BA5EC" />
<stop offset="1" stopColor="#155AEF" />
</linearGradient>
</defs>
</svg>
)
}
export default SchemaGeneratorLight

View File

@ -0,0 +1,121 @@
import React, { type FC, useCallback, useMemo, useState } from 'react'
import type { SchemaRoot } from '../../../types'
import { RiArrowLeftLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CodeEditor from '../code-editor'
import ErrorMessage from '../error-message'
import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils'
import Loading from '@/app/components/base/loading'
type GeneratedResultProps = {
schema: SchemaRoot
isGenerating: boolean
onBack: () => void
onRegenerate: () => void
onClose: () => void
onApply: () => void
}
const GeneratedResult: FC<GeneratedResultProps> = ({
schema,
isGenerating,
onBack,
onRegenerate,
onClose,
onApply,
}) => {
const { t } = useTranslation()
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const formatJSON = (json: SchemaRoot) => {
try {
const schema = JSON.stringify(json, null, 2)
setParseError(null)
return schema
}
catch (e) {
if (e instanceof Error)
setParseError(e)
else
setParseError(new Error('Invalid JSON'))
return ''
}
}
const jsonSchema = useMemo(() => formatJSON(schema), [schema])
const handleApply = useCallback(() => {
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
}
onApply()
setValidationError('')
}, [schema, onApply])
return (
<div className='flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
{
isGenerating ? (
<div className='flex h-[600px] flex-col items-center justify-center gap-y-3'>
<Loading type='area' />
<div className='system-xs-regular text-text-tertiary'>{t('workflow.nodes.llm.jsonSchema.generating')}</div>
</div>
) : (
<>
<div className='absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
{/* Title */}
<div className='flex flex-col gap-y-[0.5px] px-3 pb-1 pt-3.5'>
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
{t('workflow.nodes.llm.jsonSchema.generatedResult')}
</div>
<div className='system-xs-regular flex px-1 text-text-tertiary'>
{t('workflow.nodes.llm.jsonSchema.resultTip')}
</div>
</div>
{/* Content */}
<div className='px-4 py-2'>
<CodeEditor
className='rounded-lg'
editorWrapperClassName='h-[424px]'
value={jsonSchema}
readOnly
showFormatButton={false}
/>
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</div>
{/* Footer */}
<div className='flex items-center justify-between p-4 pt-2'>
<Button variant='secondary' className='flex items-center gap-x-0.5' onClick={onBack}>
<RiArrowLeftLine className='h-4 w-4' />
<span>{t('workflow.nodes.llm.jsonSchema.back')}</span>
</Button>
<div className='flex items-center gap-x-2'>
<Button
variant='secondary'
className='flex items-center gap-x-0.5'
onClick={onRegenerate}
>
<RiSparklingLine className='h-4 w-4' />
<span>{t('workflow.nodes.llm.jsonSchema.regenerate')}</span>
</Button>
<Button variant='primary' onClick={handleApply}>
{t('workflow.nodes.llm.jsonSchema.apply')}
</Button>
</div>
</div>
</>
)
}
</div>
)
}
export default React.memo(GeneratedResult)

View File

@ -0,0 +1,183 @@
import React, { type FC, useCallback, useEffect, useState } from 'react'
import type { SchemaRoot } from '../../../types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import useTheme from '@/hooks/use-theme'
import type { CompletionParams, Model } from '@/types/app'
import { ModelModeType } from '@/types/app'
import { Theme } from '@/types/app'
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
import cn from '@/utils/classnames'
import type { ModelInfo } from './prompt-editor'
import PromptEditor from './prompt-editor'
import GeneratedResult from './generated-result'
import { useGenerateStructuredOutputRules } from '@/service/use-common'
import Toast from '@/app/components/base/toast'
import { type FormValue, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useVisualEditorStore } from '../visual-editor/store'
import { useTranslation } from 'react-i18next'
import { useMittContext } from '../visual-editor/context'
type JsonSchemaGeneratorProps = {
onApply: (schema: SchemaRoot) => void
crossAxisOffset?: number
}
enum GeneratorView {
promptEditor = 'promptEditor',
result = 'result',
}
export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
onApply,
crossAxisOffset,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [view, setView] = useState(GeneratorView.promptEditor)
const [model, setModel] = useState<Model>({
name: '',
provider: '',
mode: ModelModeType.completion,
completion_params: {} as CompletionParams,
})
const [instruction, setInstruction] = useState('')
const [schema, setSchema] = useState<SchemaRoot | null>(null)
const { theme } = useTheme()
const {
defaultModel,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const { emit } = useMittContext()
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
useEffect(() => {
if (defaultModel) {
setModel(prev => ({
...prev,
name: defaultModel.model,
provider: defaultModel.provider.provider,
}))
}
}, [defaultModel])
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
if (advancedEditing || isAddingNewField)
emit('quitEditing', {})
setOpen(!open)
}, [open, advancedEditing, isAddingNewField, emit])
const onClose = useCallback(() => {
setOpen(false)
}, [])
const handleModelChange = useCallback((model: ModelInfo) => {
setModel(prev => ({
...prev,
provider: model.provider,
name: model.modelId,
mode: model.mode as ModelModeType,
}))
}, [])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
setModel(prev => ({
...prev,
completion_params: newParams as CompletionParams,
}),
)
}, [])
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
const generateSchema = useCallback(async () => {
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
if (error) {
Toast.notify({
type: 'error',
message: error,
})
setSchema(null)
setView(GeneratorView.promptEditor)
return
}
return output
}, [instruction, model, generateStructuredOutputRules])
const handleGenerate = useCallback(async () => {
setView(GeneratorView.result)
const output = await generateSchema()
if (output === undefined) return
setSchema(JSON.parse(output))
}, [generateSchema])
const goBackToPromptEditor = () => {
setView(GeneratorView.promptEditor)
}
const handleRegenerate = useCallback(async () => {
const output = await generateSchema()
if (output === undefined) return
setSchema(JSON.parse(output))
}, [generateSchema])
const handleApply = () => {
onApply(schema!)
setOpen(false)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset ?? 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<button
type='button'
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
open && 'bg-state-accent-active',
)}
>
<SchemaGenerator />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
{view === GeneratorView.promptEditor && (
<PromptEditor
instruction={instruction}
model={model}
onInstructionChange={setInstruction}
onCompletionParamsChange={handleCompletionParamsChange}
onGenerate={handleGenerate}
onClose={onClose}
onModelChange={handleModelChange}
/>
)}
{view === GeneratorView.result && (
<GeneratedResult
schema={schema!}
isGenerating={isGenerating}
onBack={goBackToPromptEditor}
onRegenerate={handleRegenerate}
onApply={handleApply}
onClose={onClose}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default JsonSchemaGenerator

View File

@ -0,0 +1,108 @@
import React, { useCallback } from 'react'
import type { FC } from 'react'
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { Model } from '@/types/app'
export type ModelInfo = {
modelId: string
provider: string
mode?: string
features?: string[]
}
type PromptEditorProps = {
instruction: string
model: Model
onInstructionChange: (instruction: string) => void
onCompletionParamsChange: (newParams: FormValue) => void
onModelChange: (model: ModelInfo) => void
onClose: () => void
onGenerate: () => void
}
const PromptEditor: FC<PromptEditorProps> = ({
instruction,
model,
onInstructionChange,
onCompletionParamsChange,
onClose,
onGenerate,
onModelChange,
}) => {
const { t } = useTranslation()
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onInstructionChange(e.target.value)
}, [onInstructionChange])
return (
<div className='relative flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
<div className='absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
<RiCloseLine className='h-4 w-4 text-text-tertiary'/>
</div>
{/* Title */}
<div className='flex flex-col gap-y-[0.5px] px-3 pb-1 pt-3.5'>
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
{t('workflow.nodes.llm.jsonSchema.generateJsonSchema')}
</div>
<div className='system-xs-regular flex px-1 text-text-tertiary'>
{t('workflow.nodes.llm.jsonSchema.generationTip')}
</div>
</div>
{/* Content */}
<div className='flex flex-col gap-y-1 px-4 py-2'>
<div className='system-sm-semibold-uppercase flex h-6 items-center text-text-secondary'>
{t('common.modelProvider.model')}
</div>
<ModelParameterModal
popupClassName='!w-[448px]'
portalToFollowElemContentClassName='z-[1000]'
isAdvancedMode={true}
provider={model.provider}
mode={model.mode}
completionParams={model.completion_params}
modelId={model.name}
setModel={onModelChange}
onCompletionParamsChange={onCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div className='flex flex-col gap-y-1 px-4 py-2'>
<div className='system-sm-semibold-uppercase flex h-6 items-center text-text-secondary'>
<span>{t('workflow.nodes.llm.jsonSchema.instruction')}</span>
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.promptTooltip')} />
</div>
<div className='flex items-center'>
<Textarea
className='h-[364px] resize-none px-2 py-1'
value={instruction}
placeholder={t('workflow.nodes.llm.jsonSchema.promptPlaceholder')}
onChange={handleInstructionChange}
/>
</div>
</div>
{/* Footer */}
<div className='flex justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onClose}>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
className='flex items-center gap-x-0.5'
onClick={onGenerate}
>
<RiSparklingFill className='h-4 w-4' />
<span>{t('workflow.nodes.llm.jsonSchema.generate')}</span>
</Button>
</div>
</div>
)
}
export default React.memo(PromptEditor)

View File

@ -0,0 +1,23 @@
import React, { type FC } from 'react'
import CodeEditor from './code-editor'
type SchemaEditorProps = {
schema: string
onUpdate: (schema: string) => void
}
const SchemaEditor: FC<SchemaEditorProps> = ({
schema,
onUpdate,
}) => {
return (
<CodeEditor
className='rounded-xl'
editorWrapperClassName='grow'
value={schema}
onUpdate={onUpdate}
/>
)
}
export default SchemaEditor

View File

@ -0,0 +1,33 @@
import React, { useCallback } from 'react'
import Button from '@/app/components/base/button'
import { RiAddCircleFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useVisualEditorStore } from './store'
import { useMittContext } from './context'
const AddField = () => {
const { t } = useTranslation()
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const { emit } = useMittContext()
const handleAddField = useCallback(() => {
setIsAddingNewField(true)
emit('addField', { path: [] })
}, [setIsAddingNewField, emit])
return (
<div className='py-2 pl-5'>
<Button
size='small'
variant='secondary-accent'
className='flex items-center gap-x-[1px]'
onClick={handleAddField}
>
<RiAddCircleFill className='h-3.5 w-3.5'/>
<span className='px-[3px]'>{t('workflow.nodes.llm.jsonSchema.addField')}</span>
</Button>
</div>
)
}
export default React.memo(AddField)

View File

@ -0,0 +1,46 @@
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
type CardProps = {
name: string
type: string
required: boolean
description?: string
}
const Card: FC<CardProps> = ({
name,
type,
required,
description,
}) => {
const { t } = useTranslation()
return (
<div className='flex flex-col py-0.5'>
<div className='flex h-6 items-center gap-x-1 pl-1 pr-0.5'>
<div className='system-sm-semibold truncate border border-transparent px-1 py-px text-text-primary'>
{name}
</div>
<div className='system-xs-medium px-1 py-0.5 text-text-tertiary'>
{type}
</div>
{
required && (
<div className='system-2xs-medium-uppercase px-1 py-0.5 text-text-warning'>
{t('workflow.nodes.llm.jsonSchema.required')}
</div>
)
}
</div>
{description && (
<div className='system-xs-regular truncate px-2 pb-1 text-text-tertiary'>
{description}
</div>
)}
</div>
)
}
export default React.memo(Card)

View File

@ -0,0 +1,50 @@
import {
createContext,
useContext,
useRef,
} from 'react'
import { createVisualEditorStore } from './store'
import { useMitt } from '@/hooks/use-mitt'
import { noop } from 'lodash-es'
type VisualEditorStore = ReturnType<typeof createVisualEditorStore>
type VisualEditorContextType = VisualEditorStore | null
type VisualEditorProviderProps = {
children: React.ReactNode
}
export const VisualEditorContext = createContext<VisualEditorContextType>(null)
export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => {
const storeRef = useRef<VisualEditorStore>()
if (!storeRef.current)
storeRef.current = createVisualEditorStore()
return (
<VisualEditorContext.Provider value={storeRef.current}>
{children}
</VisualEditorContext.Provider>
)
}
export const MittContext = createContext<ReturnType<typeof useMitt>>({
emit: noop,
useSubscribe: noop,
})
export const MittProvider = ({ children }: { children: React.ReactNode }) => {
const mitt = useMitt()
return (
<MittContext.Provider value={mitt}>
{children}
</MittContext.Provider>
)
}
export const useMittContext = () => {
return useContext(MittContext)
}

View File

@ -0,0 +1,56 @@
import type { FC } from 'react'
import React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { RiAddCircleLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
type ActionsProps = {
disableAddBtn: boolean
onAddChildField: () => void
onEdit: () => void
onDelete: () => void
}
const Actions: FC<ActionsProps> = ({
disableAddBtn,
onAddChildField,
onEdit,
onDelete,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-0.5'>
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.addChildField')}>
<button
type='button'
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled'
onClick={onAddChildField}
disabled={disableAddBtn}
>
<RiAddCircleLine className='h-4 w-4'/>
</button>
</Tooltip>
<Tooltip popupContent={t('common.operation.edit')}>
<button
type='button'
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
onClick={onEdit}
>
<RiEditLine className='h-4 w-4' />
</button>
</Tooltip>
<Tooltip popupContent={t('common.operation.remove')}>
<button
type='button'
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
onClick={onDelete}
>
<RiDeleteBinLine className='h-4 w-4' />
</button>
</Tooltip>
</div>
)
}
export default React.memo(Actions)

View File

@ -0,0 +1,59 @@
import React, { type FC } from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import { useKeyPress } from 'ahooks'
type AdvancedActionsProps = {
isConfirmDisabled: boolean
onCancel: () => void
onConfirm: () => void
}
const Key = (props: { keyName: string }) => {
const { keyName } = props
return (
<kbd className='system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-white px-px text-text-primary-on-surface'>
{keyName}
</kbd>
)
}
const AdvancedActions: FC<AdvancedActionsProps> = ({
isConfirmDisabled,
onCancel,
onConfirm,
}) => {
const { t } = useTranslation()
useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
e.preventDefault()
onConfirm()
}, {
exactMatch: true,
useCapture: true,
})
return (
<div className='flex items-center gap-x-1'>
<Button size='small' variant='secondary' onClick={onCancel}>
{t('common.operation.cancel')}
</Button>
<Button
className='flex items-center gap-x-1'
disabled={isConfirmDisabled}
size='small'
variant='primary'
onClick={onConfirm}
>
<span>{t('common.operation.confirm')}</span>
<div className='flex items-center gap-x-0.5'>
<Key keyName={getKeyboardKeyNameBySystem('ctrl')} />
<Key keyName='⏎' />
</div>
</Button>
</div>
)
}
export default React.memo(AdvancedActions)

View File

@ -0,0 +1,77 @@
import React, { type FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Textarea from '@/app/components/base/textarea'
export type AdvancedOptionsType = {
enum: string
}
type AdvancedOptionsProps = {
options: AdvancedOptionsType
onChange: (options: AdvancedOptionsType) => void
}
const AdvancedOptions: FC<AdvancedOptionsProps> = ({
onChange,
options,
}) => {
const { t } = useTranslation()
// const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [enumValue, setEnumValue] = useState(options.enum)
const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEnumValue(e.target.value)
}, [])
const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
onChange({ enum: e.target.value })
}, [onChange])
// const handleToggleAdvancedOptions = useCallback(() => {
// setShowAdvancedOptions(prev => !prev)
// }, [])
return (
<div className='border-t border-divider-subtle'>
{/* {showAdvancedOptions ? ( */}
<div className='flex flex-col gap-y-1 px-2 py-1.5'>
<div className='flex w-full items-center gap-x-2'>
<span className='system-2xs-medium-uppercase text-text-tertiary'>
{t('workflow.nodes.llm.jsonSchema.stringValidations')}
</span>
<div className='grow'>
<Divider type='horizontal' className='my-0 h-px bg-line-divider-bg' />
</div>
</div>
<div className='flex flex-col'>
<div className='system-xs-medium flex h-6 items-center text-text-secondary'>
Enum
</div>
<Textarea
size='small'
className='min-h-6'
value={enumValue}
onChange={handleEnumChange}
onBlur={handleEnumBlur}
placeholder={'abcd, 1, 1.5, etc.'}
/>
</div>
</div>
{/* ) : (
<button
type='button'
className='flex items-center gap-x-0.5 pb-1 pl-1.5 pr-2 pt-2'
onClick={handleToggleAdvancedOptions}
>
<RiArrowDownDoubleLine className='h-3 w-3 text-text-tertiary' />
<span className='system-xs-regular text-text-tertiary'>
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
</span>
</button>
)} */}
</div>
)
}
export default React.memo(AdvancedOptions)

View File

@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react'
import type { FC } from 'react'
import cn from '@/utils/classnames'
type AutoWidthInputProps = {
value: string
placeholder: string
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
onBlur: () => void
minWidth?: number
maxWidth?: number
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>
const AutoWidthInput: FC<AutoWidthInputProps> = ({
value,
placeholder,
onChange,
onBlur,
minWidth = 60,
maxWidth = 300,
className,
...props
}) => {
const [width, setWidth] = useState(minWidth)
const textRef = React.useRef<HTMLSpanElement>(null)
useEffect(() => {
if (textRef.current) {
textRef.current.textContent = value || placeholder
const textWidth = textRef.current.offsetWidth
const newWidth = Math.max(minWidth, Math.min(textWidth + 16, maxWidth))
if (width !== newWidth)
setWidth(newWidth)
}
}, [value, placeholder, minWidth, maxWidth, width])
// Handle Enter key
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && e.currentTarget.blur)
e.currentTarget.blur()
if (props.onKeyUp)
props.onKeyUp(e)
}
return (
<div className='relative inline-flex items-center'>
{/* Hidden measurement span */}
<span
ref={textRef}
className='system-sm-semibold invisible absolute left-0 top-0 -z-10 whitespace-pre px-1'
aria-hidden="true"
>
{value || placeholder}
</span>
{/* Actual input element */}
<input
value={value}
className={cn(
'system-sm-semibold placeholder:system-sm-semibold h-5 rounded-[5px] border border-transparent px-1',
'py-px text-text-primary caret-[#295EFF] shadow-shadow-shadow-3 outline-none',
'placeholder:text-text-placeholder hover:bg-state-base-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
className,
)}
style={{
width: `${width}px`,
minWidth: `${minWidth}px`,
maxWidth: `${maxWidth}px`,
transition: 'width 100ms ease-out',
}}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
onKeyUp={handleKeyUp}
{...props}
/>
</div>
)
}
export default React.memo(AutoWidthInput)

View File

@ -0,0 +1,277 @@
import React, { type FC, useCallback, useMemo, useRef, useState } from 'react'
import type { SchemaEnumType } from '../../../../types'
import { ArrayType, Type } from '../../../../types'
import type { TypeItem } from './type-selector'
import TypeSelector from './type-selector'
import RequiredSwitch from './required-switch'
import Divider from '@/app/components/base/divider'
import Actions from './actions'
import AdvancedActions from './advanced-actions'
import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames'
import { useVisualEditorStore } from '../store'
import { useMittContext } from '../context'
import { useUnmount } from 'ahooks'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import AutoWidthInput from './auto-width-input'
export type EditData = {
name: string
type: Type | ArrayType
required: boolean
description?: string
enum?: SchemaEnumType
}
type Options = {
description?: string
enum?: SchemaEnumType
}
type EditCardProps = {
fields: EditData
depth: number
path: string[]
parentPath: string[]
}
const TYPE_OPTIONS = [
{ value: Type.string, text: 'string' },
{ value: Type.number, text: 'number' },
// { value: Type.boolean, text: 'boolean' },
{ value: Type.object, text: 'object' },
{ value: ArrayType.string, text: 'array[string]' },
{ value: ArrayType.number, text: 'array[number]' },
// { value: ArrayType.boolean, text: 'array[boolean]' },
{ value: ArrayType.object, text: 'array[object]' },
]
const MAXIMUM_DEPTH_TYPE_OPTIONS = [
{ value: Type.string, text: 'string' },
{ value: Type.number, text: 'number' },
// { value: Type.boolean, text: 'boolean' },
{ value: ArrayType.string, text: 'array[string]' },
{ value: ArrayType.number, text: 'array[number]' },
// { value: ArrayType.boolean, text: 'array[boolean]' },
]
const EditCard: FC<EditCardProps> = ({
fields,
depth,
path,
parentPath,
}) => {
const { t } = useTranslation()
const [currentFields, setCurrentFields] = useState(fields)
const [backupFields, setBackupFields] = useState<EditData | null>(null)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
const { emit, useSubscribe } = useMittContext()
const blurWithActions = useRef(false)
const maximumDepthReached = depth === JSON_SCHEMA_MAX_DEPTH
const disableAddBtn = maximumDepthReached || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
const isAdvancedEditing = advancedEditing || isAddingNewField
const advancedOptions = useMemo(() => {
let enumValue = ''
if (currentFields.type === Type.string || currentFields.type === Type.number)
enumValue = (currentFields.enum || []).join(', ')
return { enum: enumValue }
}, [currentFields.type, currentFields.enum])
useSubscribe('restorePropertyName', () => {
setCurrentFields(prev => ({ ...prev, name: fields.name }))
})
useSubscribe('fieldChangeSuccess', () => {
isAddingNewField && setIsAddingNewField(false)
advancedEditing && setAdvancedEditing(false)
})
const emitPropertyNameChange = useCallback(() => {
emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields })
}, [fields, currentFields, path, parentPath, emit])
const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => {
emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, type } })
}, [fields, currentFields, path, parentPath, emit])
const emitPropertyRequiredToggle = useCallback(() => {
emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields })
}, [emit, path, parentPath, fields, currentFields])
const emitPropertyOptionsChange = useCallback((options: Options) => {
emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } })
}, [emit, path, parentPath, fields, currentFields])
const emitPropertyDelete = useCallback(() => {
emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields })
}, [emit, path, parentPath, fields, currentFields])
const emitPropertyAdd = useCallback(() => {
emit('addField', { path })
}, [emit, path])
const emitFieldChange = useCallback(() => {
emit('fieldChange', { path, parentPath, oldFields: fields, fields: currentFields })
}, [emit, path, parentPath, fields, currentFields])
const handlePropertyNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentFields(prev => ({ ...prev, name: e.target.value }))
}, [])
const handlePropertyNameBlur = useCallback(() => {
if (isAdvancedEditing) return
emitPropertyNameChange()
}, [isAdvancedEditing, emitPropertyNameChange])
const handleTypeChange = useCallback((item: TypeItem) => {
setCurrentFields(prev => ({ ...prev, type: item.value }))
if (isAdvancedEditing) return
emitPropertyTypeChange(item.value)
}, [isAdvancedEditing, emitPropertyTypeChange])
const toggleRequired = useCallback(() => {
setCurrentFields(prev => ({ ...prev, required: !prev.required }))
if (isAdvancedEditing) return
emitPropertyRequiredToggle()
}, [isAdvancedEditing, emitPropertyRequiredToggle])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentFields(prev => ({ ...prev, description: e.target.value }))
}, [])
const handleDescriptionBlur = useCallback(() => {
if (isAdvancedEditing) return
emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum })
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
let enumValue: any = options.enum
if (enumValue === '') {
enumValue = undefined
}
else {
enumValue = options.enum.replace(/\s/g, '').split(',')
if (currentFields.type === Type.number)
enumValue = (enumValue as SchemaEnumType).map(value => Number(value)).filter(num => !Number.isNaN(num))
}
setCurrentFields(prev => ({ ...prev, enum: enumValue }))
if (isAdvancedEditing) return
emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue })
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
const handleDelete = useCallback(() => {
blurWithActions.current = true
emitPropertyDelete()
}, [emitPropertyDelete])
const handleAdvancedEdit = useCallback(() => {
setBackupFields({ ...currentFields })
setAdvancedEditing(true)
}, [currentFields, setAdvancedEditing])
const handleAddChildField = useCallback(() => {
blurWithActions.current = true
emitPropertyAdd()
}, [emitPropertyAdd])
const handleConfirm = useCallback(() => {
emitFieldChange()
}, [emitFieldChange])
const handleCancel = useCallback(() => {
if (isAddingNewField) {
blurWithActions.current = true
emit('restoreSchema')
setIsAddingNewField(false)
return
}
if (backupFields) {
setCurrentFields(backupFields)
setBackupFields(null)
}
setAdvancedEditing(false)
}, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields])
useUnmount(() => {
if (isAdvancedEditing || blurWithActions.current) return
emitFieldChange()
})
return (
<div className='flex flex-col rounded-lg bg-components-panel-bg py-0.5 shadow-sm shadow-shadow-shadow-4'>
<div className='flex h-6 items-center pl-1 pr-0.5'>
<div className='flex grow items-center gap-x-1'>
<AutoWidthInput
value={currentFields.name}
placeholder={t('workflow.nodes.llm.jsonSchema.fieldNamePlaceholder')}
minWidth={80}
maxWidth={300}
onChange={handlePropertyNameChange}
onBlur={handlePropertyNameBlur}
/>
<TypeSelector
currentValue={currentFields.type}
items={maximumDepthReached ? MAXIMUM_DEPTH_TYPE_OPTIONS : TYPE_OPTIONS}
onSelect={handleTypeChange}
popupClassName={'z-[1000]'}
/>
{
currentFields.required && (
<div className='system-2xs-medium-uppercase px-1 py-0.5 text-text-warning'>
{t('workflow.nodes.llm.jsonSchema.required')}
</div>
)
}
</div>
<RequiredSwitch
defaultValue={currentFields.required}
toggleRequired={toggleRequired}
/>
<Divider type='vertical' className='h-3' />
{isAdvancedEditing ? (
<AdvancedActions
isConfirmDisabled={currentFields.name === ''}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
) : (
<Actions
disableAddBtn={disableAddBtn}
onAddChildField={handleAddChildField}
onDelete={handleDelete}
onEdit={handleAdvancedEdit}
/>
)}
</div>
{(fields.description || isAdvancedEditing) && (
<div className={classNames('flex', isAdvancedEditing ? 'p-2 pt-1' : 'px-2 pb-1')}>
<input
value={currentFields.description}
className='system-xs-regular placeholder:system-xs-regular h-4 w-full p-0 text-text-tertiary caret-[#295EFF] outline-none placeholder:text-text-placeholder'
placeholder={t('workflow.nodes.llm.jsonSchema.descriptionPlaceholder')}
onChange={handleDescriptionChange}
onBlur={handleDescriptionBlur}
onKeyUp={e => e.key === 'Enter' && e.currentTarget.blur()}
/>
</div>
)}
{isAdvancedEditing && hasAdvancedOptions && (
<AdvancedOptions
options={advancedOptions}
onChange={handleAdvancedOptionsChange}
/>
)}
</div>
)
}
export default EditCard

View File

@ -0,0 +1,25 @@
import React from 'react'
import type { FC } from 'react'
import Switch from '@/app/components/base/switch'
import { useTranslation } from 'react-i18next'
type RequiredSwitchProps = {
defaultValue: boolean
toggleRequired: () => void
}
const RequiredSwitch: FC<RequiredSwitchProps> = ({
defaultValue,
toggleRequired,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1 rounded-[5px] border border-divider-subtle bg-background-default-lighter px-1.5 py-1'>
<span className='system-2xs-medium-uppercase text-text-secondary'>{t('workflow.nodes.llm.jsonSchema.required')}</span>
<Switch size='xs' defaultValue={defaultValue} onChange={toggleRequired} />
</div>
)
}
export default React.memo(RequiredSwitch)

View File

@ -0,0 +1,69 @@
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import type { ArrayType, Type } from '../../../../types'
import type { FC } from 'react'
import { useState } from 'react'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
export type TypeItem = {
value: Type | ArrayType
text: string
}
type TypeSelectorProps = {
items: TypeItem[]
currentValue: Type | ArrayType
onSelect: (item: TypeItem) => void
popupClassName?: string
}
const TypeSelector: FC<TypeSelectorProps> = ({
items,
currentValue,
onSelect,
popupClassName,
}) => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn(
'flex items-center rounded-[5px] p-0.5 pl-1 hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}>
<span className='system-xs-medium text-text-tertiary'>{currentValue}</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupClassName}>
<div className='w-40 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'>
{items.map((item) => {
const isSelected = item.value === currentValue
return (<div
key={item.value}
className={'flex items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover'}
onClick={() => {
onSelect(item)
setOpen(false)
}}
>
<span className='system-sm-medium px-1 text-text-secondary'>{item.text}</span>
{isSelected && <RiCheckLine className='h-4 w-4 text-text-accent' />}
</div>
)
})}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default TypeSelector

View File

@ -0,0 +1,441 @@
import produce from 'immer'
import type { VisualEditorProps } from '.'
import { useMittContext } from './context'
import { useVisualEditorStore } from './store'
import type { EditData } from './edit-card'
import { ArrayType, type Field, Type } from '../../../types'
import Toast from '@/app/components/base/toast'
import { findPropertyWithPath } from '../../../utils'
type ChangeEventParams = {
path: string[],
parentPath: string[],
oldFields: EditData,
fields: EditData,
}
type AddEventParams = {
path: string[]
}
export const useSchemaNodeOperations = (props: VisualEditorProps) => {
const { schema: jsonSchema, onChange } = props
const backupSchema = useVisualEditorStore(state => state.backupSchema)
const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const { emit, useSubscribe } = useMittContext()
useSubscribe('restoreSchema', () => {
if (backupSchema) {
onChange(backupSchema)
setBackupSchema(null)
}
})
useSubscribe('quitEditing', (params) => {
const { callback } = params as any
callback?.(backupSchema)
if (backupSchema) {
onChange(backupSchema)
setBackupSchema(null)
}
isAddingNewField && setIsAddingNewField(false)
advancedEditing && setAdvancedEditing(false)
setHoveringProperty(null)
})
useSubscribe('propertyNameChange', (params) => {
const { parentPath, oldFields, fields } = params as ChangeEventParams
const { name: oldName } = oldFields
const { name: newName } = fields
const newSchema = produce(jsonSchema, (draft) => {
if (oldName === newName) return
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object) {
const properties = schema.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
emit('restorePropertyName')
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
schema.properties = newProperties
schema.required = newRequired
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const properties = schema.items.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
emit('restorePropertyName')
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
schema.items.properties = newProperties
schema.items.required = newRequired
}
})
onChange(newSchema)
})
useSubscribe('propertyTypeChange', (params) => {
const { path, oldFields, fields } = params as ChangeEventParams
const { type: oldType } = oldFields
const { type: newType } = fields
if (oldType === newType) return
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
schema.additionalProperties = false
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
// case ArrayType.boolean:
// schema.type = Type.array
// schema.items = {
// type: Type.boolean,
// }
// break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}
break
default:
schema.type = newType as Type
}
})
onChange(newSchema)
})
useSubscribe('propertyRequiredToggle', (params) => {
const { parentPath, fields } = params as ChangeEventParams
const { name } = fields
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object) {
const required = schema.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
schema.required = newRequired
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const required = schema.items.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
schema.items.required = newRequired
}
})
onChange(newSchema)
})
useSubscribe('propertyOptionsChange', (params) => {
const { path, fields } = params as ChangeEventParams
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
schema.description = fields.description
schema.enum = fields.enum
})
onChange(newSchema)
})
useSubscribe('propertyDelete', (params) => {
const { parentPath, fields } = params as ChangeEventParams
const { name } = fields
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object && schema.properties) {
delete schema.properties[name]
schema.required = schema.required?.filter(item => item !== name)
}
if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) {
delete schema.items.properties[name]
schema.items.required = schema.items.required?.filter(item => item !== name)
}
})
onChange(newSchema)
})
useSubscribe('addField', (params) => {
advancedEditing && setAdvancedEditing(false)
setBackupSchema(jsonSchema)
const { path } = params as AddEventParams
setIsAddingNewField(true)
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
if (schema.type === Type.object) {
schema.properties = {
...(schema.properties || {}),
'': {
type: Type.string,
},
}
setHoveringProperty([...path, 'properties', ''].join('.'))
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
schema.items.properties = {
...(schema.items.properties || {}),
'': {
type: Type.string,
},
}
setHoveringProperty([...path, 'items', 'properties', ''].join('.'))
}
})
onChange(newSchema)
})
useSubscribe('fieldChange', (params) => {
let samePropertyNameError = false
const { parentPath, oldFields, fields } = params as ChangeEventParams
const newSchema = produce(jsonSchema, (draft) => {
const parentSchema = findPropertyWithPath(draft, parentPath) as Field
const { name: oldName, type: oldType, required: oldRequired } = oldFields
const { name: newName, type: newType, required: newRequired } = fields
if (parentSchema.type === Type.object && parentSchema.properties) {
// name change
if (oldName !== newName) {
const properties = parentSchema.properties
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
samePropertyNameError = true
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const requiredProperties = parentSchema.required || []
const newRequiredProperties = produce(requiredProperties, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
parentSchema.properties = newProperties
parentSchema.required = newRequiredProperties
}
// required change
if (oldRequired !== newRequired) {
const required = parentSchema.required || []
const newRequired = required.includes(newName)
? required.filter(item => item !== newName)
: [...required, newName]
parentSchema.required = newRequired
}
const schema = parentSchema.properties[newName]
// type change
if (oldType !== newType) {
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
schema.additionalProperties = false
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
// case ArrayType.boolean:
// schema.type = Type.array
// schema.items = {
// type: Type.boolean,
// }
// break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}
break
default:
schema.type = newType as Type
}
}
// other options change
schema.description = fields.description
schema.enum = fields.enum
}
if (parentSchema.type === Type.array && parentSchema.items && parentSchema.items.type === Type.object && parentSchema.items.properties) {
// name change
if (oldName !== newName) {
const properties = parentSchema.items.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
samePropertyNameError = true
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = parentSchema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
parentSchema.items.properties = newProperties
parentSchema.items.required = newRequired
}
// required change
if (oldRequired !== newRequired) {
const required = parentSchema.items.required || []
const newRequired = required.includes(newName)
? required.filter(item => item !== newName)
: [...required, newName]
parentSchema.items.required = newRequired
}
const schema = parentSchema.items.properties[newName]
// type change
if (oldType !== newType) {
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
schema.additionalProperties = false
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
// case ArrayType.boolean:
// schema.type = Type.array
// schema.items = {
// type: Type.boolean,
// }
// break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}
break
default:
schema.type = newType as Type
}
}
// other options change
schema.description = fields.description
schema.enum = fields.enum
}
})
if (samePropertyNameError) return
onChange(newSchema)
emit('fieldChangeSuccess')
})
}

View File

@ -0,0 +1,28 @@
import type { FC } from 'react'
import type { SchemaRoot } from '../../../types'
import SchemaNode from './schema-node'
import { useSchemaNodeOperations } from './hooks'
export type VisualEditorProps = {
schema: SchemaRoot
onChange: (schema: SchemaRoot) => void
}
const VisualEditor: FC<VisualEditorProps> = (props) => {
const { schema } = props
useSchemaNodeOperations(props)
return (
<div className='h-full overflow-auto rounded-xl bg-background-section-burn p-1 pl-2'>
<SchemaNode
name='structured_output'
schema={schema}
required={false}
path={[]}
depth={0}
/>
</div>
)
}
export default VisualEditor

View File

@ -0,0 +1,194 @@
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { type Field, Type } from '../../../types'
import classNames from '@/utils/classnames'
import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react'
import { getFieldType, getHasChildren } from '../../../utils'
import Divider from '@/app/components/base/divider'
import EditCard from './edit-card'
import Card from './card'
import { useVisualEditorStore } from './store'
import { useDebounceFn } from 'ahooks'
import AddField from './add-field'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
type SchemaNodeProps = {
name: string
required: boolean
schema: Field
path: string[]
parentPath?: string[]
depth: number
}
// Support 10 levels of indentation
const indentPadding: Record<number, string> = {
0: 'pl-0',
1: 'pl-[20px]',
2: 'pl-[40px]',
3: 'pl-[60px]',
4: 'pl-[80px]',
5: 'pl-[100px]',
6: 'pl-[120px]',
7: 'pl-[140px]',
8: 'pl-[160px]',
9: 'pl-[180px]',
10: 'pl-[200px]',
}
const indentLeft: Record<number, string> = {
0: 'left-0',
1: 'left-[20px]',
2: 'left-[40px]',
3: 'left-[60px]',
4: 'left-[80px]',
5: 'left-[100px]',
6: 'left-[120px]',
7: 'left-[140px]',
8: 'left-[160px]',
9: 'left-[180px]',
10: 'left-[200px]',
}
const SchemaNode: FC<SchemaNodeProps> = ({
name,
required,
schema,
path,
parentPath,
depth,
}) => {
const [isExpanded, setIsExpanded] = useState(true)
const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string | null) => {
setHoveringProperty(path)
}, { wait: 50 })
const hasChildren = useMemo(() => getHasChildren(schema), [schema])
const type = useMemo(() => getFieldType(schema), [schema])
const isHovering = hoveringProperty === path.join('.')
const handleExpand = () => {
setIsExpanded(!isExpanded)
}
const handleMouseEnter = () => {
if (advancedEditing || isAddingNewField) return
setHoveringPropertyDebounced(path.join('.'))
}
const handleMouseLeave = () => {
if (advancedEditing || isAddingNewField) return
setHoveringPropertyDebounced(null)
}
return (
<div className='relative'>
<div className={classNames('relative z-10', indentPadding[depth])}>
{depth > 0 && hasChildren && (
<div className={classNames(
'flex items-center absolute top-0 w-5 h-7 px-0.5 z-10 bg-background-section-burn',
indentLeft[depth - 1],
)}>
<button
onClick={handleExpand}
className='py-0.5 text-text-tertiary hover:text-text-accent'
>
{
isExpanded
? <RiArrowDropDownLine className='h-4 w-4' />
: <RiArrowDropRightLine className='h-4 w-4' />
}
</button>
</div>
)}
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{(isHovering && depth > 0) ? (
<EditCard
fields={{
name,
type,
required,
description: schema.description,
enum: schema.enum,
}}
path={path}
parentPath={parentPath!}
depth={depth}
/>
) : (
<Card
name={name}
type={type}
required={required}
description={schema.description}
/>
)}
</div>
</div>
<div className={classNames(
'flex justify-center w-5 absolute z-0',
schema.description ? 'h-[calc(100%-3rem)] top-12' : 'h-[calc(100%-1.75rem)] top-7',
indentLeft[depth],
)}>
<Divider
type='vertical'
className={classNames('mx-0', isHovering ? 'bg-divider-deep' : 'bg-divider-subtle')}
/>
</div>
{isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && (
<>
{schema.type === Type.object && schema.properties && (
Object.entries(schema.properties).map(([key, childSchema]) => (
<SchemaNode
key={key}
name={key}
required={!!schema.required?.includes(key)}
schema={childSchema}
path={[...path, 'properties', key]}
parentPath={path}
depth={depth + 1}
/>
))
)}
{schema.type === Type.array
&& schema.items
&& schema.items.type === Type.object
&& schema.items.properties
&& (
Object.entries(schema.items.properties).map(([key, childSchema]) => (
<SchemaNode
key={key}
name={key}
required={!!schema.items?.required?.includes(key)}
schema={childSchema}
path={[...path, 'items', 'properties', key]}
parentPath={path}
depth={depth + 1}
/>
))
)}
</>
)}
{
depth === 0 && !isAddingNewField && (
<AddField />
)
}
</div>
)
}
export default React.memo(SchemaNode)

View File

@ -0,0 +1,34 @@
import { useContext } from 'react'
import { createStore, useStore } from 'zustand'
import type { SchemaRoot } from '../../../types'
import { VisualEditorContext } from './context'
type VisualEditorStore = {
hoveringProperty: string | null
setHoveringProperty: (propertyPath: string | null) => void
isAddingNewField: boolean
setIsAddingNewField: (isAdding: boolean) => void
advancedEditing: boolean
setAdvancedEditing: (isEditing: boolean) => void
backupSchema: SchemaRoot | null
setBackupSchema: (schema: SchemaRoot | null) => void
}
export const createVisualEditorStore = () => createStore<VisualEditorStore>(set => ({
hoveringProperty: null,
setHoveringProperty: (propertyPath: string | null) => set({ hoveringProperty: propertyPath }),
isAddingNewField: false,
setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }),
advancedEditing: false,
setAdvancedEditing: (isEditing: boolean) => set({ advancedEditing: isEditing }),
backupSchema: null,
setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }),
}))
export const useVisualEditorStore = <T>(selector: (state: VisualEditorStore) => T): T => {
const store = useContext(VisualEditorContext)
if (!store)
throw new Error('Missing VisualEditorContext.Provider in the tree')
return useStore(store, selector)
}

View File

@ -0,0 +1,75 @@
'use client'
import Button from '@/app/components/base/button'
import { RiEditLine } from '@remixicon/react'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { type SchemaRoot, type StructuredOutput, Type } from '../types'
import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
import { useBoolean } from 'ahooks'
import JsonSchemaConfigModal from './json-schema-config-modal'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {
className?: string
value?: StructuredOutput
onChange: (value: StructuredOutput) => void,
}
const StructureOutput: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
const [showConfig, {
setTrue: showConfigModal,
setFalse: hideConfigModal,
}] = useBoolean(false)
const handleChange = useCallback((value: SchemaRoot) => {
onChange({
schema: value,
})
}, [onChange])
return (
<div className={cn(className)}>
<div className='flex justify-between'>
<div className='flex items-center leading-[18px]'>
<div className='code-sm-semibold text-text-secondary'>structured_output</div>
<div className='system-xs-regular ml-2 text-text-tertiary'>object</div>
</div>
<Button
size='small'
variant='secondary'
className='flex'
onClick={showConfigModal}
>
<RiEditLine className='mr-1 size-3.5' />
<div className='system-xs-medium text-components-button-secondary-text'>{t('app.structOutput.configure')}</div>
</Button>
</div>
{(value?.schema && value.schema.properties && Object.keys(value.schema.properties).length > 0) ? (
<ShowPanel
payload={value}
/>) : (
<div className='system-xs-regular mt-1.5 flex h-10 cursor-pointer items-center justify-center rounded-[10px] bg-background-section text-text-tertiary' onClick={showConfigModal}>{t('app.structOutput.notConfiguredTip')}</div>
)}
{showConfig && (
<JsonSchemaConfigModal
isShow
defaultSchema={(value?.schema || {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}) as any} // wait for types change
onSave={handleChange as any} // wait for types change
onClose={hideConfigModal}
/>
)}
</div>
)
}
export default React.memo(StructureOutput)

View File

@ -20,6 +20,9 @@ import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/c
import ResultPanel from '@/app/components/workflow/run/result-panel'
import Tooltip from '@/app/components/base/tooltip'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import StructureOutput from './components/structure-output'
import Switch from '@/app/components/base/switch'
import { RiAlertFill, RiQuestionLine } from '@remixicon/react'
const i18nPrefix = 'workflow.nodes.llm'
@ -64,6 +67,11 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
contexts,
setContexts,
runningStatus,
isModelSupportStructuredOutput,
structuredOutputCollapsed,
setStructuredOutputCollapsed,
handleStructureOutputEnableChange,
handleStructureOutputChange,
handleRun,
handleStop,
varInputs,
@ -282,13 +290,57 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
/>
</div>
<Split />
<OutputVars>
<OutputVars
collapsed={structuredOutputCollapsed}
onCollapse={setStructuredOutputCollapsed}
operations={
<div className='mr-4 flex shrink-0 items-center'>
{!isModelSupportStructuredOutput && (
<Tooltip noDecoration popupContent={
<div className='w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]'>
<div className='title-xs-semi-bold text-text-primary'>{t('app.structOutput.modelNotSupported')}</div>
<div className='body-xs-regular mt-1 text-text-secondary'>{t('app.structOutput.modelNotSupportedTip')}</div>
</div>
}>
<div>
<RiAlertFill className='mr-1 size-4 text-text-warning-secondary' />
</div>
</Tooltip>
)}
<div className='system-xs-medium-uppercase mr-0.5 text-text-tertiary'>{t('app.structOutput.structured')}</div>
<Tooltip popupContent={
<div className='max-w-[150px]'>{t('app.structOutput.structuredTip')}</div>
}>
<div>
<RiQuestionLine className='size-3.5 text-text-quaternary' />
</div>
</Tooltip>
<Switch
className='ml-2'
defaultValue={!!inputs.structured_output_enabled}
onChange={handleStructureOutputEnableChange}
size='md'
disabled={readOnly}
/>
</div>
}
>
<>
<VarItem
name='text'
type='string'
description={t(`${i18nPrefix}.outputVars.output`)}
/>
{inputs.structured_output_enabled && (
<>
<Split className='mt-3' />
<StructureOutput
className='mt-4'
value={inputs.structured_output}
onChange={handleStructureOutputChange}
/>
</>
)}
</>
</OutputVars>
{isShowSingleRun && (

View File

@ -9,9 +9,10 @@ import {
} from '../../hooks'
import useAvailableVarList from '../_base/hooks/use-available-var-list'
import useConfigVision from '../../hooks/use-config-vision'
import type { LLMNodeType } from './types'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import type { LLMNodeType, StructuredOutput } from './types'
import { useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
ModelFeatureEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
@ -277,6 +278,30 @@ const useConfig = (id: string, payload: LLMNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
// structure output
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
const isModelSupportStructuredOutput = modelList
?.find(provideItem => provideItem.provider === model?.provider)
?.models.find(modelItem => modelItem.model === model?.name)
?.features?.includes(ModelFeatureEnum.StructuredOutput)
const [structuredOutputCollapsed, setStructuredOutputCollapsed] = useState(true)
const handleStructureOutputEnableChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.structured_output_enabled = enabled
})
setInputs(newInputs)
if (enabled)
setStructuredOutputCollapsed(false)
}, [inputs, setInputs])
const handleStructureOutputChange = useCallback((newOutput: StructuredOutput) => {
const newInputs = produce(inputs, (draft) => {
draft.structured_output = newOutput
})
setInputs(newInputs)
}, [inputs, setInputs])
const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type)
}, [])
@ -408,6 +433,11 @@ const useConfig = (id: string, payload: LLMNodeType) => {
setContexts,
varInputs,
runningStatus,
isModelSupportStructuredOutput,
handleStructureOutputChange,
structuredOutputCollapsed,
setStructuredOutputCollapsed,
handleStructureOutputEnableChange,
handleRun,
handleStop,
runResult,

View File

@ -1,5 +1,336 @@
import type { LLMNodeType } from './types'
import { ArrayType, Type } from './types'
import type { ArrayItems, Field, LLMNodeType } from './types'
import type { Schema, ValidationError } from 'jsonschema'
import { Validator } from 'jsonschema'
import produce from 'immer'
import { z } from 'zod'
export const checkNodeValid = (payload: LLMNodeType) => {
return true
}
export const getFieldType = (field: Field) => {
const { type, items } = field
if (type !== Type.array || !items)
return type
return ArrayType[items.type]
}
export const getHasChildren = (schema: Field) => {
const complexTypes = [Type.object, Type.array]
if (!complexTypes.includes(schema.type))
return false
if (schema.type === Type.object)
return schema.properties && Object.keys(schema.properties).length > 0
if (schema.type === Type.array)
return schema.items && schema.items.type === Type.object && schema.items.properties && Object.keys(schema.items.properties).length > 0
}
export const getTypeOf = (target: any) => {
if (target === null) return 'null'
if (typeof target !== 'object') {
return typeof target
}
else {
return Object.prototype.toString
.call(target)
.slice(8, -1)
.toLocaleLowerCase()
}
}
export const inferType = (value: any): Type => {
const type = getTypeOf(value)
if (type === 'array') return Type.array
// type boolean will be treated as string
if (type === 'boolean') return Type.string
if (type === 'number') return Type.number
if (type === 'string') return Type.string
if (type === 'object') return Type.object
return Type.string
}
export const jsonToSchema = (json: any): Field => {
const schema: Field = {
type: inferType(json),
}
if (schema.type === Type.object) {
schema.properties = {}
schema.required = []
schema.additionalProperties = false
Object.entries(json).forEach(([key, value]) => {
schema.properties![key] = jsonToSchema(value)
schema.required!.push(key)
})
}
else if (schema.type === Type.array) {
schema.items = jsonToSchema(json[0]) as ArrayItems
}
return schema
}
export const checkJsonDepth = (json: any) => {
if (!json || getTypeOf(json) !== 'object')
return 0
let maxDepth = 0
if (getTypeOf(json) === 'array') {
if (json[0] && getTypeOf(json[0]) === 'object')
maxDepth = checkJsonDepth(json[0])
}
else if (getTypeOf(json) === 'object') {
const propertyDepths = Object.values(json).map(value => checkJsonDepth(value))
maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
}
return maxDepth
}
export const checkJsonSchemaDepth = (schema: Field) => {
if (!schema || getTypeOf(schema) !== 'object')
return 0
let maxDepth = 0
if (schema.type === Type.object && schema.properties) {
const propertyDepths = Object.values(schema.properties).map(value => checkJsonSchemaDepth(value))
maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
}
else if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
maxDepth = checkJsonSchemaDepth(schema.items) + 1
}
return maxDepth
}
export const findPropertyWithPath = (target: any, path: string[]) => {
let current = target
for (const key of path)
current = current[key]
return current
}
const draft07MetaSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'http://json-schema.org/draft-07/schema#',
title: 'Core schema meta-schema',
definitions: {
schemaArray: {
type: 'array',
minItems: 1,
items: { $ref: '#' },
},
nonNegativeInteger: {
type: 'integer',
minimum: 0,
},
nonNegativeIntegerDefault0: {
allOf: [
{ $ref: '#/definitions/nonNegativeInteger' },
{ default: 0 },
],
},
simpleTypes: {
enum: [
'array',
'boolean',
'integer',
'null',
'number',
'object',
'string',
],
},
stringArray: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
default: [],
},
},
type: ['object', 'boolean'],
properties: {
$id: {
type: 'string',
format: 'uri-reference',
},
$schema: {
type: 'string',
format: 'uri',
},
$ref: {
type: 'string',
format: 'uri-reference',
},
title: {
type: 'string',
},
description: {
type: 'string',
},
default: true,
readOnly: {
type: 'boolean',
default: false,
},
examples: {
type: 'array',
items: true,
},
multipleOf: {
type: 'number',
exclusiveMinimum: 0,
},
maximum: {
type: 'number',
},
exclusiveMaximum: {
type: 'number',
},
minimum: {
type: 'number',
},
exclusiveMinimum: {
type: 'number',
},
maxLength: { $ref: '#/definitions/nonNegativeInteger' },
minLength: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
pattern: {
type: 'string',
format: 'regex',
},
additionalItems: { $ref: '#' },
items: {
anyOf: [
{ $ref: '#' },
{ $ref: '#/definitions/schemaArray' },
],
default: true,
},
maxItems: { $ref: '#/definitions/nonNegativeInteger' },
minItems: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
uniqueItems: {
type: 'boolean',
default: false,
},
contains: { $ref: '#' },
maxProperties: { $ref: '#/definitions/nonNegativeInteger' },
minProperties: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
required: { $ref: '#/definitions/stringArray' },
additionalProperties: { $ref: '#' },
definitions: {
type: 'object',
additionalProperties: { $ref: '#' },
default: {},
},
properties: {
type: 'object',
additionalProperties: { $ref: '#' },
default: {},
},
patternProperties: {
type: 'object',
additionalProperties: { $ref: '#' },
propertyNames: { format: 'regex' },
default: {},
},
dependencies: {
type: 'object',
additionalProperties: {
anyOf: [
{ $ref: '#' },
{ $ref: '#/definitions/stringArray' },
],
},
},
propertyNames: { $ref: '#' },
const: true,
enum: {
type: 'array',
items: true,
minItems: 1,
uniqueItems: true,
},
type: {
anyOf: [
{ $ref: '#/definitions/simpleTypes' },
{
type: 'array',
items: { $ref: '#/definitions/simpleTypes' },
minItems: 1,
uniqueItems: true,
},
],
},
format: { type: 'string' },
allOf: { $ref: '#/definitions/schemaArray' },
anyOf: { $ref: '#/definitions/schemaArray' },
oneOf: { $ref: '#/definitions/schemaArray' },
not: { $ref: '#' },
},
default: true,
} as unknown as Schema
const validator = new Validator()
export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
const schema = produce(schemaToValidate, (draft: any) => {
// Make sure the schema has the $schema property for draft-07
if (!draft.$schema)
draft.$schema = 'http://json-schema.org/draft-07/schema#'
})
const result = validator.validate(schema, draft07MetaSchema, {
nestedErrors: true,
throwError: false,
})
// Access errors from the validation result
const errors = result.valid ? [] : result.errors || []
return errors
}
export const getValidationErrorMessage = (errors: ValidationError[]) => {
const message = errors.map((error) => {
return `Error: ${error.path.join('.')} ${error.message} Details: ${JSON.stringify(error.stack)}`
}).join('; ')
return message
}
export const convertBooleanToString = (schema: any) => {
if (schema.type === Type.boolean)
schema.type = Type.string
if (schema.type === Type.array && schema.items && schema.items.type === Type.boolean)
schema.items.type = Type.string
if (schema.type === Type.object) {
schema.properties = Object.entries(schema.properties).reduce((acc, [key, value]) => {
acc[key] = convertBooleanToString(value)
return acc
}, {} as any)
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
schema.items.properties = Object.entries(schema.items.properties).reduce((acc, [key, value]) => {
acc[key] = convertBooleanToString(value)
return acc
}, {} as any)
}
return schema
}
const schemaRootObject = z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()),
additionalProperties: z.boolean().optional(),
})
export const preValidateSchema = (schema: any) => {
const result = schemaRootObject.safeParse(schema)
return result
}

View File

@ -17,6 +17,8 @@ import ResultPanel from '@/app/components/workflow/run/result-panel'
import { useToolIcon } from '@/app/components/workflow/hooks'
import { useLogs } from '@/app/components/workflow/run/hooks'
import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log'
import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
import { Type } from '../llm/types'
const i18nPrefix = 'workflow.nodes.tool'
@ -51,6 +53,7 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
handleStop,
runResult,
outputSchema,
hasObjectOutput,
} = useConfig(id, data)
const toolIcon = useToolIcon(data)
const logsParams = useLogs()
@ -134,26 +137,45 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
<>
<VarItem
name='text'
type='String'
type='string'
description={t(`${i18nPrefix}.outputVars.text`)}
isIndent={hasObjectOutput}
/>
<VarItem
name='files'
type='Array[File]'
type='array[file]'
description={t(`${i18nPrefix}.outputVars.files.title`)}
isIndent={hasObjectOutput}
/>
<VarItem
name='json'
type='Array[Object]'
type='array[object]'
description={t(`${i18nPrefix}.outputVars.json`)}
isIndent={hasObjectOutput}
/>
{outputSchema.map(outputItem => (
<VarItem
key={outputItem.name}
name={outputItem.name}
type={outputItem.type}
description={outputItem.description}
/>
<div key={outputItem.name}>
{outputItem.value?.type === 'object' ? (
<StructureOutputItem
rootClassName='code-sm-semibold text-text-secondary'
payload={{
schema: {
type: Type.object,
properties: {
[outputItem.name]: outputItem.value,
},
additionalProperties: false,
},
}} />
) : (
<VarItem
name={outputItem.name}
type={outputItem.type.toLocaleLowerCase()}
description={outputItem.description}
isIndent={hasObjectOutput}
/>
)}
</div>
))}
</>
</OutputVars>

View File

@ -262,17 +262,33 @@ const useConfig = (id: string, payload: ToolNodeType) => {
return []
Object.keys(output_schema.properties).forEach((outputKey) => {
const output = output_schema.properties[outputKey]
res.push({
name: outputKey,
type: output.type === 'array'
? `Array[${output.items?.type.slice(0, 1).toLocaleUpperCase()}${output.items?.type.slice(1)}]`
: `${output.type.slice(0, 1).toLocaleUpperCase()}${output.type.slice(1)}`,
description: output.description,
})
const type = output.type
if (type === 'object') {
res.push({
name: outputKey,
value: output,
})
}
else {
res.push({
name: outputKey,
type: output.type === 'array'
? `Array[${output.items?.type.slice(0, 1).toLocaleUpperCase()}${output.items?.type.slice(1)}]`
: `${output.type.slice(0, 1).toLocaleUpperCase()}${output.type.slice(1)}`,
description: output.description,
})
}
})
return res
}, [output_schema])
const hasObjectOutput = useMemo(() => {
if (!output_schema)
return false
const properties = output_schema.properties
return Object.keys(properties).some(key => properties[key].type === 'object')
}, [output_schema])
return {
readOnly,
inputs,
@ -302,6 +318,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
handleStop,
runResult,
outputSchema,
hasObjectOutput,
}
}

View File

@ -285,6 +285,7 @@ export const GITHUB_ACCESS_TOKEN = process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN |
export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl'
export const FULL_DOC_PREVIEW_LENGTH = 50
export const JSON_SCHEMA_MAX_DEPTH = 10
let loopNodeMaxCount = 100
if (process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT && process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT !== '')

View File

@ -10,7 +10,7 @@ const merge = <T extends Record<string, any>>(
export type _Events = Record<EventType, unknown>
export type UseSubcribeOption = {
export type UseSubscribeOption = {
/**
* Whether the subscription is enabled.
* @default true
@ -22,21 +22,21 @@ export type ExtendedOn<Events extends _Events> = {
<Key extends keyof Events>(
type: Key,
handler: Handler<Events[Key]>,
options?: UseSubcribeOption,
options?: UseSubscribeOption,
): void;
(
type: '*',
handler: WildcardHandler<Events>,
option?: UseSubcribeOption,
option?: UseSubscribeOption,
): void;
}
export type UseMittReturn<Events extends _Events> = {
useSubcribe: ExtendedOn<Events>;
useSubscribe: ExtendedOn<Events>;
emit: Emitter<Events>['emit'];
}
const defaultSubcribeOption: UseSubcribeOption = {
const defaultSubscribeOption: UseSubscribeOption = {
enabled: true,
}
@ -52,12 +52,12 @@ function useMitt<Events extends _Events>(
emitterRef.current = mitt
}
const emitter = emitterRef.current
const useSubcribe: ExtendedOn<Events> = (
const useSubscribe: ExtendedOn<Events> = (
type: string,
handler: any,
option?: UseSubcribeOption,
option?: UseSubscribeOption,
) => {
const { enabled } = merge(defaultSubcribeOption, option)
const { enabled } = merge(defaultSubscribeOption, option)
useEffect(() => {
if (enabled) {
emitter.on(type, handler)
@ -67,7 +67,7 @@ function useMitt<Events extends _Events>(
}
return {
emit: emitter.emit,
useSubcribe,
useSubscribe,
}
}

View File

@ -180,6 +180,17 @@ const translation = {
noParams: 'No parameters needed',
},
showMyCreatedAppsOnly: 'Created by me',
structOutput: {
moreFillTip: 'Showing max 10 levels of nesting',
required: 'Required',
LLMResponse: 'LLM Response',
configure: 'Configure',
notConfiguredTip: 'Structured output has not been configured yet',
structured: 'Structured',
structuredTip: 'Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema',
modelNotSupported: 'Model not supported',
modelNotSupportedTip: 'The current model does not support this feature and is automatically downgraded to prompt injection.',
},
}
export default translation

View File

@ -56,6 +56,7 @@ const translation = {
regenerate: 'Regenerate',
submit: 'Submit',
skip: 'Skip',
format: 'Format',
},
errorMsg: {
fieldRequired: '{{field}} is required',

View File

@ -423,6 +423,34 @@ const translation = {
variable: 'Variable',
},
sysQueryInUser: 'sys.query in user message is required',
jsonSchema: {
title: 'Structured Output Schema',
instruction: 'Instruction',
promptTooltip: 'Convert the text description into a standardized JSON Schema structure.',
promptPlaceholder: 'Describe your JSON Schema...',
generate: 'Generate',
import: 'Import from JSON',
generateJsonSchema: 'Generate JSON Schema',
generationTip: 'You can use natural language to quickly create a JSON Schema.',
generating: 'Generating JSON Schema...',
generatedResult: 'Generated Result',
resultTip: 'Here is the generated result. If you\'re not satisfied, you can go back and modify your prompt.',
back: 'Back',
regenerate: 'Regenerate',
apply: 'Apply',
doc: 'Learn more about structured output',
resetDefaults: 'Reset',
required: 'required',
addField: 'Add Field',
addChildField: 'Add Child Field',
showAdvancedOptions: 'Show advanced options',
stringValidations: 'String Validations',
fieldNamePlaceholder: 'Field Name',
descriptionPlaceholder: 'Add description',
warningTips: {
saveSchema: 'Please finish editing the current field before saving the schema',
},
},
},
knowledgeRetrieval: {
queryVariable: 'Query Variable',

View File

@ -33,7 +33,7 @@ export const languages = data.languages
export const LanguagesSupported = languages.filter(item => item.supported).map(item => item.value)
export const getLanguage = (locale: string) => {
if (locale === 'zh-Hans')
if (['zh-Hans', 'ja-JP'].includes(locale))
return locale.replace('-', '_')
return LanguagesSupported[0].replace('-', '_')

View File

@ -181,6 +181,17 @@ const translation = {
},
openInExplore: '在“探索”中打开',
showMyCreatedAppsOnly: '我创建的',
structOutput: {
moreFillTip: '最多显示 10 级嵌套',
required: '必填',
LLMResponse: 'LLM 的响应',
configure: '配置',
notConfiguredTip: '结构化输出尚未配置',
structured: '结构化输出',
structuredTip: '结构化输出是一项功能,可确保模型始终生成符合您提供的 JSON 模式的响应',
modelNotSupported: '模型不支持',
modelNotSupportedTip: '当前模型不支持此功能,将自动降级为提示注入。',
},
}
export default translation

View File

@ -56,6 +56,7 @@ const translation = {
regenerate: '重新生成',
submit: '提交',
skip: '跳过',
format: '格式化',
},
errorMsg: {
fieldRequired: '{{field}} 为必填项',

View File

@ -424,6 +424,34 @@ const translation = {
variable: '变量',
},
sysQueryInUser: 'user message 中必须包含 sys.query',
jsonSchema: {
title: '结构化输出 Schema',
instruction: '指令',
promptTooltip: '将文本描述转换为标准化的 JSON Schema 结构',
promptPlaceholder: '描述你的 JSON Schema...',
generate: '生成',
import: '从 JSON 导入',
generateJsonSchema: '生成 JSON Schema',
generationTip: '可以使用自然语言快速创建 JSON Schema。',
generating: '正在为您生成 JSON Schema...',
generatedResult: '生成结果',
resultTip: '以下是生成的结果。如果你对这个结果不满意,可以返回并修改你的提示词。',
back: '返回',
regenerate: '重新生成',
apply: '应用',
doc: '了解有关结构化输出的更多信息',
resetDefaults: '清空配置',
required: '必填',
addField: '添加字段',
addChildField: '添加子字段',
showAdvancedOptions: '显示高级选项',
stringValidations: '字符串验证',
fieldNamePlaceholder: '字段名',
descriptionPlaceholder: '添加描述',
warningTips: {
saveSchema: '请先完成当前字段的编辑',
},
},
},
knowledgeRetrieval: {
queryVariable: '查询变量',

View File

@ -1,4 +1,5 @@
import type { I18nText } from '@/i18n/language'
import type { Model } from '@/types/app'
export type CommonResponse = {
result: 'success' | 'fail'
@ -291,3 +292,13 @@ export type ModerationService = (
text: string
}
) => Promise<ModerateResponse>
export type StructuredOutputRulesRequestBody = {
instruction: string
model_config: Model
}
export type StructuredOutputRulesResponse = {
output: string
error?: string
}

View File

@ -77,6 +77,7 @@
"immer": "^9.0.19",
"js-audio-recorder": "^1.0.7",
"js-cookie": "^3.0.5",
"jsonschema": "^1.5.0",
"jwt-decode": "^4.0.0",
"katex": "^0.16.21",
"ky": "^1.7.2",

8
web/pnpm-lock.yaml generated
View File

@ -163,6 +163,9 @@ importers:
js-cookie:
specifier: ^3.0.5
version: 3.0.5
jsonschema:
specifier: ^1.5.0
version: 1.5.0
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
@ -6025,6 +6028,9 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonschema@1.5.0:
resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@ -15479,6 +15485,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonschema@1.5.0: {}
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.8

View File

@ -1,8 +1,10 @@
import { get } from './base'
import { get, post } from './base'
import type {
FileUploadConfigResponse,
StructuredOutputRulesRequestBody,
StructuredOutputRulesResponse,
} from '@/models/common'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
const NAME_SPACE = 'common'
@ -12,3 +14,15 @@ export const useFileUploadConfig = () => {
queryFn: () => get<FileUploadConfigResponse>('/files/upload'),
})
}
export const useGenerateStructuredOutputRules = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'generate-structured-output-rules'],
mutationFn: (body: StructuredOutputRulesRequestBody) => {
return post<StructuredOutputRulesResponse>(
'/rule-structured-output-generate',
{ body },
)
},
})
}

View File

@ -113,6 +113,7 @@ const config = {
'dataset-option-card-purple-gradient': 'var(--color-dataset-option-card-purple-gradient)',
'dataset-option-card-orange-gradient': 'var(--color-dataset-option-card-orange-gradient)',
'dataset-chunk-list-mask-bg': 'var(--color-dataset-chunk-list-mask-bg)',
'line-divider-bg': 'var(--color-line-divider-bg)',
'dataset-warning-message-bg': 'var(--color-dataset-warning-message-bg)',
'price-premium-badge-background': 'var(--color-premium-badge-background)',
'premium-yearly-tip-text-background': 'var(--color-premium-yearly-tip-text-background)',

View File

@ -1,64 +1,64 @@
html[data-theme="dark"] {
--color-premium-yearly-tip-text-background: linear-gradient(91deg, #FDB022 2.18%, #F79009 108.79%);
--color-premium-badge-background: linear-gradient(95deg, rgba(103, 111, 131, 0.90) 0%, rgba(73, 84, 100, 0.90) 105.58%), var(--util-colors-gray-gray-200, #18222F);
--color-premium-text-background: linear-gradient(92deg, rgba(249, 250, 251, 0.95) 0%, rgba(233, 235, 240, 0.95) 97.78%);
--color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
--color-grid-mask-background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(24, 24, 25, 0.1) 62.25%, rgba(24, 24, 25, 0.10) 100%);
--color-chatbot-bg: linear-gradient(180deg,
rgba(34, 34, 37, 0.9) 0%,
rgba(29, 29, 32, 0.9) 90.48%);
--color-chat-bubble-bg: linear-gradient(180deg,
rgba(200, 206, 218, 0.08) 0%,
rgba(200, 206, 218, 0.02) 100%);
--color-chat-input-mask: linear-gradient(180deg,
rgba(24, 24, 27, 0.04) 0%,
rgba(24, 24, 27, 0.60) 100%);
--color-workflow-process-bg: linear-gradient(90deg,
rgba(24, 24, 27, 0.25) 0%,
rgba(24, 24, 27, 0.04) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.12) 0%,
rgba(0, 0, 0, 0) 26.01%);
--color-workflow-batch-failed-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-marketplace-divider-bg: linear-gradient(90deg,
rgba(200, 206, 218, 0.14) 0%,
rgba(0, 0, 0, 0) 100%);
--color-marketplace-plugin-empty: linear-gradient(180deg,
rgba(0, 0, 0, 0) 0%,
#222225 100%);
--color-toast-success-bg: linear-gradient(92deg,
rgba(23, 178, 106, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-warning-bg: linear-gradient(92deg,
rgba(247, 144, 9, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-error-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg,
rgba(11, 165, 236, 0.3) 0%);
--color-account-teams-bg: linear-gradient(271deg,
rgba(34, 34, 37, 0.9) -0.1%,
rgba(29, 29, 32, 0.9) 98.26%);
--color-app-detail-bg: linear-gradient(169deg,
#1D1D20 1.18%,
#222225 99.52%);
--color-app-detail-overlay-bg: linear-gradient(270deg,
rgba(0, 0, 0, 0.00) 0%,
rgba(24, 24, 27, 0.02) 8%,
rgba(24, 24, 27, 0.54) 100%);
--color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1D1D20 0%, #222225 100%);
--color-dataset-child-chunk-expand-btn-bg: linear-gradient(90deg, rgba(24, 24, 27, 0.25) 0%, rgba(24, 24, 27, 0.04) 100%);
--color-dataset-option-card-blue-gradient: linear-gradient(90deg, #24252E 0%, #1E1E21 100%);
--color-dataset-option-card-purple-gradient: linear-gradient(90deg, #25242E 0%, #1E1E21 100%);
--color-dataset-option-card-orange-gradient: linear-gradient(90deg, #2B2322 0%, #1E1E21 100%);
--color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(34, 34, 37, 0.00) 0%, #222225 100%);
--mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg,
rgba(24, 24, 27, 0.08) 0%,
rgba(0, 0, 0, 0) 100%);
--color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-premium-yearly-tip-text-background: linear-gradient(91deg, #FDB022 2.18%, #F79009 108.79%);
--color-premium-badge-background: linear-gradient(95deg, rgba(103, 111, 131, 0.90) 0%, rgba(73, 84, 100, 0.90) 105.58%), var(--util-colors-gray-gray-200, #18222F);
--color-premium-text-background: linear-gradient(92deg, rgba(249, 250, 251, 0.95) 0%, rgba(233, 235, 240, 0.95) 97.78%);
--color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
--color-grid-mask-background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(24, 24, 25, 0.1) 62.25%, rgba(24, 24, 25, 0.10) 100%);
--color-chatbot-bg: linear-gradient(180deg,
rgba(34, 34, 37, 0.9) 0%,
rgba(29, 29, 32, 0.9) 90.48%);
--color-chat-bubble-bg: linear-gradient(180deg,
rgba(200, 206, 218, 0.08) 0%,
rgba(200, 206, 218, 0.02) 100%);
--color-chat-input-mask: linear-gradient(180deg,
rgba(24, 24, 27, 0.04) 0%,
rgba(24, 24, 27, 0.60) 100%);
--color-workflow-process-bg: linear-gradient(90deg,
rgba(24, 24, 27, 0.25) 0%,
rgba(24, 24, 27, 0.04) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.12) 0%,
rgba(0, 0, 0, 0) 26.01%);
--color-workflow-batch-failed-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-marketplace-divider-bg: linear-gradient(90deg,
rgba(200, 206, 218, 0.14) 0%,
rgba(0, 0, 0, 0) 100%);
--color-marketplace-plugin-empty: linear-gradient(180deg,
rgba(0, 0, 0, 0) 0%,
#222225 100%);
--color-toast-success-bg: linear-gradient(92deg,
rgba(23, 178, 106, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-warning-bg: linear-gradient(92deg,
rgba(247, 144, 9, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-error-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg,
rgba(11, 165, 236, 0.3) 0%);
--color-account-teams-bg: linear-gradient(271deg,
rgba(34, 34, 37, 0.9) -0.1%,
rgba(29, 29, 32, 0.9) 98.26%);
--color-app-detail-bg: linear-gradient(169deg,
#1D1D20 1.18%,
#222225 99.52%);
--color-app-detail-overlay-bg: linear-gradient(270deg,
rgba(0, 0, 0, 0.00) 0%,
rgba(24, 24, 27, 0.02) 8%,
rgba(24, 24, 27, 0.54) 100%);
--color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1D1D20 0%, #222225 100%);
--color-dataset-child-chunk-expand-btn-bg: linear-gradient(90deg, rgba(24, 24, 27, 0.25) 0%, rgba(24, 24, 27, 0.04) 100%);
--color-dataset-option-card-blue-gradient: linear-gradient(90deg, #24252E 0%, #1E1E21 100%);
--color-dataset-option-card-purple-gradient: linear-gradient(90deg, #25242E 0%, #1E1E21 100%);
--color-dataset-option-card-orange-gradient: linear-gradient(90deg, #2B2322 0%, #1E1E21 100%);
--color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(34, 34, 37, 0.00) 0%, #222225 100%);
--mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg,
rgba(24, 24, 27, 0.08) 0%,
rgba(0, 0, 0, 0) 100%);
--color-line-divider-bg: linear-gradient(90deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 100%, );
}

View File

@ -1,13 +1,54 @@
html[data-theme="light"] {
--color-premium-yearly-tip-text-background: linear-gradient(91deg, #F79009 2.18%, #DC6803 108.79%);
--color-premium-badge-background: linear-gradient(95deg, rgba(152, 162, 178, 0.90) 0%, rgba(103, 111, 131, 0.90) 105.58%);
--color-premium-text-background: linear-gradient(92deg, rgba(252, 252, 253, 0.95) 0%, rgba(242, 244, 247, 0.95) 97.78%);
--color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
--color-grid-mask-background: linear-gradient(0deg, #FFF 0%, rgba(217, 217, 217, 0.10) 62.25%, rgba(217, 217, 217, 0.10) 100%);
--color-chatbot-bg: linear-gradient(180deg,
rgba(249, 250, 251, 0.9) 0%,
rgba(242, 244, 247, 0.9) 90.48%);
rgba(249, 250, 251, 0.9) 0%,
rgba(242, 244, 247, 0.9) 90.48%);
--color-chat-bubble-bg: linear-gradient(180deg,
#fff 0%,
rgba(255, 255, 255, 0.6) 100%);
#fff 0%,
rgba(255, 255, 255, 0.6) 100%);
--color-chat-input-mask: linear-gradient(180deg,
rgba(255, 255, 255, 0.01) 0%,
#F2F4F7 100%);
--color-workflow-process-bg: linear-gradient(90deg,
rgba(200, 206, 218, 0.2) 0%,
rgba(200, 206, 218, 0.04) 100%);
rgba(200, 206, 218, 0.2) 0%,
rgba(200, 206, 218, 0.04) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.10) 0%,
rgba(255, 255, 255, 0) 26.01%);
--color-workflow-batch-failed-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-marketplace-divider-bg: linear-gradient(90deg,
rgba(16, 24, 40, 0.08) 0%,
rgba(255, 255, 255, 0) 100%);
--color-marketplace-plugin-empty: linear-gradient(180deg,
rgba(255, 255, 255, 0) 0%,
#fcfcfd 100%);
--color-toast-success-bg: linear-gradient(92deg,
rgba(23, 178, 106, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-warning-bg: linear-gradient(92deg,
rgba(247, 144, 9, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-error-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg,
rgba(11, 165, 236, 0.25) 0%);
--color-account-teams-bg: linear-gradient(271deg,
rgba(249, 250, 251, 0.9) -0.1%,
rgba(242, 244, 247, 0.9) 98.26%);
--color-app-detail-bg: linear-gradient(169deg,
#F2F4F7 1.18%,
#F9FAFB 99.52%);
--color-app-detail-overlay-bg: linear-gradient(270deg,
rgba(0, 0, 0, 0.00) 0%,
rgba(16, 24, 40, 0.01) 8%,
rgba(16, 24, 40, 0.18) 100%);
--color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #F2F4F7 0%, #F9FAFB 100%);
@ -15,50 +56,9 @@ html[data-theme="light"] {
--color-dataset-option-card-blue-gradient: linear-gradient(90deg, #F2F4F7 0%, #F9FAFB 100%);
--color-dataset-option-card-purple-gradient: linear-gradient(90deg, #F0EEFA 0%, #F9FAFB 100%);
--color-dataset-option-card-orange-gradient: linear-gradient(90deg, #F8F2EE 0%, #F9FAFB 100%);
--color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FFF 100%);
--color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FCFCFD 100%);
--mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg,
rgba(200, 206, 218, 0.2) 0%,
rgba(255, 255, 255, 0) 100%);
--color-premium-yearly-tip-text-background: linear-gradient(91deg, #F79009 2.18%, #DC6803 108.79%);
--color-premium-badge-background: linear-gradient(95deg, rgba(152, 162, 178, 0.90) 0%, rgba(103, 111, 131, 0.90) 105.58%);
--color-premium-text-background: linear-gradient(92deg, rgba(252, 252, 253, 0.95) 0%, rgba(242, 244, 247, 0.95) 97.78%);
--color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
--color-grid-mask-background: linear-gradient(0deg, #FFF 0%, rgba(217, 217, 217, 0.10) 62.25%, rgba(217, 217, 217, 0.10) 100%);
--color-chat-input-mask: linear-gradient(180deg,
rgba(255, 255, 255, 0.01) 0%,
#F2F4F7 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.10) 0%,
rgba(255, 255, 255, 0) 26.01%);
--color-workflow-batch-failed-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-marketplace-divider-bg: linear-gradient(90deg,
rgba(16, 24, 40, 0.08) 0%,
rgba(255, 255, 255, 0) 100%);
--color-marketplace-plugin-empty: linear-gradient(180deg,
rgba(255, 255, 255, 0) 0%,
#fcfcfd 100%);
--color-toast-success-bg: linear-gradient(92deg,
rgba(23, 178, 106, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-warning-bg: linear-gradient(92deg,
rgba(247, 144, 9, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-error-bg: linear-gradient(92deg,
rgba(240, 68, 56, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg,
rgba(11, 165, 236, 0.25) 0%);
--color-account-teams-bg: linear-gradient(271deg,
rgba(249, 250, 251, 0.9) -0.1%,
rgba(242, 244, 247, 0.9) 98.26%);
--color-app-detail-bg: linear-gradient(169deg,
#F2F4F7 1.18%,
#F9FAFB 99.52%);
--color-app-detail-overlay-bg: linear-gradient(270deg,
rgba(0, 0, 0, 0.00) 0%,
rgba(16, 24, 40, 0.01) 8%,
rgba(16, 24, 40, 0.18) 100%);
--color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
rgba(200, 206, 218, 0.2) 0%,
rgba(255, 255, 255, 0) 100%);
--color-line-divider-bg: linear-gradient(90deg, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
}