Compare commits

...

43 Commits

Author SHA1 Message Date
twwu
066b0deef4 feat: implement structured output generation with model configuration and error handling 2025-03-20 14:05:46 +08:00
twwu
a32bc341fb feat: add format and copy functionality to JSON schema code editor with tooltip support 2025-03-19 12:55:15 +08:00
twwu
86b1295efa feat: enhance JSON schema editor with keyboard shortcuts and additional properties validation 2025-03-18 23:14:38 +08:00
twwu
44be94d5b5 feat: add AJV for JSON schema validation and improve error handling 2025-03-18 22:34:40 +08:00
twwu
80a928a7b1 Merge branch 'feat/llm-struct-output' of https://github.com/langgenius/dify into feat/llm-struct-output 2025-03-18 15:43:03 +08:00
twwu
4a93aba8ba feat: refactor JSON schema visual editor with new context and store management 2025-03-18 15:42:58 +08:00
Joel
a75e77e156 fix: can not set llm struct the right var type 2025-03-18 15:15:41 +08:00
twwu
7a647cf18e feat: implement JSON schema depth validation and update related components 2025-03-18 15:09:08 +08:00
twwu
ffe08a35a4 Merge branch 'feat/llm-struct-output' of https://github.com/langgenius/dify into feat/llm-struct-output 2025-03-18 13:42:14 +08:00
twwu
62b8a49fb4 feat: save field on blur 2025-03-18 13:42:09 +08:00
Joel
001385e843 chore: remove useless code handle output var 2025-03-18 11:39:55 +08:00
Joel
bfaa601aca feat: node use llm output vars 2025-03-17 18:40:31 +08:00
twwu
d7fcba91eb Merge branch 'feat/llm-struct-output' of https://github.com/langgenius/dify into feat/llm-struct-output 2025-03-17 18:36:48 +08:00
twwu
1305e5f8c1 feat: update JSON Schema handling and improve UI interactions 2025-03-17 18:36:42 +08:00
Joel
744d75c8aa chore: remove legecy label 2025-03-17 18:31:41 +08:00
twwu
b305c17bc3 Merge branch 'feat/llm-struct-output' of https://github.com/langgenius/dify into feat/llm-struct-output 2025-03-17 16:42:11 +08:00
twwu
d4185c2d91 feat: implement JSON Schema configuration context and store for advanced editing options 2025-03-17 16:42:05 +08:00
Joel
cea0886e4a chore: temp show lengcy 2025-03-17 16:30:04 +08:00
Joel
475e1d07a7 feat: i18n and struct tooltip 2025-03-17 14:59:03 +08:00
Joel
b1c5299ff4 feat: struct enabled switch 2025-03-17 14:27:31 +08:00
Joel
7fd23d747e feat: integrate editor 2025-03-17 11:45:18 +08:00
Joel
7ea0a972d5 feat: pass the var into it 2025-03-14 18:07:09 +08:00
twwu
183edf0fd5 feat: add SchemaEditor component and integrate it into JSON Schema config modal 2025-03-14 17:05:52 +08:00
twwu
a07831bc05 Merge branch 'feat/llm-struct-output' of https://github.com/langgenius/dify into feat/llm-struct-output 2025-03-14 16:43:51 +08:00
twwu
4333820aa6 feat: enhance JSON Schema visual editor with new components and translations 2025-03-14 16:43:43 +08:00
Joel
8adf0fa698 feat: var picker can show the right var 2025-03-14 16:03:23 +08:00
Joel
84eb6a4715 feat: old attr to new obj attr 2025-03-14 14:53:02 +08:00
Joel
20ff7073bd fix: popup panel show detail 2025-03-14 14:22:12 +08:00
Joel
5c7a6db6b3 feat: prompt var popup 2025-03-12 15:58:33 +08:00
twwu
7a2c831ef3 Merge branch 'feat/llm-struct-output' of https://github.com/langgenius/dify into feat/llm-struct-output 2025-03-12 12:14:09 +08:00
twwu
fefd7819e6 feat: add JSON Schema generator and support enum values in types 2025-03-12 12:14:01 +08:00
Joel
a1684791fc feat: support choose var 2025-03-11 18:32:43 +08:00
Joel
f55a0dd269 feat: structral output varlist 2025-03-11 18:06:50 +08:00
Joel
6a76e27b05 feat: support var show 2025-03-11 16:09:09 +08:00
Joel
e624bf381b feat: show var full path 2025-03-11 11:08:46 +08:00
Joel
0031a3b58b feat: handle tiny ui and required 2025-03-10 16:16:25 +08:00
Joel
6913f64083 feat: show panel struct 2025-03-10 16:03:16 +08:00
Joel
e32cb0fdf8 feat: output var struct 2025-03-10 15:23:11 +08:00
Joel
8fb24bf1ca feat: show more than 10 deepth 2025-03-10 13:55:18 +08:00
Joel
d6b66aeed8 feat: picker panel main ui 2025-03-10 11:28:40 +08:00
Joel
6bf8253952 chore: temp 2025-03-07 15:28:15 +08:00
Joel
814070f1ae feat: pane struct 2025-03-05 11:52:04 +08:00
Joel
a4806be841 feat: add mock data and ts 2025-03-04 16:05:07 +08:00
71 changed files with 3646 additions and 229 deletions

View File

