Compare commits
28 Commits
main
...
feat/llm-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b305c17bc3 | ||
|
|
d4185c2d91 | ||
|
|
cea0886e4a | ||
|
|
475e1d07a7 | ||
|
|
b1c5299ff4 | ||
|
|
7fd23d747e | ||
|
|
7ea0a972d5 | ||
|
|
183edf0fd5 | ||
|
|
a07831bc05 | ||
|
|
4333820aa6 | ||
|
|
8adf0fa698 | ||
|
|
84eb6a4715 | ||
|
|
20ff7073bd | ||
|
|
5c7a6db6b3 | ||
|
|
7a2c831ef3 | ||
|
|
fefd7819e6 | ||
|
|
a1684791fc | ||
|
|
f55a0dd269 | ||
|
|
6a76e27b05 | ||
|
|
e624bf381b | ||
|
|
0031a3b58b | ||
|
|
6913f64083 | ||
|
|
e32cb0fdf8 | ||
|
|
8fb24bf1ca | ||
|
|
d6b66aeed8 | ||
|
|
6bf8253952 | ||
|
|
814070f1ae | ||
|
|
a4806be841 |
@ -3,6 +3,7 @@ import ChartView from './chartView'
|
||||
import CardView from './cardView'
|
||||
import TracingPanel from './tracing/panel'
|
||||
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel'
|
||||
import Test from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/test'
|
||||
|
||||
export type IDevelopProps = {
|
||||
params: { appId: string }
|
||||
@ -13,6 +14,7 @@ const Overview = async ({
|
||||
}: IDevelopProps) => {
|
||||
return (
|
||||
<div className="h-full px-4 sm:px-12 py-6 overflow-scroll bg-chatbot-bg">
|
||||
<Test />
|
||||
<ApikeyInfoPanel />
|
||||
<TracingPanel />
|
||||
<CardView appId={appId} />
|
||||
|
||||
@ -82,7 +82,7 @@ const Panel: FC = () => {
|
||||
? LangfuseIcon
|
||||
: inUseTracingProvider === TracingProvider.opik
|
||||
? OpikIcon
|
||||
: null
|
||||
: LangsmithIcon
|
||||
|
||||
const [langSmithConfig, setLangSmithConfig] = useState<LangSmithConfig | null>(null)
|
||||
const [langFuseConfig, setLangFuseConfig] = useState<LangFuseConfig | null>(null)
|
||||
|
||||
@ -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 { 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(
|
||||
'mx-0.5 relative group/wrap flex items-center h-[18px] pl-0.5 pr-[3px] rounded-[5px] border select-none',
|
||||
'mx-0.5 relative group/wrap flex items-center h-[18px] pl-0.5 pr-[3px] rounded-[5px] border select-none 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='w-3 h-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('shrink-0 w-3.5 h-3.5', isException && 'text-text-warning')} />}
|
||||
{isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
|
||||
@ -126,7 +143,24 @@ const WorkflowVariableBlockComponent = ({
|
||||
)
|
||||
}
|
||||
|
||||
return Item
|
||||
return (
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
varType={getVarType({
|
||||
nodeId: variables[0],
|
||||
valueSelector: variables,
|
||||
})}
|
||||
nodeType={node?.type}
|
||||
/>}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowVariableBlockComponent)
|
||||
|
||||
@ -25,11 +25,13 @@ export type WorkflowVariableBlockProps = {
|
||||
getWorkflowNode: (nodeId: string) => Node
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
getVarType: any
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
@ -7,29 +7,32 @@ export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
|
||||
export type SerializedNode = SerializedLexicalNode & {
|
||||
variables: string[]
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
getVarType: any
|
||||
}
|
||||
|
||||
export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> {
|
||||
__variables: string[]
|
||||
__workflowNodesMap: WorkflowNodesMap
|
||||
__getVarType: any
|
||||
|
||||
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 +51,13 @@ export class WorkflowVariableBlockNode extends DecoratorNode<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
|
||||
}
|
||||
@ -77,12 +81,17 @@ export class WorkflowVariableBlockNode extends DecoratorNode<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: any): WorkflowVariableBlockNode {
|
||||
return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
|
||||
}
|
||||
|
||||
export function $isWorkflowVariableBlockNode(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -3,6 +3,7 @@ import type { RoleName } from './plugins/history-block'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
ValueSelector,
|
||||
} from '@/app/components/workflow/types'
|
||||
|
||||
export type Option = {
|
||||
@ -60,6 +61,10 @@ export type WorkflowVariableBlockType = {
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>>
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
getVarType?: (payload: {
|
||||
nodeId: string,
|
||||
valueSelector: ValueSelector,
|
||||
}) => string
|
||||
}
|
||||
|
||||
export type MenuTextMatch = {
|
||||
|
||||
68
web/app/components/base/segmented-control/index.tsx
Normal file
68
web/app/components/base/segmented-control/index.tsx
Normal 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 items-center justify-center w-5 h-5'>
|
||||
<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 top-0 right-[-1px] h-full flex items-center'>
|
||||
<Divider type='vertical' className='h-3.5 mx-0' />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SegmentedControl) as typeof SegmentedControl
|
||||
@ -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: {
|
||||
|
||||
@ -10,6 +10,8 @@ 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 Badge from '@/app/components/base/badge'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type ParameterValue = number | string | string[] | boolean | undefined
|
||||
|
||||
@ -27,6 +29,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
onSwitch,
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const numberInputRef = useRef<HTMLInputElement>(null)
|
||||
@ -278,6 +281,19 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/* TODO: wait api return and product design */}
|
||||
{parameterRule.name === 'json_schema' && (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className='w-[232px]'>
|
||||
<div className='mb-1 body-xs-regular text-text-secondary'>{t('app.structOutput.legacyTip')}</div>
|
||||
<a className='' target='_blank' href='https://todo'>{t('app.structOutput.learnMore')}</a>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Badge uppercase className='text-text-accent-secondary'>{t('app.structOutput.legacy')}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
parameterRule.type === 'tag' && (
|
||||
|
||||
@ -8,6 +8,8 @@ import type {
|
||||
ValueSelector,
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useIsChatMode, useWorkflow } from './use-workflow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
|
||||
export const useWorkflowVariables = () => {
|
||||
const { t } = useTranslation()
|
||||
@ -72,3 +74,40 @@ export const useWorkflowVariables = () => {
|
||||
getCurrentVariableType,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowVariableType = () => {
|
||||
const store = useStoreApi()
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const { getBeforeNodesInSameBranch } = useWorkflow()
|
||||
const { getCurrentVariableType } = useWorkflowVariables()
|
||||
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
const getVarType = ({
|
||||
nodeId,
|
||||
valueSelector,
|
||||
}: {
|
||||
nodeId: string,
|
||||
valueSelector: ValueSelector,
|
||||
}) => {
|
||||
// debugger
|
||||
const node = getNodes().find(n => n.id === nodeId)
|
||||
// console.log(nodeId, valueSelector)
|
||||
const isInIteration = !!node?.data.isInIteration
|
||||
const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null
|
||||
const availableNodes = getBeforeNodesInSameBranch(nodeId)
|
||||
|
||||
const type = getCurrentVariableType({
|
||||
parentNode: iterationNode,
|
||||
valueSelector,
|
||||
availableNodes,
|
||||
isChatMode,
|
||||
isConstant: false,
|
||||
})
|
||||
return type
|
||||
}
|
||||
|
||||
return getVarType
|
||||
}
|
||||
|
||||
@ -4,10 +4,12 @@ import Collapse from '.'
|
||||
type FieldCollapseProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
operations?: ReactNode
|
||||
}
|
||||
const FieldCollapse = ({
|
||||
title,
|
||||
children,
|
||||
operations,
|
||||
}: FieldCollapseProps) => {
|
||||
return (
|
||||
<div className='py-4'>
|
||||
@ -15,6 +17,7 @@ const FieldCollapse = ({
|
||||
trigger={
|
||||
<div className='flex items-center h-6 system-sm-semibold-uppercase text-text-secondary cursor-pointer'>{title}</div>
|
||||
}
|
||||
operations={operations}
|
||||
>
|
||||
<div className='px-4'>
|
||||
{children}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { RiArrowDropRightLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
@ -10,6 +11,8 @@ type CollapseProps = {
|
||||
children: JSX.Element
|
||||
collapsed?: boolean
|
||||
onCollapse?: (collapsed: boolean) => void
|
||||
operations?: ReactNode
|
||||
|
||||
}
|
||||
const Collapse = ({
|
||||
disabled,
|
||||
@ -17,34 +20,38 @@ const Collapse = ({
|
||||
children,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
operations,
|
||||
}: CollapseProps) => {
|
||||
const [collapsedLocal, setCollapsedLocal] = useState(true)
|
||||
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex items-center'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setCollapsedLocal(!collapsedMerged)
|
||||
onCollapse?.(!collapsedMerged)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='shrink-0 w-4 h-4'>
|
||||
{
|
||||
!disabled && (
|
||||
<RiArrowDropRightLine
|
||||
className={cn(
|
||||
'w-4 h-4 text-text-tertiary',
|
||||
!collapsedMerged && 'transform rotate-90',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className='flex justify-between items-center'>
|
||||
<div
|
||||
className='flex items-center'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setCollapsedLocal(!collapsedMerged)
|
||||
onCollapse?.(!collapsedMerged)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='shrink-0 w-4 h-4'>
|
||||
{
|
||||
!disabled && (
|
||||
<RiArrowDropRightLine
|
||||
className={cn(
|
||||
'w-4 h-4 text-text-tertiary',
|
||||
!collapsedMerged && 'transform rotate-90',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{trigger}
|
||||
</div>
|
||||
{trigger}
|
||||
{operations}
|
||||
</div>
|
||||
{
|
||||
!collapsedMerged && children
|
||||
|
||||
@ -8,15 +8,20 @@ type Props = {
|
||||
className?: string
|
||||
title?: string
|
||||
children: ReactNode
|
||||
operations?: ReactNode
|
||||
}
|
||||
|
||||
const OutputVars: FC<Props> = ({
|
||||
title,
|
||||
children,
|
||||
operations,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<FieldCollapse title={title || t('workflow.nodes.common.outputVars')}>
|
||||
<FieldCollapse
|
||||
title={title || t('workflow.nodes.common.outputVars')}
|
||||
operations={operations}
|
||||
>
|
||||
{children}
|
||||
</FieldCollapse>
|
||||
)
|
||||
@ -40,9 +45,11 @@ export const VarItem: FC<VarItemProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className='py-1'>
|
||||
<div className='flex leading-[18px] items-center'>
|
||||
<div className='code-sm-semibold text-text-secondary'>{name}</div>
|
||||
<div className='ml-2 system-xs-regular text-text-tertiary'>{type}</div>
|
||||
<div className='flex justify-between'>
|
||||
<div className='flex leading-[18px] items-center'>
|
||||
<div className='code-sm-semibold text-text-secondary'>{name}</div>
|
||||
<div className='ml-2 system-xs-regular text-text-tertiary'>{type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-0.5 system-xs-regular text-text-tertiary'>
|
||||
{description}
|
||||
|
||||
@ -36,6 +36,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
|
||||
@ -143,6 +144,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 && s.gradientBorder) : 'bg-gray-100', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}>
|
||||
@ -249,6 +252,7 @@ const Editor: FC<Props> = ({
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: nodesOutputVars || [],
|
||||
getVarType,
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
|
||||
@ -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 pr-2 items-center justify-between rounded-md', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
onClick={() => !readonly && onSelect?.([...valueSelector, name])}
|
||||
>
|
||||
<div className='grow flex items-stretch'>
|
||||
<TreeIndentLine depth={depth} />
|
||||
{depth === MAX_DEPTH + 1 ? (
|
||||
<RiMoreFill className='w-3 h-3 text-text-tertiary' />
|
||||
) : (<div className={cn('h-6 leading-6 grow w-0 truncate system-sm-medium text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
|
||||
|
||||
</div>
|
||||
{depth < MAX_DEPTH + 1 && (
|
||||
<div className='ml-2 shrink-0 system-xs-regular 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)
|
||||
@ -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='px-2 py-1 flex justify-between items-center'>
|
||||
<div className='flex'>
|
||||
{root.nodeName && (
|
||||
<>
|
||||
<div className='max-w-[100px] truncate system-sm-medium 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='shrink-0 ml-2 system-xs-regular 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] p-1 pb-0 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]', className)}>
|
||||
<PickerPanelMain {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(PickerPanel)
|
||||
@ -0,0 +1,71 @@
|
||||
'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,
|
||||
}
|
||||
|
||||
const Field: FC<Props> = ({
|
||||
name,
|
||||
payload,
|
||||
depth = 1,
|
||||
required,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
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='grow'>
|
||||
<div className='flex relative select-none'>
|
||||
{hasChildren && (
|
||||
<RiArrowDropDownLine
|
||||
className={cn('absolute top-[50%] translate-y-[-50%] left-[-18px] bg-components-panel-bg w-4 h-4 text-text-tertiary cursor-pointer', fold && 'rotate-[270deg] text-text-accent')}
|
||||
onClick={toggleFold}
|
||||
/>
|
||||
)}
|
||||
<div className='h-6 truncate system-sm-medium text-text-secondary leading-6'>{name}</div>
|
||||
<div className='ml-3 shrink-0 system-xs-regular text-text-tertiary leading-6'>{getFieldType(payload)}</div>
|
||||
{required && <div className='ml-3 text-text-warning system-2xs-medium-uppercase leading-6'>{t('app.structOutput.required')}</div>}
|
||||
</div>
|
||||
{payload.description && (
|
||||
<div className='flex'>
|
||||
<div className='w-0 grow system-xs-regular text-text-tertiary truncate'>{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)
|
||||
@ -0,0 +1,33 @@
|
||||
'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
|
||||
}
|
||||
|
||||
const ShowPanel: FC<Props> = ({
|
||||
payload,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const schema = {
|
||||
...payload,
|
||||
schema: {
|
||||
...payload.schema,
|
||||
description: t('app.structOutput.LLMResponse'),
|
||||
},
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Field
|
||||
name={'response'}
|
||||
payload={schema.schema}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ShowPanel)
|
||||
@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import mockStructData from '@/app/components/workflow/nodes/llm/mock-struct-data'
|
||||
import VarFullPathPanel from '../var-full-path-panel'
|
||||
import PickerPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
|
||||
import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
|
||||
import { Type } from '../../../../llm/types'
|
||||
|
||||
const Test: FC = () => {
|
||||
return (
|
||||
<div className='mb-2 space-y-2'>
|
||||
<VarFullPathPanel
|
||||
nodeName='LLM'
|
||||
path={['memory', 'content', 'text']}
|
||||
varType={Type.string}
|
||||
/>
|
||||
<div className='my-2 w-[404px] bg-white'>
|
||||
<ShowPanel
|
||||
payload={mockStructData}
|
||||
/>
|
||||
</div>
|
||||
<PickerPanel
|
||||
root={{ nodeName: 'LLM', attrName: 'structured_output' }}
|
||||
payload={mockStructData}
|
||||
onSelect={() => { }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Test)
|
||||
@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
depth?: number
|
||||
}
|
||||
|
||||
const TreeIndentLine: FC<Props> = ({
|
||||
depth = 1,
|
||||
}) => {
|
||||
const depthArray = Array.from({ length: depth }, (_, index) => index)
|
||||
return (
|
||||
<div className='ml-2.5 mr-2.5 flex space-x-[12px]'>
|
||||
{depthArray.map(d => (
|
||||
<div key={d} className={cn('w-px bg-divider-regular')}></div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TreeIndentLine)
|
||||
@ -3,7 +3,7 @@ import { isArray, uniq } from 'lodash-es'
|
||||
import type { CodeNodeType } from '../../../code/types'
|
||||
import type { EndNodeType } from '../../../end/types'
|
||||
import type { AnswerNodeType } from '../../../answer/types'
|
||||
import type { LLMNodeType } from '../../../llm/types'
|
||||
import { type LLMNodeType, type StructuredOutput, Type } from '../../../llm/types'
|
||||
import type { KnowledgeRetrievalNodeType } from '../../../knowledge-retrieval/types'
|
||||
import type { IfElseNodeType } from '../../../if-else/types'
|
||||
import type { TemplateTransformNodeType } from '../../../template-transform/types'
|
||||
@ -20,6 +20,9 @@ import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/type
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { ConversationVariable, EnvironmentVariable, Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types'
|
||||
import mockStructData from '@/app/components/workflow/nodes/llm/mock-struct-data'
|
||||
import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types'
|
||||
|
||||
import {
|
||||
HTTP_REQUEST_OUTPUT_STRUCT,
|
||||
KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT,
|
||||
@ -54,19 +57,81 @@ const inputVarTypeToVarType = (type: InputVarType): VarType => {
|
||||
} as any)[type] || VarType.string
|
||||
}
|
||||
|
||||
const structTypeToVarType = (type: Type): VarType => {
|
||||
return ({
|
||||
[Type.string]: VarType.string,
|
||||
[Type.number]: VarType.number,
|
||||
[Type.boolean]: VarType.boolean,
|
||||
[Type.object]: VarType.object,
|
||||
[Type.array]: VarType.array,
|
||||
} as any)[type] || VarType.string
|
||||
}
|
||||
|
||||
export const varTypeToStructType = (type: VarType): Type => {
|
||||
return ({
|
||||
[VarType.string]: Type.string,
|
||||
[VarType.number]: Type.number,
|
||||
[VarType.boolean]: Type.boolean,
|
||||
[VarType.object]: Type.object,
|
||||
[VarType.array]: Type.array,
|
||||
} as any)[type] || Type.string
|
||||
}
|
||||
|
||||
const findExceptVarInStructuredProperties = (properties: Record<string, StructField>, filterVar: (payload: Var, selector: ValueSelector) => boolean): Record<string, StructField> => {
|
||||
const res = produce(properties, (draft) => {
|
||||
Object.keys(properties).forEach((key) => {
|
||||
const item = properties[key]
|
||||
const isObj = item.type === Type.object
|
||||
if (!isObj && !filterVar({
|
||||
variable: key,
|
||||
type: structTypeToVarType(item.type),
|
||||
}, [key])) {
|
||||
delete properties[key]
|
||||
return
|
||||
}
|
||||
if (item.type === Type.object && item.properties)
|
||||
item.properties = findExceptVarInStructuredProperties(item.properties, filterVar)
|
||||
})
|
||||
return draft
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
const findExceptVarInStructuredOutput = (structuredOutput: StructuredOutput, filterVar: (payload: Var, selector: ValueSelector) => boolean): StructuredOutput => {
|
||||
const res = produce(structuredOutput, (draft) => {
|
||||
const properties = draft.schema.properties
|
||||
Object.keys(properties).forEach((key) => {
|
||||
const item = properties[key]
|
||||
const isObj = item.type === Type.object
|
||||
if (!isObj && !filterVar({
|
||||
variable: key,
|
||||
type: structTypeToVarType(item.type),
|
||||
}, [key])) {
|
||||
delete properties[key]
|
||||
return
|
||||
}
|
||||
if (item.type === Type.object && item.properties)
|
||||
item.properties = findExceptVarInStructuredProperties(item.properties, filterVar)
|
||||
})
|
||||
return draft
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
const findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: ValueSelector) => boolean, value_selector: ValueSelector, isFile?: boolean): Var => {
|
||||
const { children } = obj
|
||||
const isStructuredOutput = !!(children as StructuredOutput)?.schema?.properties
|
||||
|
||||
const res: Var = {
|
||||
variable: obj.variable,
|
||||
type: isFile ? VarType.file : VarType.object,
|
||||
children: children.filter((item: Var) => {
|
||||
children: isStructuredOutput ? findExceptVarInStructuredOutput(children, filterVar) : children.filter((item: Var) => {
|
||||
const { children } = item
|
||||
const currSelector = [...value_selector, item.variable]
|
||||
if (!children)
|
||||
return filterVar(item, currSelector)
|
||||
|
||||
const obj = findExceptVarInObject(item, filterVar, currSelector, false) // File doesn't contains file children
|
||||
return obj.children && obj.children?.length > 0
|
||||
return obj.children && (obj.children as Var[])?.length > 0
|
||||
}),
|
||||
}
|
||||
return res
|
||||
@ -138,7 +203,14 @@ const formatItem = (
|
||||
}
|
||||
|
||||
case BlockEnum.LLM: {
|
||||
res.vars = LLM_OUTPUT_STRUCT
|
||||
res.vars = [
|
||||
...LLM_OUTPUT_STRUCT,
|
||||
{
|
||||
variable: 'structured_output',
|
||||
type: VarType.object,
|
||||
children: mockStructData,
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
@ -404,7 +476,7 @@ const formatItem = (
|
||||
return false
|
||||
|
||||
const obj = findExceptVarInObject(isFile ? { ...v, children } : v, filterVar, selector, isFile)
|
||||
return obj?.children && obj?.children.length > 0
|
||||
return obj?.children && ((obj?.children as Var[]).length > 0 || Object.keys((obj?.children as StructuredOutput)?.schema?.properties || {}).length > 0)
|
||||
}).map((v) => {
|
||||
const isFile = v.type === VarType.file
|
||||
|
||||
@ -428,6 +500,9 @@ const formatItem = (
|
||||
return findExceptVarInObject(isFile ? { ...v, children } : v, filterVar, selector, isFile)
|
||||
})
|
||||
|
||||
// if (res.nodeId === 'llm')
|
||||
// console.log(res)
|
||||
|
||||
return res
|
||||
}
|
||||
export const toNodeOutputVars = (
|
||||
@ -527,8 +602,7 @@ export const getVarType = ({
|
||||
isConstant,
|
||||
environmentVariables = [],
|
||||
conversationVariables = [],
|
||||
}:
|
||||
{
|
||||
}: {
|
||||
valueSelector: ValueSelector
|
||||
parentNode?: Node | null
|
||||
isIterationItem?: boolean
|
||||
@ -582,10 +656,30 @@ export const getVarType = ({
|
||||
|
||||
let type: VarType = VarType.string
|
||||
let curr: any = targetVar.vars
|
||||
|
||||
if (isSystem || isEnv || isChatVar) {
|
||||
return curr.find((v: any) => v.variable === (valueSelector as ValueSelector).join('.'))?.type
|
||||
}
|
||||
else {
|
||||
const targetVar = curr.find((v: any) => v.variable === valueSelector[1])
|
||||
if (!targetVar)
|
||||
return VarType.string
|
||||
|
||||
const isStructuredOutputVar = !!targetVar.children?.schema?.properties
|
||||
if (isStructuredOutputVar) {
|
||||
let currProperties = targetVar.children.schema;
|
||||
(valueSelector as ValueSelector).slice(2).forEach((key, i) => {
|
||||
const isLast = i === valueSelector.length - 3
|
||||
if (!currProperties)
|
||||
return
|
||||
|
||||
currProperties = currProperties.properties[key]
|
||||
if (isLast)
|
||||
type = structTypeToVarType(currProperties?.type)
|
||||
})
|
||||
return type
|
||||
}
|
||||
|
||||
(valueSelector as ValueSelector).slice(1).forEach((key, i) => {
|
||||
const isLast = i === valueSelector.length - 2
|
||||
if (Array.isArray(curr))
|
||||
@ -1089,17 +1183,27 @@ export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, new
|
||||
})
|
||||
return newNode
|
||||
}
|
||||
|
||||
const varToValueSelectorList = (v: Var, parentValueSelector: ValueSelector, res: ValueSelector[]) => {
|
||||
if (!v.variable)
|
||||
return
|
||||
|
||||
res.push([...parentValueSelector, v.variable])
|
||||
const isStructuredOutput = !!(v.children as StructuredOutput)?.schema?.properties
|
||||
|
||||
if (v.children && v.children.length > 0) {
|
||||
v.children.forEach((child) => {
|
||||
if ((v.children as Var[])?.length > 0) {
|
||||
(v.children as Var[]).forEach((child) => {
|
||||
varToValueSelectorList(child, [...parentValueSelector, v.variable], res)
|
||||
})
|
||||
}
|
||||
if (isStructuredOutput) {
|
||||
Object.keys((v.children as StructuredOutput)?.schema?.properties || {}).forEach((key) => {
|
||||
varToValueSelectorList({
|
||||
variable: key,
|
||||
type: structTypeToVarType((v.children as StructuredOutput)?.schema?.properties[key].type),
|
||||
}, [...parentValueSelector, v.variable], res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const varsToValueSelectorList = (vars: Var | Var[], parentValueSelector: ValueSelector, res: ValueSelector[]) => {
|
||||
@ -1133,7 +1237,15 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto
|
||||
}
|
||||
|
||||
case BlockEnum.LLM: {
|
||||
varsToValueSelectorList(LLM_OUTPUT_STRUCT, [id], res)
|
||||
varsToValueSelectorList([
|
||||
...LLM_OUTPUT_STRUCT,
|
||||
{
|
||||
variable: 'structured_output',
|
||||
type: VarType.object,
|
||||
children: mockStructData,
|
||||
},
|
||||
], [id], res)
|
||||
console.log(res)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@ -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] pb-0 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||
<div className='flex p-3 pb-2 border-b-[0.5px] border-divider-subtle space-x-1 '>
|
||||
<BlockIcon size='xs' type={nodeType} />
|
||||
<div className='w-0 grow system-xs-medium text-text-secondary truncate'>{nodeName}</div>
|
||||
</div>
|
||||
<Panel
|
||||
className='pt-2 pb-3 px-1'
|
||||
root={{ attrName: path[0] }}
|
||||
payload={schema}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(VarFullPathPanel)
|
||||
@ -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'
|
||||
|
||||
const TRIGGER_DEFAULT_WIDTH = 227
|
||||
|
||||
@ -156,16 +158,15 @@ const VarReferencePicker: FC<Props> = ({
|
||||
return getNodeInfoById(availableNodes, outputVarNodeId)?.data
|
||||
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode])
|
||||
|
||||
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 = [
|
||||
@ -253,6 +254,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
|
||||
@ -317,7 +334,7 @@ const VarReferencePicker: FC<Props> = ({
|
||||
className='grow h-full'
|
||||
>
|
||||
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center pl-1 py-1 rounded-lg bg-gray-100')}>
|
||||
<Tooltip popupContent={!isValidVar && hasValue && t('workflow.errorMsg.invalidVariable')}>
|
||||
<Tooltip popupContent={tooltipPopup} noDecoration={isShowAPart}>
|
||||
<div className={cn('h-full items-center px-1.5 rounded-[5px]', hasValue ? 'bg-white inline-flex' : 'flex')}>
|
||||
{hasValue
|
||||
? (
|
||||
@ -336,6 +353,12 @@ const VarReferencePicker: FC<Props> = ({
|
||||
<Line3 className='mr-0.5'></Line3>
|
||||
</div>
|
||||
)}
|
||||
{isShowAPart && (
|
||||
<div className='flex items-center'>
|
||||
<RiMoreLine className='w-3 h-3 text-text-secondary' />
|
||||
<Line3 className='mr-0.5 text-divider-deep'></Line3>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center text-primary-600'>
|
||||
{!hasValue && <Variable02 className='w-3.5 h-3.5' />}
|
||||
{isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
|
||||
|
||||
@ -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,20 +15,14 @@ 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'
|
||||
|
||||
interface ObjectChildrenProps {
|
||||
nodeId: string
|
||||
title: string
|
||||
data: Var[]
|
||||
objPath: string[]
|
||||
onChange: (value: ValueSelector, item: Var) => void
|
||||
onHovering?: (value: boolean) => void
|
||||
itemWidth?: number
|
||||
isSupportFileVar?: boolean
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
type ItemProps = {
|
||||
nodeId: string
|
||||
title: string
|
||||
objPath: string[]
|
||||
@ -47,15 +41,40 @@ const Item: FC<ItemProps> = ({
|
||||
itemData,
|
||||
onChange,
|
||||
onHovering,
|
||||
itemWidth,
|
||||
isSupportFileVar,
|
||||
isException,
|
||||
}) => {
|
||||
const isFile = itemData.type === VarType.file
|
||||
const isObj = ([VarType.object, VarType.file].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(null)
|
||||
const [isItemHovering, setIsItemHovering] = useState(false)
|
||||
const _ = useHover(itemRef, {
|
||||
@ -64,7 +83,7 @@ const Item: FC<ItemProps> = ({
|
||||
setIsItemHovering(true)
|
||||
}
|
||||
else {
|
||||
if (isObj) {
|
||||
if (isObj || isStructureOutput) {
|
||||
setTimeout(() => {
|
||||
setIsItemHovering(false)
|
||||
}, 100)
|
||||
@ -77,7 +96,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
|
||||
@ -104,8 +123,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 w-full flex items-center h-6 pl-3 rounded-md cursor-pointer')
|
||||
}
|
||||
onClick={handleChosen}
|
||||
@ -125,7 +144,7 @@ const Item: FC<ItemProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className='ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize'>{itemData.type}</div>
|
||||
{isObj && (
|
||||
{(isObj || isStructureOutput) && (
|
||||
<ChevronRight className={cn('ml-0.5 w-3 h-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
|
||||
)}
|
||||
</div>
|
||||
@ -133,30 +152,14 @@ const Item: FC<ItemProps> = ({
|
||||
<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>
|
||||
@ -164,69 +167,7 @@ const Item: FC<ItemProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const ObjectChildren: FC<ObjectChildrenProps> = ({
|
||||
title,
|
||||
nodeId,
|
||||
objPath,
|
||||
data,
|
||||
onChange,
|
||||
onHovering,
|
||||
itemWidth,
|
||||
isSupportFileVar,
|
||||
}) => {
|
||||
const currObjPath = objPath
|
||||
const itemRef = useRef(null)
|
||||
const [isItemHovering, setIsItemHovering] = useState(false)
|
||||
const _ = useHover(itemRef, {
|
||||
onChange: (hovering) => {
|
||||
if (hovering) {
|
||||
setIsItemHovering(true)
|
||||
}
|
||||
else {
|
||||
setTimeout(() => {
|
||||
setIsItemHovering(false)
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
})
|
||||
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
|
||||
const isHovering = isItemHovering || isChildrenHovering
|
||||
useEffect(() => {
|
||||
onHovering && onHovering(isHovering)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isHovering])
|
||||
useEffect(() => {
|
||||
onHovering && onHovering(isItemHovering)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isItemHovering])
|
||||
// absolute top-[-2px]
|
||||
return (
|
||||
<div ref={itemRef} className=' bg-white rounded-lg border border-gray-200 shadow-lg space-y-1' style={{
|
||||
right: itemWidth ? itemWidth - 10 : 215,
|
||||
minWidth: 252,
|
||||
}}>
|
||||
<div className='flex items-center h-[22px] px-3 text-xs font-normal text-gray-700'><span className='text-gray-500'>{title}.</span>{currObjPath.join('.')}</div>
|
||||
{
|
||||
(data && data.length > 0)
|
||||
&& data.map((v, i) => (
|
||||
<Item
|
||||
key={i}
|
||||
nodeId={nodeId}
|
||||
title={title}
|
||||
objPath={objPath}
|
||||
itemData={v}
|
||||
onChange={onChange}
|
||||
onHovering={setIsChildrenHovering}
|
||||
isSupportFileVar={isSupportFileVar}
|
||||
isException={v.isException}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
hideSearch?: boolean
|
||||
searchBoxClassName?: string
|
||||
vars: NodeOutPutVar[]
|
||||
@ -322,7 +263,7 @@ const VarReferenceVars: FC<Props> = ({
|
||||
}
|
||||
</div>
|
||||
: <div className='pl-3 leading-[18px] text-xs font-medium text-gray-500 uppercase'>{t('workflow.common.noVar')}</div>}
|
||||
</ >
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(VarReferenceVars)
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { createJsonSchemaConfigStore } from './store'
|
||||
import { useMitt } from '@/hooks/use-mitt'
|
||||
|
||||
type JsonSchemaConfigStore = ReturnType<typeof createJsonSchemaConfigStore>
|
||||
|
||||
type JsonSchemaConfigContextType = JsonSchemaConfigStore | null
|
||||
|
||||
type JsonSchemaConfigProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const JsonSchemaConfigContext = createContext<JsonSchemaConfigContextType>(null)
|
||||
|
||||
export const JsonSchemaConfigContextProvider = ({ children }: JsonSchemaConfigProviderProps) => {
|
||||
const storeRef = useRef<JsonSchemaConfigStore>()
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createJsonSchemaConfigStore()
|
||||
|
||||
return (
|
||||
<JsonSchemaConfigContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</JsonSchemaConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const MittContext = createContext<ReturnType<typeof useMitt>>({
|
||||
emit: () => {},
|
||||
useSubscribe: () => {},
|
||||
})
|
||||
|
||||
export const MittProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const mitt = useMitt()
|
||||
|
||||
return (
|
||||
<MittContext.Provider value={mitt}>
|
||||
{children}
|
||||
</MittContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useMittContext = () => {
|
||||
return useContext(MittContext)
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import React, { type FC } from 'react'
|
||||
import Modal from '../../../../../base/modal'
|
||||
import type { SchemaRoot } from '../../types'
|
||||
import JsonSchemaConfig from './json-schema-config'
|
||||
import { JsonSchemaConfigContextProvider, MittProvider } from './context'
|
||||
|
||||
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='max-w-[960px] h-[800px] p-0'
|
||||
>
|
||||
<MittProvider>
|
||||
<JsonSchemaConfigContextProvider>
|
||||
<JsonSchemaConfig
|
||||
defaultSchema={defaultSchema}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</JsonSchemaConfigContextProvider>
|
||||
</MittProvider >
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigModal
|
||||
@ -0,0 +1,196 @@
|
||||
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 { RiClipboardLine, RiCloseLine, RiErrorWarningFill, RiIndentIncrease } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Editor } from '@monaco-editor/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type JsonImporterProps = {
|
||||
onSubmit: (schema: string) => 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 monacoRef = useRef<any>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (importBtnRef.current) {
|
||||
const rect = importBtnRef.current.getBoundingClientRect()
|
||||
updateBtnWidth(rect.width)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
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.setTheme('light-theme')
|
||||
}, [])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (!value)
|
||||
return
|
||||
setJson(value)
|
||||
}, [])
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
setOpen(!open)
|
||||
}, [open])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const formatJsonContent = useCallback(() => {
|
||||
if (editorRef.current)
|
||||
editorRef.current.getAction('editor.action.formatDocument')?.run()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
try {
|
||||
const parsedJSON = JSON.parse(json)
|
||||
onSubmit(parsedJSON)
|
||||
setParseError(null)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e instanceof SyntaxError)
|
||||
setParseError(e)
|
||||
else
|
||||
setParseError(new Error('Unknown error'))
|
||||
}
|
||||
}, [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(
|
||||
'flex shrink-0 px-1.5 py-1 rounded-md hover:bg-components-button-ghost-bg-hover text-text-tertiary system-xs-medium',
|
||||
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 flex-col w-[400px] 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 pt-3.5 pb-1'>
|
||||
<div className='flex items-center justify-center absolute right-2.5 bottom-0 w-8 h-8' onClick={onClose}>
|
||||
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='flex pl-1 pr-8 text-text-primary system-xl-semibold'>
|
||||
{t('workflow.nodes.llm.jsonSchema.import')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='px-4 py-2'>
|
||||
<div className='flex flex-col h-full rounded-lg bg-components-input-bg-normal overflow-hidden'>
|
||||
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
||||
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
||||
<span className='px-1 py-0.5'>JSON</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center h-6 w-6'
|
||||
onClick={formatJsonContent}
|
||||
>
|
||||
<RiIndentIncrease className='w-4 h-4 text-text-tertiary' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center h-6 w-6'
|
||||
onClick={() => copy(json)}>
|
||||
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative h-[340px]'>
|
||||
<Editor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
value={json}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
readOnly: false,
|
||||
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>
|
||||
{parseError && (
|
||||
<div className='flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg'>
|
||||
<RiErrorWarningFill className='shrink-0 w-4 h-4 text-text-destructive' />
|
||||
<div className='grow text-text-primary system-xs-medium'>
|
||||
{parseError.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonImporter
|
||||
@ -0,0 +1,572 @@
|
||||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { ArrayType, type Field, 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 { useJsonSchemaConfigStore } from './store'
|
||||
import { useMittContext } from './context'
|
||||
import type { EditData } from './visual-editor/edit-card'
|
||||
import produce from 'immer'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
type ChangeEventParams = {
|
||||
path: string[],
|
||||
parentPath: string[],
|
||||
oldFields: EditData,
|
||||
fields: EditData,
|
||||
}
|
||||
|
||||
type AddEventParams = {
|
||||
path: string[]
|
||||
}
|
||||
|
||||
const findPropertyWithPath = (target: any, path: string[]) => {
|
||||
let current = target
|
||||
for (const key of path)
|
||||
current = current[key]
|
||||
return current
|
||||
}
|
||||
|
||||
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const backupSchema = useJsonSchemaConfigStore(state => state.backupSchema)
|
||||
const setBackupSchema = useJsonSchemaConfigStore(state => state.setBackupSchema)
|
||||
const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField)
|
||||
const setHoveringProperty = useJsonSchemaConfigStore(state => state.setHoveringProperty)
|
||||
const { emit, useSubscribe } = useMittContext()
|
||||
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)
|
||||
|
||||
useSubscribe('restoreSchema', () => {
|
||||
if (backupSchema) {
|
||||
setJsonSchema(backupSchema)
|
||||
setBackupSchema(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
|
||||
}
|
||||
})
|
||||
setJsonSchema(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 = []
|
||||
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: [],
|
||||
}
|
||||
break
|
||||
default:
|
||||
schema.type = newType as Type
|
||||
}
|
||||
})
|
||||
setJsonSchema(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
|
||||
}
|
||||
})
|
||||
setJsonSchema(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
|
||||
})
|
||||
setJsonSchema(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)
|
||||
}
|
||||
})
|
||||
setJsonSchema(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('addField', (params) => {
|
||||
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,
|
||||
description: '',
|
||||
enum: [],
|
||||
},
|
||||
}
|
||||
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,
|
||||
description: '',
|
||||
enum: [],
|
||||
},
|
||||
}
|
||||
setHoveringProperty([...path, 'items', 'properties', ''].join('.'))
|
||||
}
|
||||
})
|
||||
setJsonSchema(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('fieldChange', (params) => {
|
||||
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',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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 = []
|
||||
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: [],
|
||||
}
|
||||
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',
|
||||
})
|
||||
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 = 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 = []
|
||||
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: [],
|
||||
}
|
||||
break
|
||||
default:
|
||||
schema.type = newType as Type
|
||||
}
|
||||
}
|
||||
|
||||
// other options change
|
||||
schema.description = fields.description
|
||||
schema.enum = fields.enum
|
||||
}
|
||||
})
|
||||
setJsonSchema(newSchema)
|
||||
emit('fieldChangeSuccess')
|
||||
})
|
||||
|
||||
const updateBtnWidth = useCallback((width: number) => {
|
||||
setBtnWidth(width + 32)
|
||||
}, [])
|
||||
|
||||
const handleApplySchema = useCallback(() => {}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {}, [])
|
||||
|
||||
const handleSchemaEditorUpdate = useCallback((schema: string) => {
|
||||
setJson(schema)
|
||||
}, [])
|
||||
|
||||
const handleResetDefaults = useCallback(() => {
|
||||
setJsonSchema(defaultSchema || DEFAULT_SCHEMA)
|
||||
}, [defaultSchema])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(jsonSchema)
|
||||
onClose()
|
||||
}, [jsonSchema, onSave, onClose])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
{/* Header */}
|
||||
<div className='relative flex p-6 pr-14 pb-3'>
|
||||
<div className='text-text-primary title-2xl-semi-bold grow truncate'>
|
||||
{t('workflow.nodes.llm.jsonSchema.title')}
|
||||
</div>
|
||||
<div className='absolute right-5 top-5 w-8 h-8 flex justify-center items-center p-1.5' onClick={onClose}>
|
||||
<RiCloseLine className='w-[18px] h-[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={(value: SchemaView) => {
|
||||
setCurrentTab(value)
|
||||
}}
|
||||
/>
|
||||
<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='px-6 grow overflow-hidden'>
|
||||
{currentTab === SchemaView.VisualEditor && (
|
||||
<VisualEditor schema={jsonSchema} />
|
||||
)}
|
||||
{currentTab === SchemaView.JsonSchema && (
|
||||
<SchemaEditor
|
||||
schema={json}
|
||||
onUpdate={handleSchemaEditorUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className='flex items-center p-6 pt-5 gap-x-2'>
|
||||
<a
|
||||
className='flex items-center gap-x-1 grow text-text-accent'
|
||||
href='https://json-schema.org/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<span className='system-xs-regular'>{t('workflow.nodes.llm.jsonSchema.doc')}</span>
|
||||
<RiExternalLinkLine className='w-3 h-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='h-4 ml-1 mr-0' />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfig
|
||||
@ -0,0 +1,7 @@
|
||||
import SchemaGeneratorLight from './schema-generator-light'
|
||||
import SchemaGeneratorDark from './schema-generator-dark'
|
||||
|
||||
export {
|
||||
SchemaGeneratorLight,
|
||||
SchemaGeneratorDark,
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,158 @@
|
||||
import React, { type FC, useCallback, useRef, useState } from 'react'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import { RiArrowLeftLine, RiClipboardLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type GeneratedResultProps = {
|
||||
schema: SchemaRoot
|
||||
onBack: () => void
|
||||
onRegenerate: () => void
|
||||
onClose: () => void
|
||||
onApply: (schema: any) => void
|
||||
}
|
||||
|
||||
const GeneratedResult: FC<GeneratedResultProps> = ({
|
||||
schema,
|
||||
onBack,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
onApply,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const monacoRef = useRef<any>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
|
||||
const formatJSON = (json: any): string => {
|
||||
try {
|
||||
if (typeof json === 'string') {
|
||||
const parsed = JSON.parse(json)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
return JSON.stringify(json, null, 2)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to format JSON:', e)
|
||||
return typeof json === 'string' ? json : JSON.stringify(json)
|
||||
}
|
||||
}
|
||||
|
||||
const [jsonSchema, setJsonSchema] = useState(formatJSON(schema))
|
||||
|
||||
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.setTheme('light-theme')
|
||||
}, [])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (!value)
|
||||
return
|
||||
setJsonSchema(value)
|
||||
}, [])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
try {
|
||||
// Parse the JSON to ensure it's valid before applying
|
||||
const parsedJSON = JSON.parse(jsonSchema)
|
||||
onApply(parsedJSON)
|
||||
}
|
||||
catch {
|
||||
// TODO: Handle invalid JSON error
|
||||
}
|
||||
}, [jsonSchema, onApply])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-[480px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
<div className='flex items-center justify-center absolute top-2.5 right-2.5 w-8 h-8' onClick={onClose}>
|
||||
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className='flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1'>
|
||||
<div className='flex pl-1 pr-8 text-text-primary system-xl-semibold'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generatedResult')}
|
||||
</div>
|
||||
<div className='flex px-1 text-text-tertiary system-xs-regular'>
|
||||
{t('workflow.nodes.llm.jsonSchema.resultTip')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='w-full h-[468px] px-4 py-2'>
|
||||
<div className='flex flex-col h-full rounded-lg bg-components-input-bg-normal overflow-hidden'>
|
||||
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
||||
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
||||
<span className='px-1 py-0.5'>JSON</span>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center h-6 w-6'
|
||||
onClick={() => copy(jsonSchema)}>
|
||||
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='relative grow'>
|
||||
<Editor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
value={jsonSchema}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
readOnly: true,
|
||||
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>
|
||||
</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='w-4 h-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='w-4 h-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 GeneratedResult
|
||||
@ -0,0 +1,132 @@
|
||||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { type SchemaRoot, Type } from '../../../types'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
|
||||
import cn from '@/utils/classnames'
|
||||
import PromptEditor from './prompt-editor'
|
||||
import GeneratedResult from './generated-result'
|
||||
|
||||
type JsonSchemaGeneratorProps = {
|
||||
onApply: (schema: SchemaRoot) => void
|
||||
crossAxisOffset?: number
|
||||
}
|
||||
|
||||
enum GeneratorView {
|
||||
promptEditor = 'promptEditor',
|
||||
result = 'result',
|
||||
}
|
||||
|
||||
export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
onApply,
|
||||
crossAxisOffset,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const [view, setView] = useState(GeneratorView.promptEditor)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [schema, setSchema] = useState<SchemaRoot | null>(null)
|
||||
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
setOpen(!open)
|
||||
}, [open])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const generateSchema = useCallback(async () => {
|
||||
// todo: fetch schema, delete mock data
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
setSchema({
|
||||
type: Type.object,
|
||||
properties: {
|
||||
string_field_1: {
|
||||
type: Type.string,
|
||||
description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空',
|
||||
},
|
||||
string_field_2: {
|
||||
type: Type.string,
|
||||
description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'string_field_1',
|
||||
],
|
||||
additionalProperties: false,
|
||||
})
|
||||
resolve()
|
||||
}, 1000)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
await generateSchema()
|
||||
setView(GeneratorView.result)
|
||||
}, [generateSchema])
|
||||
|
||||
const goBackToPromptEditor = () => {
|
||||
setView(GeneratorView.promptEditor)
|
||||
}
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
await generateSchema()
|
||||
}, [generateSchema])
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(schema!)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: crossAxisOffset ?? 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'w-6 h-6 flex items-center justify-center p-0.5 rounded-md hover:bg-state-accent-hover',
|
||||
open && 'bg-state-accent-active',
|
||||
)}
|
||||
>
|
||||
<SchemaGenerator />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
{view === GeneratorView.promptEditor && (
|
||||
<PromptEditor
|
||||
instruction={instruction}
|
||||
onInstructionChange={setInstruction}
|
||||
onGenerate={handleGenerate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{view === GeneratorView.result && (
|
||||
<GeneratedResult
|
||||
schema={schema!}
|
||||
onBack={goBackToPromptEditor}
|
||||
onRegenerate={handleRegenerate}
|
||||
onApply={handleApply}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaGenerator
|
||||
@ -0,0 +1,88 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type PromptEditorProps = {
|
||||
instruction: string
|
||||
onInstructionChange: (instruction: string) => void
|
||||
onClose: () => void
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
instruction,
|
||||
onInstructionChange,
|
||||
onClose,
|
||||
onGenerate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
activeTextGenerationModelList,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList()
|
||||
|
||||
const handleChangeModel = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col relative w-[480px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
<div className='flex items-center justify-center absolute top-2.5 right-2.5 w-8 h-8' onClick={onClose}>
|
||||
<RiCloseLine className='w-4 h-4 text-text-tertiary'/>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className='flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1'>
|
||||
<div className='flex pl-1 pr-8 text-text-primary system-xl-semibold'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generateJsonSchema')}
|
||||
</div>
|
||||
<div className='flex px-1 text-text-tertiary system-xs-regular'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generationTip')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='flex flex-col gap-y-1 px-4 py-2'>
|
||||
<div className='flex items-center h-6 text-text-secondary system-sm-semibold-uppercase'>
|
||||
{t('common.modelProvider.model')}
|
||||
</div>
|
||||
<ModelSelector
|
||||
modelList={activeTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 px-4 py-2'>
|
||||
<div className='flex items-center h-6 text-text-secondary system-sm-semibold-uppercase'>
|
||||
<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] px-2 py-1 resize-none'
|
||||
value={instruction}
|
||||
placeholder={t('workflow.nodes.llm.jsonSchema.promptPlaceholder')}
|
||||
onChange={e => onInstructionChange(e.target.value)}
|
||||
/>
|
||||
</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='w-4 h-4' />
|
||||
<span>{t('workflow.nodes.llm.jsonSchema.generate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptEditor
|
||||
@ -0,0 +1,102 @@
|
||||
import { Editor } from '@monaco-editor/react'
|
||||
import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import React, { type FC, useCallback, useRef } from 'react'
|
||||
|
||||
type SchemaEditorProps = {
|
||||
schema: string
|
||||
onUpdate: (schema: string) => void
|
||||
}
|
||||
|
||||
const SchemaEditor: FC<SchemaEditorProps> = ({
|
||||
schema,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const monacoRef = useRef<any>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
|
||||
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.setTheme('light-theme')
|
||||
}, [])
|
||||
|
||||
const formatJsonContent = useCallback(() => {
|
||||
if (editorRef.current)
|
||||
editorRef.current.getAction('editor.action.formatDocument')?.run()
|
||||
}, [])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (!value)
|
||||
return
|
||||
onUpdate(value)
|
||||
}, [onUpdate])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full rounded-xl bg-components-input-bg-normal overflow-hidden'>
|
||||
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
||||
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
||||
<span className='px-1 py-0.5'>JSON</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center h-6 w-6'
|
||||
onClick={formatJsonContent}
|
||||
>
|
||||
<RiIndentIncrease className='w-4 h-4 text-text-tertiary' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center h-6 w-6'
|
||||
onClick={() => copy(schema)}>
|
||||
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative grow'>
|
||||
<Editor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
value={schema}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
readOnly: false,
|
||||
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 SchemaEditor
|
||||
@ -0,0 +1,34 @@
|
||||
import { useContext } from 'react'
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import type { SchemaRoot } from '../../types'
|
||||
import { JsonSchemaConfigContext } from './context'
|
||||
|
||||
type JsonSchemaConfigStore = {
|
||||
hoveringProperty: string | ''
|
||||
setHoveringProperty: (propertyPath: string) => void
|
||||
isAddingNewField: boolean
|
||||
setIsAddingNewField: (isAdding: boolean) => void
|
||||
advancedEditing: boolean
|
||||
setAdvancedEditing: (isEditing: boolean) => void
|
||||
backupSchema: SchemaRoot | null
|
||||
setBackupSchema: (schema: SchemaRoot | null) => void
|
||||
}
|
||||
|
||||
export const createJsonSchemaConfigStore = () => createStore<JsonSchemaConfigStore>(set => ({
|
||||
hoveringProperty: '',
|
||||
setHoveringProperty: (propertyPath: string) => 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 useJsonSchemaConfigStore = <T>(selector: (state: JsonSchemaConfigStore) => T): T => {
|
||||
const store = useContext(JsonSchemaConfigContext)
|
||||
if (!store)
|
||||
throw new Error('Missing JsonSchemaConfigContext.Provider in the tree')
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiAddCircleFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useJsonSchemaConfigStore } from '../store'
|
||||
import { useCallback } from 'react'
|
||||
import { useMittContext } from '../context'
|
||||
|
||||
const AddField = () => {
|
||||
const { t } = useTranslation()
|
||||
const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
const handleAddField = useCallback(() => {
|
||||
setIsAddingNewField(true)
|
||||
emit('addField', { path: [] })
|
||||
}, [setIsAddingNewField, emit])
|
||||
|
||||
return (
|
||||
<div className='pl-5 py-2'>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
className='flex items-center gap-x-[1px]'
|
||||
onClick={handleAddField}
|
||||
>
|
||||
<RiAddCircleFill className='w-3.5 h-3.5'/>
|
||||
<span className='px-[3px]'>{t('workflow.nodes.llm.jsonSchema.addField')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddField
|
||||
@ -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 items-center gap-x-1 p-0.5 pl-1'>
|
||||
<div className='px-1 py-0.5 text-text-primary system-sm-semibold'>
|
||||
{name}
|
||||
</div>
|
||||
<div className='px-1 py-0.5 text-text-tertiary system-xs-medium'>
|
||||
{type}
|
||||
</div>
|
||||
{
|
||||
required && (
|
||||
<div className='px-1 py-0.5 text-text-warning system-2xs-medium-uppercase'>
|
||||
{t('workflow.nodes.llm.jsonSchema.required')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className='px-2 pb-1 text-text-tertiary system-xs-regular'>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
@ -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 items-center justify-center w-6 h-6 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='w-4 h-4'/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.edit')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center w-6 h-6 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={onEdit}
|
||||
>
|
||||
<RiEditLine className='w-4 h-4' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.remove')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center justify-center w-6 h-6 rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
|
||||
onClick={onDelete}
|
||||
>
|
||||
<RiDeleteBinLine className='w-4 h-4' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Actions)
|
||||
@ -0,0 +1,35 @@
|
||||
import React, { type FC } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type AdvancedActionsProps = {
|
||||
isConfirmDisabled: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
isConfirmDisabled,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<Button size='small' variant='secondary' onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isConfirmDisabled}
|
||||
size='small'
|
||||
variant='primary'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t('common.operation.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AdvancedActions)
|
||||
@ -0,0 +1,78 @@
|
||||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { RiArrowDownDoubleLine } from '@remixicon/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 px-2 py-1.5 gap-y-1'>
|
||||
<div className='flex items-center gap-x-2 w-full'>
|
||||
<span className='text-text-tertiary system-2xs-medium-uppercase'>
|
||||
{t('workflow.nodes.llm.jsonSchema.stringValidations')}
|
||||
</span>
|
||||
<div className='grow'>
|
||||
<Divider type='horizontal' className='h-px my-0 bg-line-divider-bg' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center h-6 text-text-secondary system-xs-medium'>
|
||||
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 pl-1.5 pt-2 pr-2 pb-1 gap-x-0.5'
|
||||
onClick={handleToggleAdvancedOptions}
|
||||
>
|
||||
<RiArrowDownDoubleLine className='w-3 h-3 text-text-tertiary' />
|
||||
<span className='text-text-tertiary system-xs-regular'>
|
||||
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdvancedOptions
|
||||
@ -0,0 +1,257 @@
|
||||
import React, { type FC, useCallback, useMemo, 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 { useJsonSchemaConfigStore } from '../../store'
|
||||
import { useMittContext } from '../../context'
|
||||
import produce from 'immer'
|
||||
|
||||
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 DEPTH_LIMIT = 10
|
||||
|
||||
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 = useJsonSchemaConfigStore(state => state.isAddingNewField)
|
||||
const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField)
|
||||
const advancedEditing = useJsonSchemaConfigStore(state => state.advancedEditing)
|
||||
const setAdvancedEditing = useJsonSchemaConfigStore(state => state.setAdvancedEditing)
|
||||
const { emit, useSubscribe } = useMittContext()
|
||||
|
||||
const disableAddBtn = fields.type !== Type.object && fields.type !== ArrayType.object && depth < DEPTH_LIMIT
|
||||
const hasAdvancedOptions = fields.type === Type.string || fields.type === Type.number
|
||||
const isAdvancedEditing = advancedEditing || isAddingNewField
|
||||
|
||||
const advancedOptions = useMemo(() => {
|
||||
return { enum: (currentFields.enum || []).join(', ') }
|
||||
}, [currentFields.enum])
|
||||
|
||||
useSubscribe('restorePropertyName', () => {
|
||||
setCurrentFields(prev => ({ ...prev, name: fields.name }))
|
||||
})
|
||||
|
||||
useSubscribe('fieldChangeSuccess', () => {
|
||||
if (isAddingNewField) {
|
||||
setIsAddingNewField(false)
|
||||
return
|
||||
}
|
||||
setAdvancedEditing(false)
|
||||
})
|
||||
|
||||
const emitPropertyNameChange = useCallback((name: string) => {
|
||||
const newFields = produce(fields, (draft) => {
|
||||
draft.name = name
|
||||
})
|
||||
emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: newFields })
|
||||
}, [fields, path, parentPath, emit])
|
||||
|
||||
const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => {
|
||||
const newFields = produce(fields, (draft) => {
|
||||
draft.type = type
|
||||
})
|
||||
emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: newFields })
|
||||
}, [fields, 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((e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyNameChange(e.target.value)
|
||||
}, [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((e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyOptionsChange({ description: e.target.value, enum: fields.enum })
|
||||
}, [isAdvancedEditing, emitPropertyOptionsChange, fields])
|
||||
|
||||
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
|
||||
if (isAdvancedEditing) return
|
||||
const enumValue = options.enum.replace(' ', '').split(',')
|
||||
emitPropertyOptionsChange({ description: fields.description, enum: enumValue })
|
||||
}, [isAdvancedEditing, emitPropertyOptionsChange, fields])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
emitPropertyDelete()
|
||||
}, [emitPropertyDelete])
|
||||
|
||||
const handleAdvancedEdit = useCallback(() => {
|
||||
setBackupFields({ ...currentFields })
|
||||
setAdvancedEditing(true)
|
||||
}, [currentFields, setAdvancedEditing])
|
||||
|
||||
const handleAddChildField = useCallback(() => {
|
||||
emitPropertyAdd()
|
||||
}, [emitPropertyAdd])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
emitFieldChange()
|
||||
}, [emitFieldChange])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isAddingNewField) {
|
||||
emit('restoreSchema')
|
||||
setIsAddingNewField(false)
|
||||
return
|
||||
}
|
||||
if (backupFields) {
|
||||
setCurrentFields(backupFields)
|
||||
setBackupFields(null)
|
||||
}
|
||||
setAdvancedEditing(false)
|
||||
}, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col py-0.5 rounded-lg bg-components-panel-bg shadow-sm shadow-shadow-shadow-4'>
|
||||
<div className='flex items-center pl-1 pr-0.5'>
|
||||
<div className='flex items-center gap-x-1 grow'>
|
||||
<input
|
||||
value={currentFields.name}
|
||||
className='max-w-20 h-5 rounded-[5px] px-1 py-0.5 text-text-primary system-sm-semibold placeholder:text-text-placeholder
|
||||
placeholder:system-sm-semibold hover:bg-state-base-hover border border-transparent focus:border-components-input-border-active
|
||||
focus:bg-components-input-bg-active focus:shadow-xs shadow-shadow-shadow-3 caret-[#295EFF] outline-none'
|
||||
placeholder={t('workflow.nodes.llm.jsonSchema.fieldNamePlaceholder')}
|
||||
onChange={handlePropertyNameChange}
|
||||
onBlur={handlePropertyNameBlur}
|
||||
onKeyUp={e => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
/>
|
||||
<TypeSelector
|
||||
currentValue={currentFields.type}
|
||||
items={TYPE_OPTIONS}
|
||||
onSelect={handleTypeChange}
|
||||
popupClassName={'z-[1000]'}
|
||||
/>
|
||||
{
|
||||
currentFields.required && (
|
||||
<div className='px-1 py-0.5 text-text-warning system-2xs-medium-uppercase'>
|
||||
{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>
|
||||
|
||||
{(currentFields.description || isAdvancedEditing) && (
|
||||
<div className={classNames(isAdvancedEditing ? 'p-2 pt-1' : 'px-2 pb-1')}>
|
||||
<input
|
||||
value={currentFields.description}
|
||||
className='w-full h-4 p-0 text-text-tertiary system-xs-regular placeholder:text-text-placeholder placeholder:system-xs-regular caret-[#295EFF] outline-none'
|
||||
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
|
||||
@ -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 px-1.5 py-1 rounded-[5px] border border-divider-subtle bg-background-default-lighter'>
|
||||
<span className='text-text-secondary system-2xs-medium-uppercase'>{t('workflow.nodes.llm.jsonSchema.required')}</span>
|
||||
<Switch size='xs' defaultValue={defaultValue} onChange={toggleRequired} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RequiredSwitch)
|
||||
@ -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 p-0.5 pl-1 rounded-[5px] hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}>
|
||||
<span className='text-text-tertiary system-xs-medium'>{currentValue}</span>
|
||||
<RiArrowDownSLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={popupClassName}>
|
||||
<div className='w-40 p-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg 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 px-2 py-1 rounded-lg hover:bg-state-base-hover'}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className='px-1 text-text-secondary system-sm-medium'>{item.text}</span>
|
||||
{isSelected && <RiCheckLine className='w-4 h-4 text-text-accent' />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default TypeSelector
|
||||
@ -0,0 +1,25 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Field } from '../../../types'
|
||||
import SchemaNode from './schema-node'
|
||||
|
||||
type VisualEditorProps = {
|
||||
schema: Field
|
||||
}
|
||||
|
||||
const VisualEditor: FC<VisualEditorProps> = ({
|
||||
schema,
|
||||
}) => {
|
||||
return (
|
||||
<div className='h-full rounded-xl p-1 pl-2 bg-background-section-burn overflow-auto'>
|
||||
<SchemaNode
|
||||
name='structured_output'
|
||||
schema={schema}
|
||||
required={false}
|
||||
path={[]}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VisualEditor
|
||||
@ -0,0 +1,187 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { 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 { useJsonSchemaConfigStore } from '../store'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import AddField from './add-field'
|
||||
|
||||
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]',
|
||||
}
|
||||
|
||||
const indentLeft: Record<number, string> = {
|
||||
1: 'left-0',
|
||||
2: 'left-[20px]',
|
||||
3: 'left-[40px]',
|
||||
4: 'left-[60px]',
|
||||
5: 'left-[80px]',
|
||||
6: 'left-[100px]',
|
||||
7: 'left-[120px]',
|
||||
8: 'left-[140px]',
|
||||
9: 'left-[160px]',
|
||||
}
|
||||
|
||||
const SchemaNode: FC<SchemaNodeProps> = ({
|
||||
name,
|
||||
required,
|
||||
schema,
|
||||
path,
|
||||
parentPath,
|
||||
depth,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const hoveringProperty = useJsonSchemaConfigStore(state => state.hoveringProperty)
|
||||
const setHoveringProperty = useJsonSchemaConfigStore(state => state.setHoveringProperty)
|
||||
const isAddingNewField = useJsonSchemaConfigStore(state => state.isAddingNewField)
|
||||
const advancedEditing = useJsonSchemaConfigStore(state => state.advancedEditing)
|
||||
|
||||
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string) => {
|
||||
setHoveringProperty(path)
|
||||
}, { wait: 50 })
|
||||
|
||||
const hasChildren = getHasChildren(schema)
|
||||
const type = getFieldType(schema)
|
||||
const isHovering = hoveringProperty === path.join('.') && depth > 0
|
||||
|
||||
const handleExpand = () => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (advancedEditing || isAddingNewField) return
|
||||
setHoveringPropertyDebounced(path.join('.'))
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (advancedEditing || isAddingNewField) return
|
||||
setHoveringPropertyDebounced('')
|
||||
}
|
||||
|
||||
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],
|
||||
)}>
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
className='py-0.5 text-text-tertiary hover:text-text-accent'
|
||||
>
|
||||
{
|
||||
isExpanded
|
||||
? <RiArrowDropDownLine className='w-4 h-4' />
|
||||
: <RiArrowDropRightLine className='w-4 h-4' />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{isHovering ? (
|
||||
<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 top-7 z-0',
|
||||
schema.description ? 'h-[calc(100%-3rem)]' : 'h-[calc(100%-1.75rem)]',
|
||||
indentLeft[depth + 1],
|
||||
)}>
|
||||
<Divider type='vertical' className='bg-divider-subtle mx-0' />
|
||||
</div>
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<>
|
||||
{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)
|
||||
@ -0,0 +1,70 @@
|
||||
'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, StructuredOutput } 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 leading-[18px] items-center'>
|
||||
<div className='code-sm-semibold text-text-secondary'>structured_output</div>
|
||||
<div className='ml-2 system-xs-regular text-text-tertiary'>object</div>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary'
|
||||
className='flex'
|
||||
onClick={showConfigModal}
|
||||
>
|
||||
<RiEditLine className='size-3.5 mr-1' />
|
||||
<div className='system-xs-medium text-components-button-secondary-text'>{t('app.structOutput.configure')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
{value?.schema ? (
|
||||
<ShowPanel
|
||||
payload={value}
|
||||
/>) : (
|
||||
<div className='mt-1.5 flex items-center h-10 justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary'>{t('app.structOutput.notConfiguredTip')}</div>
|
||||
)}
|
||||
|
||||
{showConfig && (
|
||||
<JsonSchemaConfigModal
|
||||
isShow
|
||||
defaultSchema={(value?.schema || {}) as any} // wait for types change
|
||||
onSave={handleChange as any} // wait for types change
|
||||
onClose={hideConfigModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(StructureOutput)
|
||||
113
web/app/components/workflow/nodes/llm/mock-struct-data.ts
Normal file
113
web/app/components/workflow/nodes/llm/mock-struct-data.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { type StructuredOutput, Type } from './types'
|
||||
|
||||
const data: StructuredOutput = {
|
||||
schema: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
string_field: {
|
||||
type: Type.string,
|
||||
description: '这是一个字符串类型的字段',
|
||||
},
|
||||
obj_field: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
string_field_1: {
|
||||
type: Type.string,
|
||||
description: 'this is a string type field',
|
||||
},
|
||||
number_field_2: {
|
||||
type: Type.number,
|
||||
description: '描述可为空',
|
||||
},
|
||||
array_field_4: {
|
||||
type: Type.array,
|
||||
items: {
|
||||
type: Type.string,
|
||||
},
|
||||
},
|
||||
boolean_field_5: {
|
||||
type: Type.boolean,
|
||||
description: '描述可为空',
|
||||
},
|
||||
sub_item_d_2: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_3: {
|
||||
type: Type.object,
|
||||
// generate more than sub item 10 levels
|
||||
properties: {
|
||||
sub_item_4: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_5: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_6: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_7: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_8: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_9: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_10: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_11: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
sub_item_12: {
|
||||
type: Type.object,
|
||||
description: 'This is a object type field.This is a object type field.This is a object type field.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
number_field_3: {
|
||||
type: Type.number,
|
||||
description: '描述可为空',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'string_field_1',
|
||||
'number_field_2',
|
||||
'enum_field_3',
|
||||
'array_field_4',
|
||||
'boolean_field_5',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'string_field_1',
|
||||
'number_field_2',
|
||||
'enum_field_3',
|
||||
'array_field_4',
|
||||
'boolean_field_5',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default data
|
||||
@ -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,9 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
contexts,
|
||||
setContexts,
|
||||
runningStatus,
|
||||
isModelSupportStructuredOutput,
|
||||
handleStructureOutputEnableChange,
|
||||
handleStructureOutputChange,
|
||||
handleRun,
|
||||
handleStop,
|
||||
varInputs,
|
||||
@ -272,13 +278,55 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
/>
|
||||
</div>
|
||||
<Split />
|
||||
<OutputVars>
|
||||
<OutputVars
|
||||
operations={
|
||||
<div className='mr-4 flex items-center'>
|
||||
{!isModelSupportStructuredOutput && (
|
||||
<Tooltip noDecoration popupContent={
|
||||
<div className='w-[232px] px-4 py-3.5 rounded-xl bg-components-tooltip-bg border-[0.5px] border-components-panel-border shadow-lg backdrop-blur-[5px]'>
|
||||
<div className='title-xs-semi-bold text-text-primary'>{t('app.structOutput.modelNotSupported')}</div>
|
||||
<div className='mt-1 body-xs-regular text-text-secondary'>{t('app.structOutput.modelNotSupportedTip')}</div>
|
||||
</div>
|
||||
}>
|
||||
<div>
|
||||
<RiAlertFill className='mr-1 size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className='mr-0.5 system-xs-medium-uppercase 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 && (
|
||||
|
||||
@ -15,4 +15,51 @@ export type LLMNodeType = CommonNodeType & {
|
||||
enabled: boolean
|
||||
configs?: VisionSetting
|
||||
}
|
||||
structured_output_enabled?: boolean
|
||||
structured_output?: StructuredOutput
|
||||
}
|
||||
|
||||
export enum Type {
|
||||
string = 'string',
|
||||
number = 'number',
|
||||
boolean = 'boolean',
|
||||
object = 'object',
|
||||
array = 'array',
|
||||
}
|
||||
|
||||
export enum ArrayType {
|
||||
string = 'array[string]',
|
||||
number = 'array[number]',
|
||||
boolean = 'array[boolean]',
|
||||
object = 'array[object]',
|
||||
}
|
||||
|
||||
export type TypeWithArray = Type | ArrayType
|
||||
|
||||
type ArrayItemType = Exclude<Type, Type.array>
|
||||
export type ArrayItems = Omit<Field, 'type'> & { type: ArrayItemType }
|
||||
|
||||
export type SchemaEnumType = string[] | number[]
|
||||
|
||||
export type Field = {
|
||||
type: Type
|
||||
properties?: { // Object has properties
|
||||
[key: string]: Field
|
||||
}
|
||||
required?: string[] // Key of required properties in object
|
||||
description?: string
|
||||
items?: ArrayItems // Array has items. Define the item type
|
||||
enum?: SchemaEnumType // Enum values
|
||||
additionalProperties?: false // Required in object by api. Just set false
|
||||
}
|
||||
|
||||
export type StructuredOutput = {
|
||||
schema: SchemaRoot
|
||||
}
|
||||
|
||||
export type SchemaRoot = {
|
||||
type: Type.object
|
||||
properties: Record<string, Field>
|
||||
required?: string[]
|
||||
additionalProperties: false
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ 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 type { LLMNodeType, StructuredOutput } from './types'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import {
|
||||
ModelTypeEnum,
|
||||
@ -18,6 +18,8 @@ import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-cr
|
||||
import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run'
|
||||
import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants'
|
||||
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
|
||||
import useSWR from 'swr'
|
||||
import { fetchModelParameterRules } from '@/service/common'
|
||||
|
||||
const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
@ -277,6 +279,25 @@ const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
// structure output
|
||||
// TODO: this method has problem, different model has different parameter rules that show support structured output
|
||||
const { data: parameterRulesData } = useSWR((model?.provider && model?.name) ? `/workspaces/current/model-providers/${model.provider}/models/parameter-rules?model=${model.name}` : null, fetchModelParameterRules)
|
||||
const isModelSupportStructuredOutput = parameterRulesData?.data?.some((rule: any) => rule.name === 'json_schema')
|
||||
|
||||
const handleStructureOutputEnableChange = useCallback((enabled: boolean) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.structured_output_enabled = enabled
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [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 +429,9 @@ const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
setContexts,
|
||||
varInputs,
|
||||
runningStatus,
|
||||
isModelSupportStructuredOutput,
|
||||
handleStructureOutputChange,
|
||||
handleStructureOutputEnableChange,
|
||||
handleRun,
|
||||
handleStop,
|
||||
runResult,
|
||||
|
||||
@ -1,5 +1,24 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import { ArrayType, Type } from './types'
|
||||
import type { Field, LLMNodeType } from './types'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
ErrorHandleTypeEnum,
|
||||
} from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import type { WorkflowRetryConfig } from '@/app/components/workflow/nodes/_base/components/retry/types'
|
||||
import type { StructuredOutput } from './nodes/llm/types'
|
||||
|
||||
export enum BlockEnum {
|
||||
Start = 'start',
|
||||
@ -250,7 +251,7 @@ export enum VarType {
|
||||
export type Var = {
|
||||
variable: string
|
||||
type: VarType
|
||||
children?: Var[] // if type is obj, has the children struct
|
||||
children?: Var[] | StructuredOutput // if type is obj, has the children struct
|
||||
isParagraph?: boolean
|
||||
isSelect?: boolean
|
||||
options?: string[]
|
||||
|
||||
@ -1,19 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { ToolTipContent } from '../components/base/tooltip/content'
|
||||
import { SwitchPluginVersion } from '../components/workflow/nodes/_base/components/switch-plugin-version'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { type SchemaRoot, Type } from '../components/workflow/nodes/llm/types'
|
||||
import JsonSchemaConfigModal from '../components/workflow/nodes/llm/components/json-schema-config-modal'
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation()
|
||||
return <div className="p-20">
|
||||
<SwitchPluginVersion
|
||||
uniqueIdentifier={'langgenius/openai:12'}
|
||||
tooltip={<ToolTipContent
|
||||
title={t('workflow.nodes.agent.unsupportedStrategy')}
|
||||
>
|
||||
{t('workflow.nodes.agent.strategyNotFoundDescAndSwitchVersion')}
|
||||
</ToolTipContent>}
|
||||
/>
|
||||
const [show, setShow] = useState(false)
|
||||
const [schema, setSchema] = useState<SchemaRoot>({
|
||||
type: Type.object,
|
||||
properties: {
|
||||
userId: {
|
||||
type: Type.number,
|
||||
description: 'The user ID',
|
||||
},
|
||||
id: {
|
||||
type: Type.number,
|
||||
},
|
||||
title: {
|
||||
type: Type.string,
|
||||
},
|
||||
locations: {
|
||||
type: Type.array,
|
||||
items: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
x: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
x1: {
|
||||
type: Type.array,
|
||||
items: {
|
||||
type: Type.number,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'x1',
|
||||
],
|
||||
},
|
||||
y: {
|
||||
type: Type.number,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'x',
|
||||
'y',
|
||||
],
|
||||
},
|
||||
},
|
||||
completed: {
|
||||
type: Type.boolean,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'userId',
|
||||
'id',
|
||||
'title',
|
||||
],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
return <div className='flex flex-col p-20 h-full w-full overflow-hidden'>
|
||||
<button onClick={() => setShow(true)} className='shrink-0'>Open Json Schema Config</button>
|
||||
{show && (
|
||||
<JsonSchemaConfigModal
|
||||
isShow={show}
|
||||
defaultSchema={schema}
|
||||
onSave={(schema) => {
|
||||
setSchema(schema)
|
||||
}}
|
||||
onClose={() => setShow(false)}
|
||||
/>
|
||||
)}
|
||||
<pre className='bg-gray-50 p-4 rounded-lg overflow-auto grow'>
|
||||
{JSON.stringify(schema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -180,6 +180,20 @@ 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.',
|
||||
legacy: 'Legacy',
|
||||
legacyTip: 'JSON Schema will be removed from model parameters, you can use the structured output functionality under nodes instead.',
|
||||
learnMore: 'Learn more',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -410,6 +410,30 @@ 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.',
|
||||
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 Defaults',
|
||||
required: 'required',
|
||||
addField: 'Add Field',
|
||||
addChildField: 'Add Child Field',
|
||||
showAdvancedOptions: 'Show advanced options',
|
||||
stringValidations: 'String Validations',
|
||||
fieldNamePlaceholder: 'Field Name',
|
||||
descriptionPlaceholder: 'Add description',
|
||||
},
|
||||
},
|
||||
knowledgeRetrieval: {
|
||||
queryVariable: 'Query Variable',
|
||||
|
||||
@ -181,6 +181,20 @@ const translation = {
|
||||
},
|
||||
openInExplore: '在“探索”中打开',
|
||||
showMyCreatedAppsOnly: '我创建的',
|
||||
structOutput: {
|
||||
moreFillTip: '最多显示 10 级嵌套',
|
||||
required: '必填',
|
||||
LLMResponse: 'LLM 的响应',
|
||||
configure: '配置',
|
||||
notConfiguredTip: '结构化输出尚未配置',
|
||||
structured: '结构化',
|
||||
structuredTip: '结构化输出是一项功能,可确保模型始终生成符合您提供的 JSON 模式的响应',
|
||||
modelNotSupported: '模型不支持',
|
||||
modelNotSupportedTip: '当前模型不支持此功能,将自动降级为提示注入。',
|
||||
legacy: '遗留',
|
||||
legacyTip: '此功能将在未来版本中删除',
|
||||
learnMore: '了解更多',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -410,6 +410,30 @@ 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。',
|
||||
generatedResult: '生成结果',
|
||||
resultTip: '以下是生成的结果。如果你对这个结果不满意,可以返回并修改你的提示词。',
|
||||
back: '返回',
|
||||
regenerate: '重新生成',
|
||||
apply: '应用',
|
||||
doc: '了解有关结构化输出的更多信息',
|
||||
resetDefaults: '恢复默认值',
|
||||
required: '必填',
|
||||
addField: '添加字段',
|
||||
addChildField: '添加子字段',
|
||||
showAdvancedOptions: '显示高级选项',
|
||||
stringValidations: '字符串验证',
|
||||
fieldNamePlaceholder: '字段名',
|
||||
descriptionPlaceholder: '添加描述',
|
||||
},
|
||||
},
|
||||
knowledgeRetrieval: {
|
||||
queryVariable: '查询变量',
|
||||
|
||||
@ -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)',
|
||||
},
|
||||
animation: {
|
||||
'spin-slow': 'spin 2s linear infinite',
|
||||
|
||||
@ -33,7 +33,7 @@ html[data-theme="dark"] {
|
||||
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%),
|
||||
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%
|
||||
@ -61,4 +61,5 @@ html[data-theme="dark"] {
|
||||
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%,);
|
||||
}
|
||||
@ -33,7 +33,7 @@ html[data-theme="light"] {
|
||||
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%),
|
||||
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%
|
||||
@ -61,4 +61,5 @@ html[data-theme="light"] {
|
||||
180deg,
|
||||
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%);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user