@ -3,6 +3,7 @@ import ChartView from './chartView'
import CardView from './cardView' import CardView from './cardView'
import TracingPanel from './tracing/panel' import TracingPanel from './tracing/panel'
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-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 = { export type IDevelopProps = {
params: { appId: string } params: { appId: string }
@ -13,6 +14,7 @@ const Overview = async ({
}: IDevelopProps) => { }: IDevelopProps) => {
return ( return (
<div className="h-full px-4 sm:px-12 py-6 overflow-scroll bg-chatbot-bg"> <div className="h-full px-4 sm:px-12 py-6 overflow-scroll bg-chatbot-bg">
<Test />
<ApikeyInfoPanel /> <ApikeyInfoPanel />
<TracingPanel /> <TracingPanel />
<CardView appId={appId} /> <CardView appId={appId} />

View File

@ -82,7 +82,7 @@ const Panel: FC = () => {
? LangfuseIcon ? LangfuseIcon
: inUseTracingProvider === TracingProvider.opik : inUseTracingProvider === TracingProvider.opik
? OpikIcon ? OpikIcon
: null : LangsmithIcon
const [langSmithConfig, setLangSmithConfig] = useState<LangSmithConfig | null>(null) const [langSmithConfig, setLangSmithConfig] = useState<LangSmithConfig | null>(null)
const [langFuseConfig, setLangFuseConfig] = useState<LangFuseConfig | null>(null) const [langFuseConfig, setLangFuseConfig] = useState<LangFuseConfig | null>(null)

View File

@ -11,6 +11,7 @@ import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { import {
RiErrorWarningFill, RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useSelectOrDelete } from '../../hooks' import { useSelectOrDelete } from '../../hooks'
import type { WorkflowNodesMap } from './node' 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 { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils' 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 = { type WorkflowVariableBlockComponentProps = {
nodeKey: string nodeKey: string
variables: string[] variables: string[]
workflowNodesMap: WorkflowNodesMap workflowNodesMap: WorkflowNodesMap
getVarType: (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
} }
const WorkflowVariableBlockComponent = ({ const WorkflowVariableBlockComponent = ({
nodeKey, nodeKey,
variables, variables,
workflowNodesMap = {}, workflowNodesMap = {},
getVarType,
}: WorkflowVariableBlockComponentProps) => { }: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND) const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
const variablesLength = variables.length const variablesLength = variables.length
const isShowAPart = variablesLength > 2
const varName = ( const varName = (
() => { () => {
const isSystem = isSystemVar(variables) const isSystem = isSystemVar(variables)
const varName = variablesLength >= 3 ? (variables).slice(-2).join('.') : variables[variablesLength - 1] const varName = variables[variablesLength - 1]
return `${isSystem ? 'sys.' : ''}${varName}` return `${isSystem ? 'sys.' : ''}${varName}`
} }
)() )()
@ -76,7 +86,7 @@ const WorkflowVariableBlockComponent = ({
const Item = ( const Item = (
<div <div
className={cn( 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', 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', !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> <Line3 className='mr-0.5 text-divider-deep'></Line3>
</div> </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'> <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 && !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' />} {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) export default memo(WorkflowVariableBlockComponent)

View File

@ -25,11 +25,13 @@ export type WorkflowVariableBlockProps = {
getWorkflowNode: (nodeId: string) => Node getWorkflowNode: (nodeId: string) => Node
onInsert?: () => void onInsert?: () => void
onDelete?: () => void onDelete?: () => void
getVarType: any
} }
const WorkflowVariableBlock = memo(({ const WorkflowVariableBlock = memo(({
workflowNodesMap, workflowNodesMap,
onInsert, onInsert,
onDelete, onDelete,
getVarType,
}: WorkflowVariableBlockType) => { }: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
@ -48,7 +50,7 @@ const WorkflowVariableBlock = memo(({
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => { (variables: string[]) => {
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined) editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap) const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
$insertNodes([workflowVariableBlockNode]) $insertNodes([workflowVariableBlockNode])
if (onInsert) if (onInsert)
@ -69,7 +71,7 @@ const WorkflowVariableBlock = memo(({
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
), ),
) )
}, [editor, onInsert, onDelete, workflowNodesMap]) }, [editor, onInsert, onDelete, workflowNodesMap, getVarType])
return null return null
}) })

View File

@ -7,29 +7,32 @@ export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
export type SerializedNode = SerializedLexicalNode & { export type SerializedNode = SerializedLexicalNode & {
variables: string[] variables: string[]
workflowNodesMap: WorkflowNodesMap workflowNodesMap: WorkflowNodesMap
getVarType: any
} }
export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> { export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> {
__variables: string[] __variables: string[]
__workflowNodesMap: WorkflowNodesMap __workflowNodesMap: WorkflowNodesMap
__getVarType: any
static getType(): string { static getType(): string {
return 'workflow-variable-block' return 'workflow-variable-block'
} }
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode { 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 { isInline(): boolean {
return true return true
} }
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, key?: NodeKey) { constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey) {
super(key) super(key)
this.__variables = variables this.__variables = variables
this.__workflowNodesMap = workflowNodesMap this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType
} }
createDOM(): HTMLElement { createDOM(): HTMLElement {
@ -48,12 +51,13 @@ export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> {
nodeKey={this.getKey()} nodeKey={this.getKey()}
variables={this.__variables} variables={this.__variables}
workflowNodesMap={this.__workflowNodesMap} workflowNodesMap={this.__workflowNodesMap}
getVarType={this.__getVarType!}
/> />
) )
} }
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode { static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap) const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType)
return node return node
} }
@ -77,12 +81,17 @@ export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> {
return self.__workflowNodesMap return self.__workflowNodesMap
} }
getVarType(): any {
const self = this.getLatest()
return self.__getVarType
}
getTextContent(): string { getTextContent(): string {
return `{{#${this.getVariables().join('.')}#}}` return `{{#${this.getVariables().join('.')}#}}`
} }
} }
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap): WorkflowVariableBlockNode { export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap) return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
} }
export function $isWorkflowVariableBlockNode( export function $isWorkflowVariableBlockNode(

View File

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

View File

@ -3,6 +3,7 @@ import type { RoleName } from './plugins/history-block'
import type { import type {
Node, Node,
NodeOutPutVar, NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
export type Option = { export type Option = {
@ -60,6 +61,10 @@ export type WorkflowVariableBlockType = {
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>> workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>>
onInsert?: () => void onInsert?: () => void
onDelete?: () => void onDelete?: () => void
getVarType?: (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => string
} }
export type MenuTextMatch = { export type MenuTextMatch = {

View File

@ -0,0 +1,68 @@
import React from 'react'
import classNames from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
import Divider from '../divider'
// Updated generic type to allow enum values
type SegmentedControlProps<T extends string | number | symbol> = {
options: { Icon: RemixiconComponentType, text: string, value: T }[]
value: T
onChange: (value: T) => void
className?: string
}
export const SegmentedControl = <T extends string | number | symbol>({
options,
value,
onChange,
className,
}: SegmentedControlProps<T>): JSX.Element => {
const selectedOptionIndex = options.findIndex(option => option.value === value)
return (
<div className={classNames(
'flex items-center rounded-lg bg-components-segmented-control-bg-normal gap-x-[1px] p-0.5',
className,
)}>
{options.map((option, index) => {
const { Icon } = option
const isSelected = index === selectedOptionIndex
const isNextSelected = index === selectedOptionIndex - 1
const isLast = index === options.length - 1
return (
<button
type='button'
key={String(option.value)}
className={classNames(
'flex items-center justify-center relative px-2 py-1 rounded-lg gap-x-0.5 group border-0.5 border-transparent',
isSelected
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
: 'hover:bg-state-base-hover',
)}
onClick={() => onChange(option.value)}
>
<span className='flex 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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { useState } from 'react' import { useState } from 'react'
import { RiArrowDropRightLine } from '@remixicon/react' import { RiArrowDropRightLine } from '@remixicon/react'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
@ -10,6 +11,8 @@ type CollapseProps = {
children: JSX.Element children: JSX.Element
collapsed?: boolean collapsed?: boolean
onCollapse?: (collapsed: boolean) => void onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
} }
const Collapse = ({ const Collapse = ({
disabled, disabled,
@ -17,12 +20,14 @@ const Collapse = ({
children, children,
collapsed, collapsed,
onCollapse, onCollapse,
operations,
}: CollapseProps) => { }: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true) const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
return ( return (
<> <>
<div className='flex justify-between items-center'>
<div <div
className='flex items-center' className='flex items-center'
onClick={() => { onClick={() => {
@ -46,6 +51,8 @@ const Collapse = ({
</div> </div>
{trigger} {trigger}
</div> </div>
{operations}
</div>
{ {
!collapsedMerged && children !collapsedMerged && children
} }

View File

@ -8,15 +8,20 @@ type Props = {
className?: string className?: string
title?: string title?: string
children: ReactNode children: ReactNode
operations?: ReactNode
} }
const OutputVars: FC<Props> = ({ const OutputVars: FC<Props> = ({
title, title,
children, children,
operations,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<FieldCollapse title={title || t('workflow.nodes.common.outputVars')}> <FieldCollapse
title={title || t('workflow.nodes.common.outputVars')}
operations={operations}
>
{children} {children}
</FieldCollapse> </FieldCollapse>
) )
@ -40,10 +45,12 @@ export const VarItem: FC<VarItemProps> = ({
}) => { }) => {
return ( return (
<div className='py-1'> <div className='py-1'>
<div className='flex justify-between'>
<div className='flex leading-[18px] items-center'> <div className='flex leading-[18px] items-center'>
<div className='code-sm-semibold text-text-secondary'>{name}</div> <div className='code-sm-semibold text-text-secondary'>{name}</div>
<div className='ml-2 system-xs-regular text-text-tertiary'>{type}</div> <div className='ml-2 system-xs-regular text-text-tertiary'>{type}</div>
</div> </div>
</div>
<div className='mt-0.5 system-xs-regular text-text-tertiary'> <div className='mt-0.5 system-xs-regular text-text-tertiary'>
{description} {description}
{subItems && ( {subItems && (

View File

@ -36,6 +36,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow' import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
type Props = { type Props = {
className?: string className?: string
@ -143,6 +144,8 @@ const Editor: FC<Props> = ({
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any) eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
} }
const getVarType = useWorkflowVariableType()
return ( return (
<Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}> <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)}> <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={{ workflowVariableBlock={{
show: true, show: true,
variables: nodesOutputVars || [], variables: nodesOutputVars || [],
getVarType,
workflowNodesMap: availableNodes.reduce((acc, node) => { workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = { acc[node.id] = {
title: node.data.title, title: node.data.title,

View File

@ -0,0 +1,77 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import type { Field as FieldType } from '../../../../../llm/types'
import cn from '@/utils/classnames'
import TreeIndentLine from '../tree-indent-line'
import { RiMoreFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import type { ValueSelector } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
const MAX_DEPTH = 10
type Props = {
valueSelector: ValueSelector
name: string,
payload: FieldType,
depth?: number
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
}
const Field: FC<Props> = ({
valueSelector,
name,
payload,
depth = 1,
readonly,
onSelect,
}) => {
const { t } = useTranslation()
const isLastFieldHighlight = readonly
const hasChildren = payload.type === Type.object && payload.properties
const isHighlight = isLastFieldHighlight && !hasChildren
if (depth > MAX_DEPTH + 1)
return null
return (
<div>
<Tooltip popupContent={t('app.structOutput.moreFillTip')} disabled={depth !== MAX_DEPTH + 1}>
<div
className={cn('flex 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)

View File

@ -0,0 +1,82 @@
'use client'
import type { FC } from 'react'
import React, { useRef } from 'react'
import type { StructuredOutput } from '../../../../../llm/types'
import Field from './field'
import cn from '@/utils/classnames'
import { useHover } from 'ahooks'
import type { ValueSelector } from '@/app/components/workflow/types'
type Props = {
className?: string
root: { nodeId?: string, nodeName?: string, attrName: string }
payload: StructuredOutput
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
onHovering?: (value: boolean) => void
}
export const PickerPanelMain: FC<Props> = ({
className,
root,
payload,
readonly,
onHovering,
onSelect,
}) => {
const ref = useRef<HTMLDivElement>(null)
useHover(ref, {
onChange: (hovering) => {
if (hovering) {
onHovering?.(true)
}
else {
setTimeout(() => {
onHovering?.(false)
}, 100)
}
},
})
const schema = payload.schema
const fieldNames = Object.keys(schema.properties)
return (
<div className={cn(className)} ref={ref}>
{/* Root info */}
<div className='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)

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { isArray, uniq } from 'lodash-es'
import type { CodeNodeType } from '../../../code/types' import type { CodeNodeType } from '../../../code/types'
import type { EndNodeType } from '../../../end/types' import type { EndNodeType } from '../../../end/types'
import type { AnswerNodeType } from '../../../answer/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 { KnowledgeRetrievalNodeType } from '../../../knowledge-retrieval/types'
import type { IfElseNodeType } from '../../../if-else/types' import type { IfElseNodeType } from '../../../if-else/types'
import type { TemplateTransformNodeType } from '../../../template-transform/types' import type { TemplateTransformNodeType } from '../../../template-transform/types'
@ -20,6 +20,8 @@ import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/type
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' 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 { ConversationVariable, EnvironmentVariable, Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types' import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types'
import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types'
import { import {
HTTP_REQUEST_OUTPUT_STRUCT, HTTP_REQUEST_OUTPUT_STRUCT,
KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT, KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT,
@ -54,19 +56,81 @@ const inputVarTypeToVarType = (type: InputVarType): VarType => {
} as any)[type] || VarType.string } 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 findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: ValueSelector) => boolean, value_selector: ValueSelector, isFile?: boolean): Var => {
const { children } = obj const { children } = obj
const isStructuredOutput = !!(children as StructuredOutput)?.schema?.properties
const res: Var = { const res: Var = {
variable: obj.variable, variable: obj.variable,
type: isFile ? VarType.file : VarType.object, type: isFile ? VarType.file : VarType.object,
children: children.filter((item: Var) => { children: isStructuredOutput ? findExceptVarInStructuredOutput(children, filterVar) : children.filter((item: Var) => {
const { children } = item const { children } = item
const currSelector = [...value_selector, item.variable] const currSelector = [...value_selector, item.variable]
if (!children) if (!children)
return filterVar(item, currSelector) return filterVar(item, currSelector)
const obj = findExceptVarInObject(item, filterVar, currSelector, false) // File doesn't contains file children 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 return res
@ -138,10 +202,17 @@ const formatItem = (
} }
case BlockEnum.LLM: { case BlockEnum.LLM: {
res.vars = LLM_OUTPUT_STRUCT res.vars = [...LLM_OUTPUT_STRUCT]
break if (data.structured_output_enabled && data.structured_output?.schema?.properties && Object.keys(data.structured_output.schema.properties).length > 0) {
res.vars.push({
variable: 'structured_output',
type: VarType.object,
children: data.structured_output,
})
} }
break
}
case BlockEnum.KnowledgeRetrieval: { case BlockEnum.KnowledgeRetrieval: {
res.vars = KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT res.vars = KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT
break break
@ -404,7 +475,7 @@ const formatItem = (
return false return false
const obj = findExceptVarInObject(isFile ? { ...v, children } : v, filterVar, selector, isFile) 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) => { }).map((v) => {
const isFile = v.type === VarType.file const isFile = v.type === VarType.file
@ -527,8 +598,7 @@ export const getVarType = ({
isConstant, isConstant,
environmentVariables = [], environmentVariables = [],
conversationVariables = [], conversationVariables = [],
}: }: {
{
valueSelector: ValueSelector valueSelector: ValueSelector
parentNode?: Node | null parentNode?: Node | null
isIterationItem?: boolean isIterationItem?: boolean
@ -582,10 +652,30 @@ export const getVarType = ({
let type: VarType = VarType.string let type: VarType = VarType.string
let curr: any = targetVar.vars let curr: any = targetVar.vars
if (isSystem || isEnv || isChatVar) { if (isSystem || isEnv || isChatVar) {
return curr.find((v: any) => v.variable === (valueSelector as ValueSelector).join('.'))?.type return curr.find((v: any) => v.variable === (valueSelector as ValueSelector).join('.'))?.type
} }
else { 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) => { (valueSelector as ValueSelector).slice(1).forEach((key, i) => {
const isLast = i === valueSelector.length - 2 const isLast = i === valueSelector.length - 2
if (Array.isArray(curr)) if (Array.isArray(curr))
@ -1089,17 +1179,27 @@ export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, new
}) })
return newNode return newNode
} }
const varToValueSelectorList = (v: Var, parentValueSelector: ValueSelector, res: ValueSelector[]) => { const varToValueSelectorList = (v: Var, parentValueSelector: ValueSelector, res: ValueSelector[]) => {
if (!v.variable) if (!v.variable)
return return
res.push([...parentValueSelector, v.variable]) res.push([...parentValueSelector, v.variable])
const isStructuredOutput = !!(v.children as StructuredOutput)?.schema?.properties
if (v.children && v.children.length > 0) { if ((v.children as Var[])?.length > 0) {
v.children.forEach((child) => { (v.children as Var[]).forEach((child) => {
varToValueSelectorList(child, [...parentValueSelector, v.variable], res) 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[]) => { const varsToValueSelectorList = (vars: Var | Var[], parentValueSelector: ValueSelector, res: ValueSelector[]) => {
@ -1133,7 +1233,16 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto
} }
case BlockEnum.LLM: { case BlockEnum.LLM: {
varsToValueSelectorList(LLM_OUTPUT_STRUCT, [id], res) const vars = [...LLM_OUTPUT_STRUCT]
const llmNodeData = data as LLMNodeType
if (llmNodeData.structured_output_enabled && llmNodeData.structured_output?.schema?.properties && Object.keys(llmNodeData.structured_output.schema.properties).length > 0) {
vars.push({
variable: 'structured_output',
type: VarType.object,
children: llmNodeData.structured_output,
})
}
varsToValueSelectorList(vars, [id], res)
break break
} }

View File

@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { Field, StructuredOutput, TypeWithArray } from '../../../llm/types'
import { Type } from '../../../llm/types'
import { PickerPanelMain as Panel } from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
type Props = {
nodeName: string
path: string[]
varType: TypeWithArray
nodeType?: BlockEnum
}
const VarFullPathPanel: FC<Props> = ({
nodeName,
path,
varType,
nodeType = BlockEnum.LLM,
}) => {
const schema: StructuredOutput = (() => {
const schema: StructuredOutput['schema'] = {
type: Type.object,
properties: {} as { [key: string]: Field },
required: [],
additionalProperties: false,
}
let current = schema
for (let i = 1; i < path.length; i++) {
const isLast = i === path.length - 1
const name = path[i]
current.properties[name] = {
type: isLast ? varType : Type.object,
properties: {},
} as Field
current = current.properties[name] as { type: Type.object; properties: { [key: string]: Field; }; required: never[]; additionalProperties: false; }
}
return {
schema,
}
})()
return (
<div className='w-[280px] 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)

View File

@ -6,13 +6,14 @@ import {
RiArrowDownSLine, RiArrowDownSLine,
RiCloseLine, RiCloseLine,
RiErrorWarningFill, RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react' } from '@remixicon/react'
import produce from 'immer' import produce from 'immer'
import { useStoreApi } from 'reactflow' import { useStoreApi } from 'reactflow'
import RemoveButton from '../remove-button' import RemoveButton from '../remove-button'
import useAvailableVarList from '../../hooks/use-available-var-list' import useAvailableVarList from '../../hooks/use-available-var-list'
import VarReferencePopup from './var-reference-popup' 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 ConstantField from './constant-field'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' 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 Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils' import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from './var-full-path-panel'
const TRIGGER_DEFAULT_WIDTH = 227 const TRIGGER_DEFAULT_WIDTH = 227
@ -156,16 +158,15 @@ const VarReferencePicker: FC<Props> = ({
return getNodeInfoById(availableNodes, outputVarNodeId)?.data return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode]) }, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode])
const varName = useMemo(() => { const isShowAPart = (value as ValueSelector).length > 2
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]
return `${isSystem ? 'sys.' : ''}${varName}` const varName = useMemo(() => {
} if (!hasValue)
return '' return ''
const isSystem = isSystemVar(value as ValueSelector)
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
return `${isSystem ? 'sys.' : ''}${varName}`
}, [hasValue, value]) }, [hasValue, value])
const varKindTypes = [ const varKindTypes = [
@ -253,6 +254,22 @@ const VarReferencePicker: FC<Props> = ({
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !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 ( return (
<div className={cn(className, !readonly && 'cursor-pointer')}> <div className={cn(className, !readonly && 'cursor-pointer')}>
<PortalToFollowElem <PortalToFollowElem
@ -317,7 +334,7 @@ const VarReferencePicker: FC<Props> = ({
className='grow h-full' 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')}> <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')}> <div className={cn('h-full items-center px-1.5 rounded-[5px]', hasValue ? 'bg-white inline-flex' : 'flex')}>
{hasValue {hasValue
? ( ? (
@ -336,6 +353,12 @@ const VarReferencePicker: FC<Props> = ({
<Line3 className='mr-0.5'></Line3> <Line3 className='mr-0.5'></Line3>
</div> </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'> <div className='flex items-center text-primary-600'>
{!hasValue && <Variable02 className='w-3.5 h-3.5' />} {!hasValue && <Variable02 className='w-3.5 h-3.5' />}
{isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />}

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' 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 { useHover } from 'ahooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
@ -15,20 +15,14 @@ import {
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { checkKeys } from '@/utils/var' import { checkKeys } from '@/utils/var'
import type { StructuredOutput } from '../../../llm/types'
import { Type } from '../../../llm/types'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { varTypeToStructType } from './utils'
import type { Field } from '@/app/components/workflow/nodes/llm/types'
import { FILE_STRUCT } from '@/app/components/workflow/constants' import { FILE_STRUCT } from '@/app/components/workflow/constants'
interface ObjectChildrenProps { type ItemProps = {
nodeId: string
title: string
data: Var[]
objPath: string[]
onChange: (value: ValueSelector, item: Var) => void
onHovering?: (value: boolean) => void
itemWidth?: number
isSupportFileVar?: boolean
}
interface ItemProps {
nodeId: string nodeId: string
title: string title: string
objPath: string[] objPath: string[]
@ -47,15 +41,40 @@ const Item: FC<ItemProps> = ({
itemData, itemData,
onChange, onChange,
onHovering, onHovering,
itemWidth,
isSupportFileVar, isSupportFileVar,
isException, isException,
}) => { }) => {
const isFile = itemData.type === VarType.file const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && itemData.children.length > 0) 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 isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.') const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.') 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 itemRef = useRef(null)
const [isItemHovering, setIsItemHovering] = useState(false) const [isItemHovering, setIsItemHovering] = useState(false)
const _ = useHover(itemRef, { const _ = useHover(itemRef, {
@ -64,7 +83,7 @@ const Item: FC<ItemProps> = ({
setIsItemHovering(true) setIsItemHovering(true)
} }
else { else {
if (isObj) { if (isObj || isStructureOutput) {
setTimeout(() => { setTimeout(() => {
setIsItemHovering(false) setIsItemHovering(false)
}, 100) }, 100)
@ -77,7 +96,7 @@ const Item: FC<ItemProps> = ({
}) })
const [isChildrenHovering, setIsChildrenHovering] = useState(false) const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering const isHovering = isItemHovering || isChildrenHovering
const open = isObj && isHovering const open = (isObj || isStructureOutput) && isHovering
useEffect(() => { useEffect(() => {
onHovering && onHovering(isHovering) onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -104,8 +123,8 @@ const Item: FC<ItemProps> = ({
<div <div
ref={itemRef} ref={itemRef}
className={cn( className={cn(
isObj ? ' pr-1' : 'pr-[18px]', (isObj || isStructureOutput) ? ' pr-1' : 'pr-[18px]',
isHovering && (isObj ? 'bg-primary-50' : 'bg-state-base-hover'), isHovering && ((isObj || isStructureOutput) ? 'bg-primary-50' : 'bg-state-base-hover'),
'relative w-full flex items-center h-6 pl-3 rounded-md cursor-pointer') 'relative w-full flex items-center h-6 pl-3 rounded-md cursor-pointer')
} }
onClick={handleChosen} onClick={handleChosen}
@ -125,7 +144,7 @@ const Item: FC<ItemProps> = ({
)} )}
</div> </div>
<div className='ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize'>{itemData.type}</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')} /> <ChevronRight className={cn('ml-0.5 w-3 h-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)} )}
</div> </div>
@ -133,30 +152,14 @@ const Item: FC<ItemProps> = ({
<PortalToFollowElemContent style={{ <PortalToFollowElemContent style={{
zIndex: 100, zIndex: 100,
}}> }}>
{(isObj && !isFile) && ( {(isStructureOutput || isObj) && (
// eslint-disable-next-line ts/no-use-before-define <PickerStructurePanel
<ObjectChildren root={{ nodeId, nodeName: title, attrName: itemData.variable }}
nodeId={nodeId} payload={structuredOutput!}
title={title}
objPath={[...objPath, itemData.variable]}
data={itemData.children as Var[]}
onChange={onChange}
onHovering={setIsChildrenHovering} onHovering={setIsChildrenHovering}
itemWidth={itemWidth} onSelect={(valueSelector) => {
isSupportFileVar={isSupportFileVar} onChange(valueSelector, itemData)
/> }}
)}
{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}
/> />
)} )}
</PortalToFollowElemContent> </PortalToFollowElemContent>
@ -164,69 +167,7 @@ const Item: FC<ItemProps> = ({
) )
} }
const ObjectChildren: FC<ObjectChildrenProps> = ({ type Props = {
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 {
hideSearch?: boolean hideSearch?: boolean
searchBoxClassName?: string searchBoxClassName?: string
vars: NodeOutPutVar[] vars: NodeOutPutVar[]

View File

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

View File

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

View File

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

View File

@ -0,0 +1,125 @@
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import { checkDepth } from '../../utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import CodeEditor from './code-editor'
import ErrorMessage from './error-message'
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)
useEffect(() => {
if (importBtnRef.current) {
const rect = importBtnRef.current.getBoundingClientRect()
updateBtnWidth(rect.width)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
setOpen(!open)
}, [open])
const onClose = useCallback(() => {
setOpen(false)
}, [])
const handleSubmit = useCallback(() => {
try {
const parsedJSON = JSON.parse(json)
const maxDepth = checkDepth(parsedJSON)
if (maxDepth > JSON_SCHEMA_MAX_DEPTH) {
setParseError({
type: 'error',
message: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`,
})
return
}
onSubmit(parsedJSON)
setParseError(null)
setOpen(false)
}
catch (e: any) {
if (e instanceof 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'>
<CodeEditor
className='rounded-lg'
editorWrapperClassName='h-[340px]'
value={json}
onUpdate={setJson}
showFormatButton={false}
/>
{parseError && <ErrorMessage message={parseError.message} />}
</div>
{/* Footer */}
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onClose}>
{t('common.operation.cancel')}
</Button>
<Button variant='primary' onClick={handleSubmit}>
{t('common.operation.submit')}
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default JsonImporter

View File

@ -0,0 +1,228 @@
import React, { type FC, useCallback, useState } from 'react'
import { type SchemaRoot, Type } from '../../types'
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
import { SegmentedControl } from '../../../../../base/segmented-control'
import JsonSchemaGenerator from './json-schema-generator'
import Divider from '@/app/components/base/divider'
import JsonImporter from './json-importer'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import VisualEditor from './visual-editor'
import SchemaEditor from './schema-editor'
import { getValidationErrorMessage, jsonToSchema, validateSchemaAgainstDraft7 } from '../../utils'
import { MittProvider, VisualEditorContextProvider } from './visual-editor/context'
import ErrorMessage from './error-message'
type JsonSchemaConfigProps = {
defaultSchema?: SchemaRoot
onSave: (schema: SchemaRoot) => void
onClose: () => void
}
enum SchemaView {
VisualEditor = 'visualEditor',
JsonSchema = 'jsonSchema',
}
const VIEW_TABS = [
{ Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor },
{ Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema },
]
const DEFAULT_SCHEMA: SchemaRoot = {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
defaultSchema,
onSave,
onClose,
}) => {
const { t } = useTranslation()
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
const [btnWidth, setBtnWidth] = useState(0)
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const updateBtnWidth = useCallback((width: number) => {
setBtnWidth(width + 32)
}, [])
const handleTabChange = useCallback((value: SchemaView) => {
if (currentTab === value) return
if (currentTab === SchemaView.JsonSchema) {
try {
const schema = JSON.parse(json)
setParseError(null)
const ajvError = validateSchemaAgainstDraft7(schema)
if (ajvError.length > 0) {
setValidationError(getValidationErrorMessage(ajvError))
return
}
else {
setJsonSchema(schema)
setValidationError('')
}
}
catch (error) {
setValidationError('')
if (error instanceof Error)
setParseError(error)
else
setParseError(new Error('Invalid JSON'))
return
}
}
else if (currentTab === SchemaView.VisualEditor) {
setJson(JSON.stringify(jsonSchema, null, 2))
}
setCurrentTab(value)
}, [currentTab, jsonSchema, json])
const handleApplySchema = useCallback((schema: SchemaRoot) => {
setJsonSchema(schema)
}, [])
const handleSubmit = useCallback((schema: string) => {
const jsonSchema = jsonToSchema(schema) as SchemaRoot
setJsonSchema(jsonSchema)
}, [])
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
setJsonSchema(schema)
}, [])
const handleSchemaEditorUpdate = useCallback((schema: string) => {
setJson(schema)
}, [])
const handleResetDefaults = useCallback(() => {
setJsonSchema(defaultSchema || DEFAULT_SCHEMA)
setJson(JSON.stringify(defaultSchema || DEFAULT_SCHEMA, null, 2))
}, [defaultSchema])
const handleCancel = useCallback(() => {
onClose()
}, [onClose])
const handleSave = useCallback(() => {
let schema = jsonSchema
if (currentTab === SchemaView.JsonSchema) {
try {
schema = JSON.parse(json)
setParseError(null)
const ajvError = validateSchemaAgainstDraft7(schema)
if (ajvError.length > 0) {
setValidationError(getValidationErrorMessage(ajvError))
return
}
else {
setJsonSchema(schema)
setValidationError('')
}
}
catch (error) {
setValidationError('')
if (error instanceof Error)
setParseError(error)
else
setParseError(new Error('Invalid JSON'))
return
}
}
onSave(schema)
onClose()
}, [currentTab, jsonSchema, json, 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={handleTabChange}
/>
<div className='flex items-center gap-x-0.5'>
{/* JSON Schema Generator */}
<JsonSchemaGenerator
crossAxisOffset={btnWidth}
onApply={handleApplySchema}
/>
<Divider type='vertical' className='h-3' />
{/* JSON Schema Importer */}
<JsonImporter
updateBtnWidth={updateBtnWidth}
onSubmit={handleSubmit}
/>
</div>
</div>
<div className='flex flex-col gap-y-1 px-6 grow overflow-hidden'>
{currentTab === SchemaView.VisualEditor && (
<MittProvider>
<VisualEditorContextProvider>
<VisualEditor
schema={jsonSchema}
onChange={handleVisualEditorUpdate}
/>
</VisualEditorContextProvider>
</MittProvider>
)}
{currentTab === SchemaView.JsonSchema && (
<SchemaEditor
schema={json}
onUpdate={handleSchemaEditorUpdate}
/>
)}
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</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/' // todo: replace with documentation link
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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,103 @@
import React, { type FC, useCallback, useMemo, useState } from 'react'
import type { SchemaRoot } from '../../../types'
import { RiArrowLeftLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CodeEditor from '../code-editor'
import ErrorMessage from '../error-message'
import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils'
type GeneratedResultProps = {
schema: SchemaRoot
onBack: () => void
onRegenerate: () => void
onClose: () => void
onApply: () => void
}
const GeneratedResult: FC<GeneratedResultProps> = ({
schema,
onBack,
onRegenerate,
onClose,
onApply,
}) => {
const { t } = useTranslation()
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const formatJSON = (json: SchemaRoot) => {
try {
const schema = JSON.stringify(json, null, 2)
setParseError(null)
return schema
}
catch (e) {
if (e instanceof Error)
setParseError(e)
else
setParseError(new Error('Invalid JSON'))
return ''
}
}
const jsonSchema = useMemo(() => formatJSON(schema), [schema])
const handleApply = useCallback(() => {
const ajvError = validateSchemaAgainstDraft7(schema)
if (ajvError.length > 0) {
setValidationError(getValidationErrorMessage(ajvError))
}
else {
onApply()
setValidationError('')
}
}, [schema, 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='px-4 py-2'>
<CodeEditor
className='rounded-lg'
editorWrapperClassName='h-[424px]'
value={jsonSchema}
readOnly
showFormatButton={false}
/>
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</div>
{/* Footer */}
<div className='flex items-center justify-between p-4 pt-2'>
<Button variant='secondary' className='flex items-center gap-x-0.5' onClick={onBack}>
<RiArrowLeftLine className='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 React.memo(GeneratedResult)

View File

@ -0,0 +1,171 @@
import React, { type FC, useCallback, useEffect, useState } from 'react'
import type { SchemaRoot } from '../../../types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import useTheme from '@/hooks/use-theme'
import type { CompletionParams, Model } from '@/types/app'
import { ModelModeType } from '@/types/app'
import { Theme } from '@/types/app'
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
import cn from '@/utils/classnames'
import type { ModelInfo } from './prompt-editor'
import PromptEditor from './prompt-editor'
import GeneratedResult from './generated-result'
import { useGenerateStructuredOutputRules } from '@/service/use-common'
import Toast from '@/app/components/base/toast'
import { type FormValue, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
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 [model, setModel] = useState<Model>({
name: '',
provider: '',
mode: ModelModeType.completion,
completion_params: {} as CompletionParams,
})
const [instruction, setInstruction] = useState('')
const [schema, setSchema] = useState<SchemaRoot | null>(null)
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
const {
defaultModel,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
useEffect(() => {
if (defaultModel) {
setModel(prev => ({
...prev,
name: defaultModel.model,
provider: defaultModel.provider.provider,
}))
}
}, [defaultModel])
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
setOpen(!open)
}, [open])
const onClose = useCallback(() => {
setOpen(false)
}, [])
const handleModelChange = useCallback((model: ModelInfo) => {
setModel(prev => ({
...prev,
provider: model.provider,
name: model.modelId,
mode: model.mode as ModelModeType,
}))
}, [])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
setModel(prev => ({
...prev,
completion_params: newParams as CompletionParams,
}),
)
}, [])
const { mutateAsync: generateStructuredOutputRules } = useGenerateStructuredOutputRules()
const generateSchema = useCallback(async () => {
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
if (error) {
Toast.notify({
type: 'error',
message: error,
})
return
}
return output
}, [instruction, model, generateStructuredOutputRules])
const handleGenerate = useCallback(async () => {
const output = await generateSchema()
if (output === undefined) return
setSchema(JSON.parse(output))
setView(GeneratorView.result)
}, [generateSchema])
const goBackToPromptEditor = () => {
setView(GeneratorView.promptEditor)
}
const handleRegenerate = useCallback(async () => {
const output = await generateSchema()
if (output === undefined) return
setSchema(JSON.parse(output))
}, [generateSchema])
const handleApply = () => {
onApply(schema!)
setOpen(false)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset ?? 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<button
type='button'
className={cn(
'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}
model={model}
onInstructionChange={setInstruction}
onCompletionParamsChange={handleCompletionParamsChange}
onGenerate={handleGenerate}
onClose={onClose}
onModelChange={handleModelChange}
/>
)}
{view === GeneratorView.result && (
<GeneratedResult
schema={schema!}
onBack={goBackToPromptEditor}
onRegenerate={handleRegenerate}
onApply={handleApply}
onClose={onClose}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default JsonSchemaGenerator

View File

@ -0,0 +1,108 @@
import React, { useCallback } from 'react'
import type { FC } from 'react'
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { Model } from '@/types/app'
export type ModelInfo = {
modelId: string
provider: string
mode?: string
features?: string[]
}
type PromptEditorProps = {
instruction: string
model: Model
onInstructionChange: (instruction: string) => void
onCompletionParamsChange: (newParams: FormValue) => void
onModelChange: (model: ModelInfo) => void
onClose: () => void
onGenerate: () => void
}
const PromptEditor: FC<PromptEditorProps> = ({
instruction,
model,
onInstructionChange,
onCompletionParamsChange,
onClose,
onGenerate,
onModelChange,
}) => {
const { t } = useTranslation()
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onInstructionChange(e.target.value)
}, [onInstructionChange])
return (
<div className='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>
<ModelParameterModal
popupClassName='!w-[448px]'
portalToFollowElemContentClassName='z-[1000]'
isAdvancedMode={true}
provider={model.provider}
mode={model.mode}
completionParams={model.completion_params}
modelId={model.name}
setModel={onModelChange}
onCompletionParamsChange={onCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div className='flex flex-col gap-y-1 px-4 py-2'>
<div className='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={handleInstructionChange}
/>
</div>
</div>
{/* Footer */}
<div className='flex justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onClose}>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
className='flex items-center gap-x-0.5'
onClick={onGenerate}
>
<RiSparklingFill className='w-4 h-4' />
<span>{t('workflow.nodes.llm.jsonSchema.generate')}</span>
</Button>
</div>
</div>
)
}
export default React.memo(PromptEditor)

View File

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

View File

@ -0,0 +1,33 @@
import React, { useCallback } from 'react'
import Button from '@/app/components/base/button'
import { RiAddCircleFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useVisualEditorStore } from './store'
import { useMittContext } from './context'
const AddField = () => {
const { t } = useTranslation()
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const { emit } = useMittContext()
const handleAddField = useCallback(() => {
setIsAddingNewField(true)
emit('addField', { path: [] })
}, [setIsAddingNewField, emit])
return (
<div className='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 React.memo(AddField)

View File

@ -0,0 +1,46 @@
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
type CardProps = {
name: string
type: string
required: boolean
description?: string
}
const Card: FC<CardProps> = ({
name,
type,
required,
description,
}) => {
const { t } = useTranslation()
return (
<div className='flex flex-col py-0.5'>
<div className='flex items-center gap-x-1 p-0.5 pl-1'>
<div className='px-1 py-0.5 text-text-primary system-sm-semibold truncate'>
{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 truncate'>
{description}
</div>
)}
</div>
)
}
export default React.memo(Card)

View File

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

View File

@ -0,0 +1,56 @@
import type { FC } from 'react'
import React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { RiAddCircleLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
type ActionsProps = {
disableAddBtn: boolean
onAddChildField: () => void
onEdit: () => void
onDelete: () => void
}
const Actions: FC<ActionsProps> = ({
disableAddBtn,
onAddChildField,
onEdit,
onDelete,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-0.5'>
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.addChildField')}>
<button
type='button'
className='flex 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)

View File

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

View File

@ -0,0 +1,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 React.memo(AdvancedOptions)

View File

@ -0,0 +1,263 @@
import React, { type FC, useCallback, useMemo, useRef, useState } from 'react'
import type { SchemaEnumType } from '../../../../types'
import { ArrayType, Type } from '../../../../types'
import type { TypeItem } from './type-selector'
import TypeSelector from './type-selector'
import RequiredSwitch from './required-switch'
import Divider from '@/app/components/base/divider'
import Actions from './actions'
import AdvancedActions from './advanced-actions'
import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames'
import { useVisualEditorStore } from '../store'
import { useMittContext } from '../context'
import { useUnmount } from 'ahooks'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
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 EditCard: FC<EditCardProps> = ({
fields,
depth,
path,
parentPath,
}) => {
const { t } = useTranslation()
const [currentFields, setCurrentFields] = useState(fields)
const [backupFields, setBackupFields] = useState<EditData | null>(null)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
const { emit, useSubscribe } = useMittContext()
const blurWithActions = useRef(false)
const disableAddBtn = depth >= JSON_SCHEMA_MAX_DEPTH || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
const isAdvancedEditing = advancedEditing || isAddingNewField
const advancedOptions = useMemo(() => {
let enumValue = ''
if (currentFields.type === Type.string || currentFields.type === Type.number)
enumValue = (currentFields.enum || []).join(', ')
return { enum: enumValue }
}, [currentFields.type, currentFields.enum])
useSubscribe('restorePropertyName', () => {
setCurrentFields(prev => ({ ...prev, name: fields.name }))
})
useSubscribe('fieldChangeSuccess', () => {
if (isAddingNewField) {
setIsAddingNewField(false)
return
}
setAdvancedEditing(false)
})
const emitPropertyNameChange = useCallback(() => {
emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields })
}, [fields, currentFields, path, parentPath, emit])
const emitPropertyTypeChange = useCallback(() => {
emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: currentFields })
}, [fields, currentFields, path, parentPath, emit])
const emitPropertyRequiredToggle = useCallback(() => {
emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields })
}, [emit, path, parentPath, fields, currentFields])
const emitPropertyOptionsChange = useCallback((options: Options) => {
emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } })
}, [emit, path, parentPath, fields, currentFields])
const emitPropertyDelete = useCallback(() => {
emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields })
}, [emit, path, parentPath, fields, currentFields])
const emitPropertyAdd = useCallback(() => {
emit('addField', { path })
}, [emit, path])
const emitFieldChange = useCallback(() => {
emit('fieldChange', { path, parentPath, oldFields: fields, fields: currentFields })
}, [emit, path, parentPath, fields, currentFields])
const handlePropertyNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentFields(prev => ({ ...prev, name: e.target.value }))
}, [])
const handlePropertyNameBlur = useCallback(() => {
if (isAdvancedEditing) return
emitPropertyNameChange()
}, [isAdvancedEditing, emitPropertyNameChange])
const handleTypeChange = useCallback((item: TypeItem) => {
setCurrentFields(prev => ({ ...prev, type: item.value }))
if (isAdvancedEditing) return
emitPropertyTypeChange()
}, [isAdvancedEditing, emitPropertyTypeChange])
const toggleRequired = useCallback(() => {
setCurrentFields(prev => ({ ...prev, required: !prev.required }))
if (isAdvancedEditing) return
emitPropertyRequiredToggle()
}, [isAdvancedEditing, emitPropertyRequiredToggle])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentFields(prev => ({ ...prev, description: e.target.value }))
}, [])
const handleDescriptionBlur = useCallback(() => {
if (isAdvancedEditing) return
emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum })
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
const enumValue = options.enum.replace(/\s/g, '').split(',')
setCurrentFields(prev => ({ ...prev, enum: enumValue }))
if (isAdvancedEditing) return
emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue })
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
const handleDelete = useCallback(() => {
blurWithActions.current = true
emitPropertyDelete()
}, [emitPropertyDelete])
const handleAdvancedEdit = useCallback(() => {
setBackupFields({ ...currentFields })
setAdvancedEditing(true)
}, [currentFields, setAdvancedEditing])
const handleAddChildField = useCallback(() => {
blurWithActions.current = true
emitPropertyAdd()
}, [emitPropertyAdd])
const handleConfirm = useCallback(() => {
emitFieldChange()
}, [emitFieldChange])
const handleCancel = useCallback(() => {
if (isAddingNewField) {
blurWithActions.current = true
emit('restoreSchema')
setIsAddingNewField(false)
return
}
if (backupFields) {
setCurrentFields(backupFields)
setBackupFields(null)
}
setAdvancedEditing(false)
}, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields])
useUnmount(() => {
if (isAdvancedEditing || isAddingNewField || blurWithActions.current) return
emitFieldChange()
})
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('flex', 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

View File

@ -0,0 +1,25 @@
import React from 'react'
import type { FC } from 'react'
import Switch from '@/app/components/base/switch'
import { useTranslation } from 'react-i18next'
type RequiredSwitchProps = {
defaultValue: boolean
toggleRequired: () => void
}
const RequiredSwitch: FC<RequiredSwitchProps> = ({
defaultValue,
toggleRequired,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1 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)

View File

@ -0,0 +1,69 @@
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import type { ArrayType, Type } from '../../../../types'
import type { FC } from 'react'
import { useState } from 'react'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
export type TypeItem = {
value: Type | ArrayType
text: string
}
type TypeSelectorProps = {
items: TypeItem[]
currentValue: Type | ArrayType
onSelect: (item: TypeItem) => void
popupClassName?: string
}
const TypeSelector: FC<TypeSelectorProps> = ({
items,
currentValue,
onSelect,
popupClassName,
}) => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn(
'flex items-center 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

View File

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

View File

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

View File

@ -0,0 +1,191 @@
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { type Field, Type } from '../../../types'
import classNames from '@/utils/classnames'
import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react'
import { getFieldType, getHasChildren } from '../../../utils'
import Divider from '@/app/components/base/divider'
import EditCard from './edit-card'
import Card from './card'
import { useVisualEditorStore } from './store'
import { useDebounceFn } from 'ahooks'
import AddField from './add-field'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
type SchemaNodeProps = {
name: string
required: boolean
schema: Field
path: string[]
parentPath?: string[]
depth: number
}
// Support 10 levels of indentation
const indentPadding: Record<number, string> = {
1: 'pl-0',
2: 'pl-[20px]',
3: 'pl-[40px]',
4: 'pl-[60px]',
5: 'pl-[80px]',
6: 'pl-[100px]',
7: 'pl-[120px]',
8: 'pl-[140px]',
9: 'pl-[160px]',
10: 'pl-[180px]',
}
const indentLeft: Record<number, string> = {
2: 'left-0',
3: 'left-[20px]',
4: 'left-[40px]',
5: 'left-[60px]',
6: 'left-[80px]',
7: 'left-[100px]',
8: 'left-[120px]',
9: 'left-[140px]',
10: 'left-[160px]',
}
const SchemaNode: FC<SchemaNodeProps> = ({
name,
required,
schema,
path,
parentPath,
depth,
}) => {
const [isExpanded, setIsExpanded] = useState(true)
const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string) => {
setHoveringProperty(path)
}, { wait: 50 })
const hasChildren = useMemo(() => getHasChildren(schema), [schema])
const type = useMemo(() => getFieldType(schema), [schema])
const isHovering = hoveringProperty === path.join('.') && depth > 1
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 > 1 && 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={classNames('mx-0', isHovering ? 'bg-divider-deep' : 'bg-divider-subtle')}
/>
</div>
{isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && (
<>
{schema.type === Type.object && schema.properties && (
Object.entries(schema.properties).map(([key, childSchema]) => (
<SchemaNode
key={key}
name={key}
required={!!schema.required?.includes(key)}
schema={childSchema}
path={[...path, 'properties', key]}
parentPath={path}
depth={depth + 1}
/>
))
)}
{schema.type === Type.array
&& schema.items
&& schema.items.type === Type.object
&& schema.items.properties
&& (
Object.entries(schema.items.properties).map(([key, childSchema]) => (
<SchemaNode
key={key}
name={key}
required={!!schema.items?.required?.includes(key)}
schema={childSchema}
path={[...path, 'items', 'properties', key]}
parentPath={path}
depth={depth + 1}
/>
))
)}
</>
)}
{
depth === 1 && !isAddingNewField && (
<AddField />
)
}
</div>
)
}
export default React.memo(SchemaNode)

View File

@ -0,0 +1,34 @@
import { useContext } from 'react'
import { createStore, useStore } from 'zustand'
import type { SchemaRoot } from '../../../types'
import { VisualEditorContext } from './context'
type VisualEditorStore = {
hoveringProperty: string | ''
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 createVisualEditorStore = () => createStore<VisualEditorStore>(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 useVisualEditorStore = <T>(selector: (state: VisualEditorStore) => T): T => {
const store = useContext(VisualEditorContext)
if (!store)
throw new Error('Missing VisualEditorContext.Provider in the tree')
return useStore(store, selector)
}

View File

@ -0,0 +1,75 @@
'use client'
import Button from '@/app/components/base/button'
import { RiEditLine } from '@remixicon/react'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { type SchemaRoot, type StructuredOutput, Type } from '../types'
import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
import { useBoolean } from 'ahooks'
import JsonSchemaConfigModal from './json-schema-config-modal'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {
className?: string
value?: StructuredOutput
onChange: (value: StructuredOutput) => void,
}
const StructureOutput: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
const [showConfig, {
setTrue: showConfigModal,
setFalse: hideConfigModal,
}] = useBoolean(false)
const handleChange = useCallback((value: SchemaRoot) => {
onChange({
schema: value,
})
}, [onChange])
return (
<div className={cn(className)}>
<div className='flex justify-between'>
<div className='flex 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 || {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}) as any} // wait for types change
onSave={handleChange as any} // wait for types change
onClose={hideConfigModal}
/>
)}
</div>
)
}
export default React.memo(StructureOutput)

View File

@ -20,6 +20,9 @@ import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/c
import ResultPanel from '@/app/components/workflow/run/result-panel' import ResultPanel from '@/app/components/workflow/run/result-panel'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' 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' const i18nPrefix = 'workflow.nodes.llm'
@ -64,6 +67,9 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
contexts, contexts,
setContexts, setContexts,
runningStatus, runningStatus,
isModelSupportStructuredOutput,
handleStructureOutputEnableChange,
handleStructureOutputChange,
handleRun, handleRun,
handleStop, handleStop,
varInputs, varInputs,
@ -272,13 +278,55 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
/> />
</div> </div>
<Split /> <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 <VarItem
name='text' name='text'
type='string' type='string'
description={t(`${i18nPrefix}.outputVars.output`)} description={t(`${i18nPrefix}.outputVars.output`)}
/> />
{inputs.structured_output_enabled && (
<>
<Split className='mt-3' />
<StructureOutput
className='mt-4'
value={inputs.structured_output}
onChange={handleStructureOutputChange}
/>
</>
)}
</> </>
</OutputVars> </OutputVars>
{isShowSingleRun && ( {isShowSingleRun && (

View File

@ -15,4 +15,51 @@ export type LLMNodeType = CommonNodeType & {
enabled: boolean enabled: boolean
configs?: VisionSetting 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
} }

View File

@ -9,7 +9,7 @@ import {
} from '../../hooks' } from '../../hooks'
import useAvailableVarList from '../_base/hooks/use-available-var-list' import useAvailableVarList from '../_base/hooks/use-available-var-list'
import useConfigVision from '../../hooks/use-config-vision' 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 { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { import {
ModelTypeEnum, 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 useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run'
import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants' import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants'
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/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 useConfig = (id: string, payload: LLMNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly() const { nodesReadOnly: readOnly } = useNodesReadOnly()
@ -277,6 +279,25 @@ const useConfig = (id: string, payload: LLMNodeType) => {
setInputs(newInputs) setInputs(newInputs)
}, [inputs, setInputs]) }, [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) => { const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type) 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, setContexts,
varInputs, varInputs,
runningStatus, runningStatus,
isModelSupportStructuredOutput,
handleStructureOutputChange,
handleStructureOutputEnableChange,
handleRun, handleRun,
handleStop, handleStop,
runResult, runResult,

View File

@ -1,5 +1,111 @@
import type { LLMNodeType } from './types' import { ArrayType, Type } from './types'
import type { ArrayItems, Field, LLMNodeType } from './types'
import Ajv, { type ErrorObject } from 'ajv'
import draft7MetaSchema from 'ajv/dist/refs/json-schema-draft-07.json'
import produce from 'immer'
export const checkNodeValid = (payload: LLMNodeType) => { export const checkNodeValid = (payload: LLMNodeType) => {
return true return true
} }
export const getFieldType = (field: Field) => {
const { type, items } = field
if (type !== Type.array || !items)
return type
return ArrayType[items.type]
}
export const getHasChildren = (schema: Field) => {
const complexTypes = [Type.object, Type.array]
if (!complexTypes.includes(schema.type))
return false
if (schema.type === Type.object)
return schema.properties && Object.keys(schema.properties).length > 0
if (schema.type === Type.array)
return schema.items && schema.items.type === Type.object && schema.items.properties && Object.keys(schema.items.properties).length > 0
}
export const inferType = (value: any): Type => {
if (Array.isArray(value)) return Type.array
if (typeof value === 'boolean') return Type.boolean
if (typeof value === 'number') return Type.number
if (typeof value === 'string') return Type.string
if (typeof value === 'object') return Type.object
return Type.string
}
export const jsonToSchema = (json: any): Field => {
const schema: Field = {
type: inferType(json),
}
if (schema.type === Type.object) {
schema.properties = {}
schema.required = []
schema.additionalProperties = false
Object.entries(json).forEach(([key, value]) => {
schema.properties![key] = jsonToSchema(value)
schema.required!.push(key)
})
}
else if (schema.type === Type.array && json.length > 0) {
schema.items = jsonToSchema(json[0]) as ArrayItems
}
return schema
}
export const checkDepth = (json: any, currentDepth = 1) => {
const type = inferType(json)
if (type !== Type.object && type !== Type.array)
return currentDepth
let maxDepth = currentDepth
if (type === Type.object) {
Object.keys(json).forEach((key) => {
const depth = checkDepth(json[key], currentDepth + 1)
maxDepth = Math.max(maxDepth, depth)
})
}
else if (type === Type.array && json.length > 0) {
const depth = checkDepth(json[0], currentDepth + 1)
maxDepth = Math.max(maxDepth, depth)
}
return maxDepth
}
export const findPropertyWithPath = (target: any, path: string[]) => {
let current = target
for (const key of path)
current = current[key]
return current
}
const ajv = new Ajv({
allErrors: true,
verbose: true,
validateSchema: true,
meta: false,
})
ajv.addMetaSchema(draft7MetaSchema)
export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
const schema = produce(schemaToValidate, (draft: any) => {
// Make sure the schema has the $schema property for draft-07
if (!draft.$schema)
draft.$schema = 'http://json-schema.org/draft-07/schema#'
})
const valid = ajv.validateSchema(schema)
return valid ? [] : ajv.errors || []
}
export const getValidationErrorMessage = (errors: ErrorObject[]) => {
const message = errors.map((error) => {
return `Error: ${error.instancePath} ${error.message} Details: ${JSON.stringify(error.params)}`
}).join('; ')
return message
}

View File

@ -14,6 +14,7 @@ import type {
ErrorHandleTypeEnum, ErrorHandleTypeEnum,
} from '@/app/components/workflow/nodes/_base/components/error-handle/types' } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { WorkflowRetryConfig } from '@/app/components/workflow/nodes/_base/components/retry/types' import type { WorkflowRetryConfig } from '@/app/components/workflow/nodes/_base/components/retry/types'
import type { StructuredOutput } from './nodes/llm/types'
export enum BlockEnum { export enum BlockEnum {
Start = 'start', Start = 'start',
@ -250,7 +251,7 @@ export enum VarType {
export type Var = { export type Var = {
variable: string variable: string
type: VarType 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 isParagraph?: boolean
isSelect?: boolean isSelect?: boolean
options?: string[] options?: string[]

View File

@ -1,19 +1,79 @@
'use client' 'use client'
import { ToolTipContent } from '../components/base/tooltip/content' import { useState } from 'react'
import { SwitchPluginVersion } from '../components/workflow/nodes/_base/components/switch-plugin-version' import { type SchemaRoot, Type } from '../components/workflow/nodes/llm/types'
import { useTranslation } from 'react-i18next' import JsonSchemaConfigModal from '../components/workflow/nodes/llm/components/json-schema-config-modal'
export default function Page() { export default function Page() {
const { t } = useTranslation() const [show, setShow] = useState(false)
return <div className="p-20"> const [schema, setSchema] = useState<SchemaRoot>({
<SwitchPluginVersion type: Type.object,
uniqueIdentifier={'langgenius/openai:12'} properties: {
tooltip={<ToolTipContent userId: {
title={t('workflow.nodes.agent.unsupportedStrategy')} type: Type.number,
> description: 'The user ID',
{t('workflow.nodes.agent.strategyNotFoundDescAndSwitchVersion')} },
</ToolTipContent>} 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> </div>
} }

View File

@ -276,3 +276,5 @@ export const GITHUB_ACCESS_TOKEN = process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN |
export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl' export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl'
export const FULL_DOC_PREVIEW_LENGTH = 50 export const FULL_DOC_PREVIEW_LENGTH = 50
export const JSON_SCHEMA_MAX_DEPTH = 10

View File

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

View File

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

View File

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

View File

@ -410,6 +410,30 @@ const translation = {
variable: 'Variable', variable: 'Variable',
}, },
sysQueryInUser: 'sys.query in user message is required', 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: { knowledgeRetrieval: {
queryVariable: 'Query Variable', queryVariable: 'Query Variable',

View File

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

View File

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

View File

@ -410,6 +410,30 @@ const translation = {
variable: '变量', variable: '变量',
}, },
sysQueryInUser: 'user message 中必须包含 sys.query', 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: { knowledgeRetrieval: {
queryVariable: '查询变量', queryVariable: '查询变量',

View File

@ -1,23 +1,24 @@
import type { I18nText } from '@/i18n/language' import type { I18nText } from '@/i18n/language'
import type { Model } from '@/types/app'
export interface CommonResponse { export type CommonResponse = {
result: 'success' | 'fail' result: 'success' | 'fail'
} }
export interface OauthResponse { export type OauthResponse = {
redirect_url: string redirect_url: string
} }
export interface SetupStatusResponse { export type SetupStatusResponse = {
step: 'finished' | 'not_started' step: 'finished' | 'not_started'
setup_at?: Date setup_at?: Date
} }
export interface InitValidateStatusResponse { export type InitValidateStatusResponse = {
status: 'finished' | 'not_started' status: 'finished' | 'not_started'
} }
export interface UserProfileResponse { export type UserProfileResponse = {
id: string id: string
name: string name: string
email: string email: string
@ -33,13 +34,13 @@ export interface UserProfileResponse {
created_at?: string created_at?: string
} }
export interface UserProfileOriginResponse { export type UserProfileOriginResponse = {
json: () => Promise<UserProfileResponse> json: () => Promise<UserProfileResponse>
bodyUsed: boolean bodyUsed: boolean
headers: any headers: any
} }
export interface LangGeniusVersionResponse { export type LangGeniusVersionResponse = {
current_version: string current_version: string
latest_version: string latest_version: string
version: string version: string
@ -49,7 +50,7 @@ export interface LangGeniusVersionResponse {
current_env: string current_env: string
} }
export interface TenantInfoResponse { export type TenantInfoResponse = {
name: string name: string
created_at: string created_at: string
providers: Array<{ providers: Array<{
@ -80,14 +81,14 @@ export enum ProviderName {
Tongyi = 'tongyi', Tongyi = 'tongyi',
ChatGLM = 'chatglm', ChatGLM = 'chatglm',
} }
export interface ProviderAzureToken { export type ProviderAzureToken = {
openai_api_base?: string openai_api_base?: string
openai_api_key?: string openai_api_key?: string
} }
export interface ProviderAnthropicToken { export type ProviderAnthropicToken = {
anthropic_api_key?: string anthropic_api_key?: string
} }
export interface ProviderTokenType { export type ProviderTokenType = {
[ProviderName.OPENAI]: string [ProviderName.OPENAI]: string
[ProviderName.AZURE_OPENAI]: ProviderAzureToken [ProviderName.AZURE_OPENAI]: ProviderAzureToken
[ProviderName.ANTHROPIC]: ProviderAnthropicToken [ProviderName.ANTHROPIC]: ProviderAnthropicToken
@ -110,14 +111,14 @@ export type ProviderHosted = Provider & {
quota_used: number quota_used: number
} }
export interface AccountIntegrate { export type AccountIntegrate = {
provider: 'google' | 'github' provider: 'google' | 'github'
created_at: number created_at: number
is_bound: boolean is_bound: boolean
link: string link: string
} }
export interface IWorkspace { export type IWorkspace = {
id: string id: string
name: string name: string
plan: string plan: string
@ -137,7 +138,7 @@ export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
} }
} }
export interface DataSourceNotionPage { export type DataSourceNotionPage = {
page_icon: null | { page_icon: null | {
type: string | null type: string | null
url: string | null url: string | null
@ -156,7 +157,7 @@ export type NotionPage = DataSourceNotionPage & {
export type DataSourceNotionPageMap = Record<string, DataSourceNotionPage & { workspace_id: string }> export type DataSourceNotionPageMap = Record<string, DataSourceNotionPage & { workspace_id: string }>
export interface DataSourceNotionWorkspace { export type DataSourceNotionWorkspace = {
workspace_name: string workspace_name: string
workspace_id: string workspace_id: string
workspace_icon: string | null workspace_icon: string | null
@ -166,7 +167,7 @@ export interface DataSourceNotionWorkspace {
export type DataSourceNotionWorkspaceMap = Record<string, DataSourceNotionWorkspace> export type DataSourceNotionWorkspaceMap = Record<string, DataSourceNotionWorkspace>
export interface DataSourceNotion { export type DataSourceNotion = {
id: string id: string
provider: string provider: string
is_bound: boolean is_bound: boolean
@ -181,12 +182,12 @@ export enum DataSourceProvider {
jinaReader = 'jinareader', jinaReader = 'jinareader',
} }
export interface FirecrawlConfig { export type FirecrawlConfig = {
api_key: string api_key: string
base_url: string base_url: string
} }
export interface DataSourceItem { export type DataSourceItem = {
id: string id: string
category: DataSourceCategory category: DataSourceCategory
provider: DataSourceProvider provider: DataSourceProvider
@ -195,15 +196,15 @@ export interface DataSourceItem {
updated_at: number updated_at: number
} }
export interface DataSources { export type DataSources = {
sources: DataSourceItem[] sources: DataSourceItem[]
} }
export interface GithubRepo { export type GithubRepo = {
stargazers_count: number stargazers_count: number
} }
export interface PluginProvider { export type PluginProvider = {
tool_name: string tool_name: string
is_enabled: boolean is_enabled: boolean
credentials: { credentials: {
@ -211,7 +212,7 @@ export interface PluginProvider {
} | null } | null
} }
export interface FileUploadConfigResponse { export type FileUploadConfigResponse = {
batch_count_limit: number batch_count_limit: number
image_file_size_limit?: number | string // default is 10MB image_file_size_limit?: number | string // default is 10MB
file_size_limit: number // default is 15MB file_size_limit: number // default is 15MB
@ -234,14 +235,14 @@ export type InvitationResponse = CommonResponse & {
invitation_results: InvitationResult[] invitation_results: InvitationResult[]
} }
export interface ApiBasedExtension { export type ApiBasedExtension = {
id?: string id?: string
name?: string name?: string
api_endpoint?: string api_endpoint?: string
api_key?: string api_key?: string
} }
export interface CodeBasedExtensionForm { export type CodeBasedExtensionForm = {
type: string type: string
label: I18nText label: I18nText
variable: string variable: string
@ -252,17 +253,17 @@ export interface CodeBasedExtensionForm {
max_length?: number max_length?: number
} }
export interface CodeBasedExtensionItem { export type CodeBasedExtensionItem = {
name: string name: string
label: any label: any
form_schema: CodeBasedExtensionForm[] form_schema: CodeBasedExtensionForm[]
} }
export interface CodeBasedExtension { export type CodeBasedExtension = {
module: string module: string
data: CodeBasedExtensionItem[] data: CodeBasedExtensionItem[]
} }
export interface ExternalDataTool { export type ExternalDataTool = {
type?: string type?: string
label?: string label?: string
icon?: string icon?: string
@ -274,7 +275,7 @@ export interface ExternalDataTool {
} & Partial<Record<string, any>> } & Partial<Record<string, any>>
} }
export interface ModerateResponse { export type ModerateResponse = {
flagged: boolean flagged: boolean
text: string text: string
} }
@ -286,3 +287,13 @@ export type ModerationService = (
text: string text: string
} }
) => Promise<ModerateResponse> ) => Promise<ModerateResponse>
export type StructuredOutputRulesRequestBody = {
instruction: string
model_config: Model
}
export type StructuredOutputRulesResponse = {
output: string
error?: string
}

View File

@ -54,6 +54,7 @@
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@tanstack/react-query-devtools": "^5.60.5", "@tanstack/react-query-devtools": "^5.60.5",
"ahooks": "^3.8.1", "ahooks": "^3.8.1",
"ajv": "^8.17.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",

View File

@ -103,6 +103,9 @@ importers:
ahooks: ahooks:
specifier: ^3.8.1 specifier: ^3.8.1
version: 3.8.1(react@18.2.0) version: 3.8.1(react@18.2.0)
ajv:
specifier: ^8.17.1
version: 8.17.1
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0

View File

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

View File

@ -113,6 +113,7 @@ const config = {
'dataset-option-card-purple-gradient': 'var(--color-dataset-option-card-purple-gradient)', 'dataset-option-card-purple-gradient': 'var(--color-dataset-option-card-purple-gradient)',
'dataset-option-card-orange-gradient': 'var(--color-dataset-option-card-orange-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)', 'dataset-chunk-list-mask-bg': 'var(--color-dataset-chunk-list-mask-bg)',
'line-divider-bg': 'var(--color-line-divider-bg)',
}, },
animation: { animation: {
'spin-slow': 'spin 2s linear infinite', 'spin-slow': 'spin 2s linear infinite',

View File

@ -33,7 +33,7 @@ html[data-theme="dark"] {
rgba(240, 68, 56, 0.3) 0%, rgba(240, 68, 56, 0.3) 0%,
rgba(0, 0, 0, 0) 100%); rgba(0, 0, 0, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg, --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, --color-account-teams-bg: linear-gradient(271deg,
rgba(34, 34, 37, 0.9) -0.1%, rgba(34, 34, 37, 0.9) -0.1%,
rgba(29, 29, 32, 0.9) 98.26% rgba(29, 29, 32, 0.9) 98.26%
@ -61,4 +61,5 @@ html[data-theme="dark"] {
180deg, 180deg,
rgba(24, 24, 27, 0.08) 0%, rgba(24, 24, 27, 0.08) 0%,
rgba(0, 0, 0, 0) 100%); rgba(0, 0, 0, 0) 100%);
--color-line-divider-bg: linear-gradient(90deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 100%,);
} }

View File

@ -33,7 +33,7 @@ html[data-theme="light"] {
rgba(240, 68, 56, 0.25) 0%, rgba(240, 68, 56, 0.25) 0%,
rgba(255, 255, 255, 0) 100%); rgba(255, 255, 255, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg, --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, --color-account-teams-bg: linear-gradient(271deg,
rgba(249, 250, 251, 0.9) -0.1%, rgba(249, 250, 251, 0.9) -0.1%,
rgba(242, 244, 247, 0.9) 98.26% rgba(242, 244, 247, 0.9) 98.26%
@ -61,4 +61,5 @@ html[data-theme="light"] {
180deg, 180deg,
rgba(200, 206, 218, 0.2) 0%, rgba(200, 206, 218, 0.2) 0%,
rgba(255, 255, 255, 0) 100%); 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%);
} }