-
-
- {description}
- {subItems && (
-
- {subItems.map((item, index) => (
-
- ))}
+
+ {isIndent &&
}
+
+
+
+ {description}
+ {subItems && (
+
+ {subItems.map((item, index) => (
+
+ ))}
+
+ )}
+
)
diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx
index dd4d837d12..c6233ff377 100644
--- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx
+++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx
@@ -35,6 +35,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store'
+import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
type Props = {
className?: string
@@ -144,6 +145,8 @@ const Editor: FC
= ({
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
}
+ const getVarType = useWorkflowVariableType()
+
return (
@@ -251,6 +254,7 @@ const Editor: FC
= ({
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
+ getVarType,
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
diff --git a/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx b/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx
index 749fb77e47..4a4ca454d3 100644
--- a/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx
+++ b/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx
@@ -9,6 +9,7 @@ import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './variab
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
+import { RiMoreLine } from '@remixicon/react'
type Props = {
nodeId: string
value: string
@@ -45,6 +46,7 @@ const ReadonlyInputWithSelectVar: FC = ({
const isChatVar = isConversationVar(value)
const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data
const varName = `${isSystem ? 'sys.' : ''}${value[value.length - 1]}`
+ const isShowAPart = value.length > 2
return (
{str}
@@ -61,6 +63,12 @@ const ReadonlyInputWithSelectVar: FC = ({
)}
+ {isShowAPart && (
+
+
+
+
+ )}
{!isEnv && !isChatVar &&
}
{isEnv &&
}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx
new file mode 100644
index 0000000000..f90f30e7ce
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx
@@ -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
= ({
+ 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 (
+
+
+ !readonly && onSelect?.([...valueSelector, name])}
+ >
+
+
+ {depth === MAX_DEPTH + 1 ? (
+
+ ) : (
{name}
)}
+
+
+ {depth < MAX_DEPTH + 1 && (
+
{getFieldType(payload)}
+ )}
+
+
+
+ {depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (
+
+ {Object.keys(payload.properties).map(propName => (
+
+ ))}
+
+ )}
+
+ )
+}
+export default React.memo(Field)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx
new file mode 100644
index 0000000000..302ed3ca75
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx
@@ -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 = ({
+ className,
+ root,
+ payload,
+ readonly,
+ onHovering,
+ onSelect,
+}) => {
+ const ref = useRef(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 (
+
+ {/* Root info */}
+
+
+ {root.nodeName && (
+ <>
+
{root.nodeName}
+
.
+ >
+ )}
+
{root.attrName}
+
+ {/* It must be object */}
+
object
+
+ {fieldNames.map(name => (
+
+ ))}
+
+ )
+}
+
+const PickerPanel: FC = ({
+ className,
+ ...props
+}) => {
+ return (
+
+ )
+}
+export default React.memo(PickerPanel)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx
new file mode 100644
index 0000000000..63b4880851
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx
@@ -0,0 +1,74 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import { Type } from '../../../../../llm/types'
+import { getFieldType } from '../../../../../llm/utils'
+import type { Field as FieldType } from '../../../../../llm/types'
+import cn from '@/utils/classnames'
+import TreeIndentLine from '../tree-indent-line'
+import { useTranslation } from 'react-i18next'
+import { useBoolean } from 'ahooks'
+import { RiArrowDropDownLine } from '@remixicon/react'
+
+type Props = {
+ name: string,
+ payload: FieldType,
+ required: boolean,
+ depth?: number,
+ rootClassName?: string
+}
+
+const Field: FC = ({
+ name,
+ payload,
+ depth = 1,
+ required,
+ rootClassName,
+}) => {
+ const { t } = useTranslation()
+ const isRoot = depth === 1
+ const hasChildren = payload.type === Type.object && payload.properties
+ const [fold, {
+ toggle: toggleFold,
+ }] = useBoolean(false)
+ return (
+
+
+
+
+
+ {hasChildren && (
+
+ )}
+
{name}
+
{getFieldType(payload)}
+ {required &&
{t('app.structOutput.required')}
}
+
+ {payload.description && (
+
+
{payload.description}
+
+ )}
+
+
+
+ {hasChildren && !fold && (
+
+ {Object.keys(payload.properties!).map(name => (
+
+ ))}
+
+ )}
+
+ )
+}
+export default React.memo(Field)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx
new file mode 100644
index 0000000000..86f707af13
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx
@@ -0,0 +1,39 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import type { StructuredOutput } from '../../../../../llm/types'
+import Field from './field'
+import { useTranslation } from 'react-i18next'
+
+type Props = {
+ payload: StructuredOutput
+ rootClassName?: string
+}
+
+const ShowPanel: FC = ({
+ payload,
+ rootClassName,
+}) => {
+ const { t } = useTranslation()
+ const schema = {
+ ...payload,
+ schema: {
+ ...payload.schema,
+ description: t('app.structOutput.LLMResponse'),
+ },
+ }
+ return (
+
+ {Object.keys(schema.schema.properties!).map(name => (
+
+ ))}
+
+ )
+}
+export default React.memo(ShowPanel)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx
new file mode 100644
index 0000000000..475c119647
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx
@@ -0,0 +1,24 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import cn from '@/utils/classnames'
+
+type Props = {
+ depth?: number,
+ className?: string,
+}
+
+const TreeIndentLine: FC = ({
+ depth = 1,
+ className,
+}) => {
+ const depthArray = Array.from({ length: depth }, (_, index) => index)
+ return (
+
+ {depthArray.map(d => (
+
+ ))}
+
+ )
+}
+export default React.memo(TreeIndentLine)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
index c27fa43049..7299bcb34e 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts
+++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
@@ -319,12 +319,19 @@ const formatItem = (
const outputSchema: any[] = []
Object.keys(output_schema.properties).forEach((outputKey) => {
const output = output_schema.properties[outputKey]
+ const dataType = output.type
outputSchema.push({
variable: outputKey,
- type: output.type === 'array'
+ type: dataType === 'array'
? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]`
: `${output.type.slice(0, 1).toLocaleLowerCase()}${output.type.slice(1)}`,
description: output.description,
+ children: output.type === 'object' ? {
+ schema: {
+ type: 'object',
+ properties: output.properties,
+ },
+ } : undefined,
})
})
res.vars = [
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx
new file mode 100644
index 0000000000..54e27b5e38
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx
@@ -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 = ({
+ 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 (
+
+ )
+}
+export default React.memo(VarFullPathPanel)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
index 568dd7150a..789da34f9d 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
+++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
@@ -6,13 +6,14 @@ import {
RiArrowDownSLine,
RiCloseLine,
RiErrorWarningFill,
+ RiMoreLine,
} from '@remixicon/react'
import produce from 'immer'
import { useStoreApi } from 'reactflow'
import RemoveButton from '../remove-button'
import useAvailableVarList from '../../hooks/use-available-var-list'
import VarReferencePopup from './var-reference-popup'
-import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './utils'
+import { getNodeInfoById, isConversationVar, isENV, isSystemVar, varTypeToStructType } from './utils'
import ConstantField from './constant-field'
import cn from '@/utils/classnames'
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
@@ -37,6 +38,7 @@ import AddButton from '@/app/components/base/button/add-button'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
+import VarFullPathPanel from './var-full-path-panel'
import { noop } from 'lodash-es'
const TRIGGER_DEFAULT_WIDTH = 227
@@ -173,16 +175,15 @@ const VarReferencePicker: FC = ({
return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
- const varName = useMemo(() => {
- if (hasValue) {
- const isSystem = isSystemVar(value as ValueSelector)
- let varName = ''
- if (Array.isArray(value))
- varName = value.length >= 3 ? (value as ValueSelector).slice(-2).join('.') : value[value.length - 1]
+ const isShowAPart = (value as ValueSelector).length > 2
- return `${isSystem ? 'sys.' : ''}${varName}`
- }
- return ''
+ const varName = useMemo(() => {
+ if (!hasValue)
+ return ''
+
+ const isSystem = isSystemVar(value as ValueSelector)
+ const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
+ return `${isSystem ? 'sys.' : ''}${varName}`
}, [hasValue, value])
const varKindTypes = [
@@ -270,6 +271,22 @@ const VarReferencePicker: FC = ({
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
+
+ const tooltipPopup = useMemo(() => {
+ if (isValidVar && isShowAPart) {
+ return (
+ )
+ }
+ if (!isValidVar && hasValue)
+ return t('workflow.errorMsg.invalidVariable')
+
+ return null
+ }, [isValidVar, isShowAPart, hasValue, t, outputVarNode?.title, outputVarNode?.type, value, type])
return (
= ({
className='h-full grow'
>
-
+
{hasValue
? (
@@ -353,6 +370,12 @@ const VarReferencePicker: FC
= ({
)}
+ {isShowAPart && (
+
+
+
+
+ )}
{!hasValue &&
}
{isEnv &&
}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx
index 27117c81b8..751e1990cf 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx
+++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
-import React, { useEffect, useRef, useState } from 'react'
+import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHover } from 'ahooks'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
@@ -15,6 +15,11 @@ import {
import Input from '@/app/components/base/input'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { checkKeys } from '@/utils/var'
+import type { StructuredOutput } from '../../../llm/types'
+import { Type } from '../../../llm/types'
+import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
+import { varTypeToStructType } from './utils'
+import type { Field } from '@/app/components/workflow/nodes/llm/types'
import { FILE_STRUCT } from '@/app/components/workflow/constants'
import { Loop } from '@/app/components/base/icons/src/vender/workflow'
import { noop } from 'lodash-es'
@@ -52,16 +57,41 @@ const Item: FC
= ({
itemData,
onChange,
onHovering,
- itemWidth,
isSupportFileVar,
isException,
isLoopVar,
}) => {
- const isFile = itemData.type === VarType.file
- const isObj = (objVarTypes.includes(itemData.type) && itemData.children && itemData.children.length > 0)
+ const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
+ const isFile = itemData.type === VarType.file && !isStructureOutput
+ const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
+
+ const objStructuredOutput: StructuredOutput | null = useMemo(() => {
+ if (!isObj) return null
+ const properties: Record = {};
+ (isFile ? FILE_STRUCT : (itemData.children as Var[])).forEach((c) => {
+ properties[c.variable] = {
+ type: varTypeToStructType(c.type),
+ }
+ })
+ return {
+ schema: {
+ type: Type.object,
+ properties,
+ required: [],
+ additionalProperties: false,
+ },
+ }
+ }, [isFile, isObj, itemData.children])
+
+ const structuredOutput = (() => {
+ if (isStructureOutput)
+ return itemData.children as StructuredOutput
+ return objStructuredOutput
+ })()
+
const itemRef = useRef(null)
const [isItemHovering, setIsItemHovering] = useState(false)
useHover(itemRef, {
@@ -70,7 +100,7 @@ const Item: FC = ({
setIsItemHovering(true)
}
else {
- if (isObj) {
+ if (isObj || isStructureOutput) {
setTimeout(() => {
setIsItemHovering(false)
}, 100)
@@ -83,7 +113,7 @@ const Item: FC = ({
})
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering
- const open = isObj && isHovering
+ const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -110,8 +140,8 @@ const Item: FC = ({
= ({
)}
{itemData.type}
- {isObj && (
-
- )}
-
-
+ {
+ (isObj || isStructureOutput) && (
+
+ )
+ }
+
+
- {(isObj && !isFile) && (
- // eslint-disable-next-line ts/no-use-before-define
-
- )}
- {isFile && (
- // eslint-disable-next-line ts/no-use-before-define
- {
+ onChange(valueSelector, itemData)
+ }}
/>
)}
-
+
)
}
@@ -331,7 +347,7 @@ const VarReferenceVars: FC
= ({
}
: {t('workflow.common.noVar')}
}
- >
+ >
)
}
export default React.memo(VarReferenceVars)
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx
index 925970e311..8ea313dd26 100644
--- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx
+++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx
@@ -39,7 +39,8 @@ const MetadataFilter = ({
disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual}
collapsed={collapsed}
onCollapse={setCollapsed}
- trigger={
+ hideCollapseIcon
+ trigger={collapseIcon => (
@@ -52,6 +53,7 @@ const MetadataFilter = ({
)}
/>
+ {collapseIcon}
- }
+ )}
>
<>
{
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx
new file mode 100644
index 0000000000..a3c2552b45
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx
@@ -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
+
+const CodeEditor: FC = ({
+ value,
+ onUpdate,
+ showFormatButton = true,
+ editorWrapperClassName,
+ readOnly = false,
+ className,
+}) => {
+ const { t } = useTranslation()
+ const { theme } = useTheme()
+ const monacoRef = useRef(null)
+ const editorRef = useRef(null)
+
+ useEffect(() => {
+ if (monacoRef.current) {
+ if (theme === Theme.light)
+ monacoRef.current.editor.setTheme('light-theme')
+ else
+ monacoRef.current.editor.setTheme('dark-theme')
+ }
+ }, [theme])
+
+ const handleEditorDidMount = useCallback((editor: any, monaco: any) => {
+ editorRef.current = editor
+ monacoRef.current = monaco
+ monaco.editor.defineTheme('light-theme', {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editor.background': '#00000000',
+ 'editor.lineHighlightBackground': '#00000000',
+ 'focusBorder': '#00000000',
+ },
+ })
+ monaco.editor.defineTheme('dark-theme', {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editor.background': '#00000000',
+ 'editor.lineHighlightBackground': '#00000000',
+ 'focusBorder': '#00000000',
+ },
+ })
+ monaco.editor.setTheme('light-theme')
+ }, [])
+
+ const formatJsonContent = useCallback(() => {
+ if (editorRef.current)
+ editorRef.current.getAction('editor.action.formatDocument')?.run()
+ }, [])
+
+ const handleEditorChange = useCallback((value: string | undefined) => {
+ if (value !== undefined)
+ onUpdate?.(value)
+ }, [onUpdate])
+
+ return (
+
+
+
+ JSON
+
+
+ {showFormatButton && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default React.memo(CodeEditor)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx
new file mode 100644
index 0000000000..2685182f9f
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx
@@ -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
+
+const ErrorMessage: FC = ({
+ message,
+ className,
+}) => {
+ return (
+
+ )
+}
+
+export default React.memo(ErrorMessage)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx
new file mode 100644
index 0000000000..d34836d5b2
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx
@@ -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 = ({
+ isShow,
+ defaultSchema,
+ onSave,
+ onClose,
+}) => {
+ return (
+
+
+
+ )
+}
+
+export default JsonSchemaConfigModal
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx
new file mode 100644
index 0000000000..643059adbd
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx
@@ -0,0 +1,136 @@
+import React, { type FC, useCallback, useEffect, useRef, useState } from 'react'
+import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
+import cn from '@/utils/classnames'
+import { useTranslation } from 'react-i18next'
+import { RiCloseLine } from '@remixicon/react'
+import Button from '@/app/components/base/button'
+import { checkJsonDepth } from '../../utils'
+import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
+import CodeEditor from './code-editor'
+import ErrorMessage from './error-message'
+import { useVisualEditorStore } from './visual-editor/store'
+import { useMittContext } from './visual-editor/context'
+
+type JsonImporterProps = {
+ onSubmit: (schema: any) => void
+ updateBtnWidth: (width: number) => void
+}
+
+const JsonImporter: FC = ({
+ onSubmit,
+ updateBtnWidth,
+}) => {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const [json, setJson] = useState('')
+ const [parseError, setParseError] = useState(null)
+ const importBtnRef = useRef(null)
+ const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
+ const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
+ const { emit } = useMittContext()
+
+ useEffect(() => {
+ if (importBtnRef.current) {
+ const rect = importBtnRef.current.getBoundingClientRect()
+ updateBtnWidth(rect.width)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const handleTrigger = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (advancedEditing || isAddingNewField)
+ emit('quitEditing', {})
+ setOpen(!open)
+ }, [open, advancedEditing, isAddingNewField, emit])
+
+ const onClose = useCallback(() => {
+ setOpen(false)
+ }, [])
+
+ const handleSubmit = useCallback(() => {
+ try {
+ const parsedJSON = JSON.parse(json)
+ if (typeof parsedJSON !== 'object' || Array.isArray(parsedJSON)) {
+ setParseError(new Error('Root must be an object, not an array or primitive value.'))
+ return
+ }
+ const maxDepth = checkJsonDepth(parsedJSON)
+ if (maxDepth > JSON_SCHEMA_MAX_DEPTH) {
+ setParseError({
+ type: 'error',
+ message: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`,
+ })
+ return
+ }
+ onSubmit(parsedJSON)
+ setParseError(null)
+ setOpen(false)
+ }
+ catch (e: any) {
+ if (e instanceof Error)
+ setParseError(e)
+ else
+ setParseError(new Error('Invalid JSON'))
+ }
+ }, [onSubmit, json])
+
+ return (
+
+
+
+
+
+
+ {/* Title */}
+
+
+
+
+
+ {t('workflow.nodes.llm.jsonSchema.import')}
+
+
+ {/* Content */}
+
+
+ {parseError && }
+
+ {/* Footer */}
+
+
+
+
+
+
+
+ )
+}
+
+export default JsonImporter
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx
new file mode 100644
index 0000000000..d125e31dae
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx
@@ -0,0 +1,301 @@
+import React, { type FC, useCallback, useState } from 'react'
+import { type SchemaRoot, Type } from '../../types'
+import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
+import { SegmentedControl } from '../../../../../base/segmented-control'
+import JsonSchemaGenerator from './json-schema-generator'
+import Divider from '@/app/components/base/divider'
+import JsonImporter from './json-importer'
+import { useTranslation } from 'react-i18next'
+import Button from '@/app/components/base/button'
+import VisualEditor from './visual-editor'
+import SchemaEditor from './schema-editor'
+import {
+ checkJsonSchemaDepth,
+ convertBooleanToString,
+ getValidationErrorMessage,
+ jsonToSchema,
+ preValidateSchema,
+ validateSchemaAgainstDraft7,
+} from '../../utils'
+import { MittProvider, VisualEditorContextProvider, useMittContext } from './visual-editor/context'
+import ErrorMessage from './error-message'
+import { useVisualEditorStore } from './visual-editor/store'
+import Toast from '@/app/components/base/toast'
+import { useGetLanguage } from '@/context/i18n'
+import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
+
+type JsonSchemaConfigProps = {
+ defaultSchema?: SchemaRoot
+ onSave: (schema: SchemaRoot) => void
+ onClose: () => void
+}
+
+enum SchemaView {
+ VisualEditor = 'visualEditor',
+ JsonSchema = 'jsonSchema',
+}
+
+const VIEW_TABS = [
+ { Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor },
+ { Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema },
+]
+
+const DEFAULT_SCHEMA: SchemaRoot = {
+ type: Type.object,
+ properties: {},
+ required: [],
+ additionalProperties: false,
+}
+
+const HELP_DOC_URL = {
+ zh_Hans: 'https://docs.dify.ai/zh-hans/guides/workflow/structured-outputs',
+ en_US: 'https://docs.dify.ai/guides/workflow/structured-outputs',
+ ja_JP: 'https://docs.dify.ai/ja-jp/guides/workflow/structured-outputs',
+}
+
+type LocaleKey = keyof typeof HELP_DOC_URL
+
+const JsonSchemaConfig: FC = ({
+ defaultSchema,
+ onSave,
+ onClose,
+}) => {
+ const { t } = useTranslation()
+ const locale = useGetLanguage() as LocaleKey
+ const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
+ const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
+ const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
+ const [btnWidth, setBtnWidth] = useState(0)
+ const [parseError, setParseError] = useState(null)
+ const [validationError, setValidationError] = useState('')
+ const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
+ const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
+ const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
+ const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
+ const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
+ const { emit } = useMittContext()
+
+ const updateBtnWidth = useCallback((width: number) => {
+ setBtnWidth(width + 32)
+ }, [])
+
+ const handleTabChange = useCallback((value: SchemaView) => {
+ if (currentTab === value) return
+ if (currentTab === SchemaView.JsonSchema) {
+ try {
+ const schema = JSON.parse(json)
+ setParseError(null)
+ const result = preValidateSchema(schema)
+ if (!result.success) {
+ setValidationError(result.error.message)
+ return
+ }
+ const schemaDepth = checkJsonSchemaDepth(schema)
+ if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
+ setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
+ return
+ }
+ convertBooleanToString(schema)
+ const validationErrors = validateSchemaAgainstDraft7(schema)
+ if (validationErrors.length > 0) {
+ setValidationError(getValidationErrorMessage(validationErrors))
+ return
+ }
+ setJsonSchema(schema)
+ setValidationError('')
+ }
+ catch (error) {
+ setValidationError('')
+ if (error instanceof Error)
+ setParseError(error)
+ else
+ setParseError(new Error('Invalid JSON'))
+ return
+ }
+ }
+ else if (currentTab === SchemaView.VisualEditor) {
+ if (advancedEditing || isAddingNewField)
+ emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
+ else
+ setJson(JSON.stringify(jsonSchema, null, 2))
+ }
+
+ setCurrentTab(value)
+ }, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit])
+
+ const handleApplySchema = useCallback((schema: SchemaRoot) => {
+ if (currentTab === SchemaView.VisualEditor)
+ setJsonSchema(schema)
+ else if (currentTab === SchemaView.JsonSchema)
+ setJson(JSON.stringify(schema, null, 2))
+ }, [currentTab])
+
+ const handleSubmit = useCallback((schema: any) => {
+ const jsonSchema = jsonToSchema(schema) as SchemaRoot
+ if (currentTab === SchemaView.VisualEditor)
+ setJsonSchema(jsonSchema)
+ else if (currentTab === SchemaView.JsonSchema)
+ setJson(JSON.stringify(jsonSchema, null, 2))
+ }, [currentTab])
+
+ const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
+ setJsonSchema(schema)
+ }, [])
+
+ const handleSchemaEditorUpdate = useCallback((schema: string) => {
+ setJson(schema)
+ }, [])
+
+ const handleResetDefaults = useCallback(() => {
+ if (currentTab === SchemaView.VisualEditor) {
+ setHoveringProperty(null)
+ advancedEditing && setAdvancedEditing(false)
+ isAddingNewField && setIsAddingNewField(false)
+ }
+ setJsonSchema(DEFAULT_SCHEMA)
+ setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2))
+ }, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty])
+
+ const handleCancel = useCallback(() => {
+ onClose()
+ }, [onClose])
+
+ const handleSave = useCallback(() => {
+ let schema = jsonSchema
+ if (currentTab === SchemaView.JsonSchema) {
+ try {
+ schema = JSON.parse(json)
+ setParseError(null)
+ const result = preValidateSchema(schema)
+ if (!result.success) {
+ setValidationError(result.error.message)
+ return
+ }
+ const schemaDepth = checkJsonSchemaDepth(schema)
+ if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
+ setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
+ return
+ }
+ convertBooleanToString(schema)
+ const validationErrors = validateSchemaAgainstDraft7(schema)
+ if (validationErrors.length > 0) {
+ setValidationError(getValidationErrorMessage(validationErrors))
+ return
+ }
+ setJsonSchema(schema)
+ setValidationError('')
+ }
+ catch (error) {
+ setValidationError('')
+ if (error instanceof Error)
+ setParseError(error)
+ else
+ setParseError(new Error('Invalid JSON'))
+ return
+ }
+ }
+ else if (currentTab === SchemaView.VisualEditor) {
+ if (advancedEditing || isAddingNewField) {
+ Toast.notify({
+ type: 'warning',
+ message: t('workflow.nodes.llm.jsonSchema.warningTips.saveSchema'),
+ })
+ return
+ }
+ }
+ onSave(schema)
+ onClose()
+ }, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t])
+
+ return (
+
+ {/* Header */}
+
+
+ {t('workflow.nodes.llm.jsonSchema.title')}
+
+
+
+
+
+ {/* Content */}
+
+ {/* Tab */}
+
+ options={VIEW_TABS}
+ value={currentTab}
+ onChange={handleTabChange}
+ />
+
+ {/* JSON Schema Generator */}
+
+
+ {/* JSON Schema Importer */}
+
+
+
+
+ {currentTab === SchemaView.VisualEditor && (
+
+ )}
+ {currentTab === SchemaView.JsonSchema && (
+
+ )}
+ {parseError && }
+ {validationError && }
+
+ {/* Footer */}
+
+
+ )
+}
+
+const JsonSchemaConfigWrapper: FC = (props) => {
+ return (
+
+
+
+
+
+ )
+}
+
+export default JsonSchemaConfigWrapper
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx
new file mode 100644
index 0000000000..5f1f117086
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx
@@ -0,0 +1,7 @@
+import SchemaGeneratorLight from './schema-generator-light'
+import SchemaGeneratorDark from './schema-generator-dark'
+
+export {
+ SchemaGeneratorLight,
+ SchemaGeneratorDark,
+}
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx
new file mode 100644
index 0000000000..ac4793b1e3
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx
@@ -0,0 +1,15 @@
+const SchemaGeneratorDark = () => {
+ return (
+
+ )
+}
+
+export default SchemaGeneratorDark
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx
new file mode 100644
index 0000000000..8b898bde68
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx
@@ -0,0 +1,15 @@
+const SchemaGeneratorLight = () => {
+ return (
+
+ )
+}
+
+export default SchemaGeneratorLight
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx
new file mode 100644
index 0000000000..00f57237e5
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx
@@ -0,0 +1,121 @@
+import React, { type FC, useCallback, useMemo, useState } from 'react'
+import type { SchemaRoot } from '../../../types'
+import { RiArrowLeftLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import Button from '@/app/components/base/button'
+import CodeEditor from '../code-editor'
+import ErrorMessage from '../error-message'
+import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils'
+import Loading from '@/app/components/base/loading'
+
+type GeneratedResultProps = {
+ schema: SchemaRoot
+ isGenerating: boolean
+ onBack: () => void
+ onRegenerate: () => void
+ onClose: () => void
+ onApply: () => void
+}
+
+const GeneratedResult: FC = ({
+ schema,
+ isGenerating,
+ onBack,
+ onRegenerate,
+ onClose,
+ onApply,
+}) => {
+ const { t } = useTranslation()
+ const [parseError, setParseError] = useState(null)
+ const [validationError, setValidationError] = useState('')
+
+ const formatJSON = (json: SchemaRoot) => {
+ try {
+ const schema = JSON.stringify(json, null, 2)
+ setParseError(null)
+ return schema
+ }
+ catch (e) {
+ if (e instanceof Error)
+ setParseError(e)
+ else
+ setParseError(new Error('Invalid JSON'))
+ return ''
+ }
+ }
+
+ const jsonSchema = useMemo(() => formatJSON(schema), [schema])
+
+ const handleApply = useCallback(() => {
+ const validationErrors = validateSchemaAgainstDraft7(schema)
+ if (validationErrors.length > 0) {
+ setValidationError(getValidationErrorMessage(validationErrors))
+ return
+ }
+ onApply()
+ setValidationError('')
+ }, [schema, onApply])
+
+ return (
+
+ {
+ isGenerating ? (
+
+
+
{t('workflow.nodes.llm.jsonSchema.generating')}
+
+ ) : (
+ <>
+
+
+
+ {/* Title */}
+
+
+ {t('workflow.nodes.llm.jsonSchema.generatedResult')}
+
+
+ {t('workflow.nodes.llm.jsonSchema.resultTip')}
+
+
+ {/* Content */}
+
+
+ {parseError && }
+ {validationError && }
+
+ {/* Footer */}
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ )
+}
+
+export default React.memo(GeneratedResult)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx
new file mode 100644
index 0000000000..4732499f3a
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx
@@ -0,0 +1,183 @@
+import React, { type FC, useCallback, useEffect, useState } from 'react'
+import type { SchemaRoot } from '../../../types'
+import {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import useTheme from '@/hooks/use-theme'
+import type { CompletionParams, Model } from '@/types/app'
+import { ModelModeType } from '@/types/app'
+import { Theme } from '@/types/app'
+import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
+import cn from '@/utils/classnames'
+import type { ModelInfo } from './prompt-editor'
+import PromptEditor from './prompt-editor'
+import GeneratedResult from './generated-result'
+import { useGenerateStructuredOutputRules } from '@/service/use-common'
+import Toast from '@/app/components/base/toast'
+import { type FormValue, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
+import { useVisualEditorStore } from '../visual-editor/store'
+import { useTranslation } from 'react-i18next'
+import { useMittContext } from '../visual-editor/context'
+
+type JsonSchemaGeneratorProps = {
+ onApply: (schema: SchemaRoot) => void
+ crossAxisOffset?: number
+}
+
+enum GeneratorView {
+ promptEditor = 'promptEditor',
+ result = 'result',
+}
+
+export const JsonSchemaGenerator: FC = ({
+ onApply,
+ crossAxisOffset,
+}) => {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const [view, setView] = useState(GeneratorView.promptEditor)
+ const [model, setModel] = useState({
+ name: '',
+ provider: '',
+ mode: ModelModeType.completion,
+ completion_params: {} as CompletionParams,
+ })
+ const [instruction, setInstruction] = useState('')
+ const [schema, setSchema] = useState(null)
+ const { theme } = useTheme()
+ const {
+ defaultModel,
+ } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
+ const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
+ const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
+ const { emit } = useMittContext()
+ const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
+
+ useEffect(() => {
+ if (defaultModel) {
+ setModel(prev => ({
+ ...prev,
+ name: defaultModel.model,
+ provider: defaultModel.provider.provider,
+ }))
+ }
+ }, [defaultModel])
+
+ const handleTrigger = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (advancedEditing || isAddingNewField)
+ emit('quitEditing', {})
+ setOpen(!open)
+ }, [open, advancedEditing, isAddingNewField, emit])
+
+ const onClose = useCallback(() => {
+ setOpen(false)
+ }, [])
+
+ const handleModelChange = useCallback((model: ModelInfo) => {
+ setModel(prev => ({
+ ...prev,
+ provider: model.provider,
+ name: model.modelId,
+ mode: model.mode as ModelModeType,
+ }))
+ }, [])
+
+ const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
+ setModel(prev => ({
+ ...prev,
+ completion_params: newParams as CompletionParams,
+ }),
+ )
+ }, [])
+
+ const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
+
+ const generateSchema = useCallback(async () => {
+ const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
+ if (error) {
+ Toast.notify({
+ type: 'error',
+ message: error,
+ })
+ setSchema(null)
+ setView(GeneratorView.promptEditor)
+ return
+ }
+ return output
+ }, [instruction, model, generateStructuredOutputRules])
+
+ const handleGenerate = useCallback(async () => {
+ setView(GeneratorView.result)
+ const output = await generateSchema()
+ if (output === undefined) return
+ setSchema(JSON.parse(output))
+ }, [generateSchema])
+
+ const goBackToPromptEditor = () => {
+ setView(GeneratorView.promptEditor)
+ }
+
+ const handleRegenerate = useCallback(async () => {
+ const output = await generateSchema()
+ if (output === undefined) return
+ setSchema(JSON.parse(output))
+ }, [generateSchema])
+
+ const handleApply = () => {
+ onApply(schema!)
+ setOpen(false)
+ }
+
+ return (
+
+
+
+
+
+ {view === GeneratorView.promptEditor && (
+
+ )}
+ {view === GeneratorView.result && (
+
+ )}
+
+
+ )
+}
+
+export default JsonSchemaGenerator
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx
new file mode 100644
index 0000000000..9387813ee5
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx
@@ -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 = ({
+ instruction,
+ model,
+ onInstructionChange,
+ onCompletionParamsChange,
+ onClose,
+ onGenerate,
+ onModelChange,
+}) => {
+ const { t } = useTranslation()
+
+ const handleInstructionChange = useCallback((e: React.ChangeEvent) => {
+ onInstructionChange(e.target.value)
+ }, [onInstructionChange])
+
+ return (
+
+
+
+
+ {/* Title */}
+
+
+ {t('workflow.nodes.llm.jsonSchema.generateJsonSchema')}
+
+
+ {t('workflow.nodes.llm.jsonSchema.generationTip')}
+
+
+ {/* Content */}
+
+
+ {t('common.modelProvider.model')}
+
+
+
+
+
+ {t('workflow.nodes.llm.jsonSchema.instruction')}
+
+
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+ )
+}
+
+export default React.memo(PromptEditor)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx
new file mode 100644
index 0000000000..e78b9224b2
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx
@@ -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 = ({
+ schema,
+ onUpdate,
+}) => {
+ return (
+
+ )
+}
+
+export default SchemaEditor
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx
new file mode 100644
index 0000000000..ab28233841
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx
@@ -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 (
+
+
+
+ )
+}
+
+export default React.memo(AddField)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx
new file mode 100644
index 0000000000..4f53f6b163
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx
@@ -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 = ({
+ name,
+ type,
+ required,
+ description,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {name}
+
+
+ {type}
+
+ {
+ required && (
+
+ {t('workflow.nodes.llm.jsonSchema.required')}
+
+ )
+ }
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ )
+}
+
+export default React.memo(Card)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx
new file mode 100644
index 0000000000..5bf4b22f11
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx
@@ -0,0 +1,50 @@
+import {
+ createContext,
+ useContext,
+ useRef,
+} from 'react'
+import { createVisualEditorStore } from './store'
+import { useMitt } from '@/hooks/use-mitt'
+import { noop } from 'lodash-es'
+
+type VisualEditorStore = ReturnType
+
+type VisualEditorContextType = VisualEditorStore | null
+
+type VisualEditorProviderProps = {
+ children: React.ReactNode
+}
+
+export const VisualEditorContext = createContext(null)
+
+export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => {
+ const storeRef = useRef()
+
+ if (!storeRef.current)
+ storeRef.current = createVisualEditorStore()
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const MittContext = createContext>({
+ emit: noop,
+ useSubscribe: noop,
+})
+
+export const MittProvider = ({ children }: { children: React.ReactNode }) => {
+ const mitt = useMitt()
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useMittContext = () => {
+ return useContext(MittContext)
+}
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx
new file mode 100644
index 0000000000..3f693c23c7
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx
@@ -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 = ({
+ disableAddBtn,
+ onAddChildField,
+ onEdit,
+ onDelete,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default React.memo(Actions)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx
new file mode 100644
index 0000000000..e065406bde
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx
@@ -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 (
+
+ {keyName}
+
+ )
+}
+
+const AdvancedActions: FC = ({
+ isConfirmDisabled,
+ onCancel,
+ onConfirm,
+}) => {
+ const { t } = useTranslation()
+
+ useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
+ e.preventDefault()
+ onConfirm()
+ }, {
+ exactMatch: true,
+ useCapture: true,
+ })
+
+ return (
+
+
+
+
+ )
+}
+
+export default React.memo(AdvancedActions)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx
new file mode 100644
index 0000000000..cd06fc8244
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx
@@ -0,0 +1,77 @@
+import React, { type FC, useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Divider from '@/app/components/base/divider'
+import Textarea from '@/app/components/base/textarea'
+
+export type AdvancedOptionsType = {
+ enum: string
+}
+
+type AdvancedOptionsProps = {
+ options: AdvancedOptionsType
+ onChange: (options: AdvancedOptionsType) => void
+}
+
+const AdvancedOptions: FC = ({
+ onChange,
+ options,
+}) => {
+ const { t } = useTranslation()
+ // const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
+ const [enumValue, setEnumValue] = useState(options.enum)
+
+ const handleEnumChange = useCallback((e: React.ChangeEvent) => {
+ setEnumValue(e.target.value)
+ }, [])
+
+ const handleEnumBlur = useCallback((e: React.FocusEvent) => {
+ onChange({ enum: e.target.value })
+ }, [onChange])
+
+ // const handleToggleAdvancedOptions = useCallback(() => {
+ // setShowAdvancedOptions(prev => !prev)
+ // }, [])
+
+ return (
+
+ {/* {showAdvancedOptions ? ( */}
+
+
+
+ {t('workflow.nodes.llm.jsonSchema.stringValidations')}
+
+
+
+
+
+ {/* ) : (
+
+ )} */}
+
+ )
+}
+
+export default React.memo(AdvancedOptions)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx
new file mode 100644
index 0000000000..af4a82c772
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx
@@ -0,0 +1,81 @@
+import React, { useEffect, useState } from 'react'
+import type { FC } from 'react'
+import cn from '@/utils/classnames'
+
+type AutoWidthInputProps = {
+ value: string
+ placeholder: string
+ onChange: (event: React.ChangeEvent) => void
+ onBlur: () => void
+ minWidth?: number
+ maxWidth?: number
+} & Omit, 'onChange'>
+
+const AutoWidthInput: FC = ({
+ value,
+ placeholder,
+ onChange,
+ onBlur,
+ minWidth = 60,
+ maxWidth = 300,
+ className,
+ ...props
+}) => {
+ const [width, setWidth] = useState(minWidth)
+ const textRef = React.useRef(null)
+
+ useEffect(() => {
+ if (textRef.current) {
+ textRef.current.textContent = value || placeholder
+ const textWidth = textRef.current.offsetWidth
+ const newWidth = Math.max(minWidth, Math.min(textWidth + 16, maxWidth))
+ if (width !== newWidth)
+ setWidth(newWidth)
+ }
+ }, [value, placeholder, minWidth, maxWidth, width])
+
+ // Handle Enter key
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && e.currentTarget.blur)
+ e.currentTarget.blur()
+ if (props.onKeyUp)
+ props.onKeyUp(e)
+ }
+
+ return (
+
+ {/* Hidden measurement span */}
+
+ {value || placeholder}
+
+
+ {/* Actual input element */}
+
+
+ )
+}
+
+export default React.memo(AutoWidthInput)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx
new file mode 100644
index 0000000000..4023a937fd
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx
@@ -0,0 +1,277 @@
+import React, { type FC, useCallback, useMemo, useRef, useState } from 'react'
+import type { SchemaEnumType } from '../../../../types'
+import { ArrayType, Type } from '../../../../types'
+import type { TypeItem } from './type-selector'
+import TypeSelector from './type-selector'
+import RequiredSwitch from './required-switch'
+import Divider from '@/app/components/base/divider'
+import Actions from './actions'
+import AdvancedActions from './advanced-actions'
+import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
+import { useTranslation } from 'react-i18next'
+import classNames from '@/utils/classnames'
+import { useVisualEditorStore } from '../store'
+import { useMittContext } from '../context'
+import { useUnmount } from 'ahooks'
+import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
+import AutoWidthInput from './auto-width-input'
+
+export type EditData = {
+ name: string
+ type: Type | ArrayType
+ required: boolean
+ description?: string
+ enum?: SchemaEnumType
+}
+
+type Options = {
+ description?: string
+ enum?: SchemaEnumType
+}
+
+type EditCardProps = {
+ fields: EditData
+ depth: number
+ path: string[]
+ parentPath: string[]
+}
+
+const TYPE_OPTIONS = [
+ { value: Type.string, text: 'string' },
+ { value: Type.number, text: 'number' },
+ // { value: Type.boolean, text: 'boolean' },
+ { value: Type.object, text: 'object' },
+ { value: ArrayType.string, text: 'array[string]' },
+ { value: ArrayType.number, text: 'array[number]' },
+ // { value: ArrayType.boolean, text: 'array[boolean]' },
+ { value: ArrayType.object, text: 'array[object]' },
+]
+
+const MAXIMUM_DEPTH_TYPE_OPTIONS = [
+ { value: Type.string, text: 'string' },
+ { value: Type.number, text: 'number' },
+ // { value: Type.boolean, text: 'boolean' },
+ { value: ArrayType.string, text: 'array[string]' },
+ { value: ArrayType.number, text: 'array[number]' },
+ // { value: ArrayType.boolean, text: 'array[boolean]' },
+]
+
+const EditCard: FC = ({
+ fields,
+ depth,
+ path,
+ parentPath,
+}) => {
+ const { t } = useTranslation()
+ const [currentFields, setCurrentFields] = useState(fields)
+ const [backupFields, setBackupFields] = useState(null)
+ const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
+ const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
+ const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
+ const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
+ const { emit, useSubscribe } = useMittContext()
+ const blurWithActions = useRef(false)
+
+ const maximumDepthReached = depth === JSON_SCHEMA_MAX_DEPTH
+ const disableAddBtn = maximumDepthReached || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
+ const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
+ const isAdvancedEditing = advancedEditing || isAddingNewField
+
+ const advancedOptions = useMemo(() => {
+ let enumValue = ''
+ if (currentFields.type === Type.string || currentFields.type === Type.number)
+ enumValue = (currentFields.enum || []).join(', ')
+ return { enum: enumValue }
+ }, [currentFields.type, currentFields.enum])
+
+ useSubscribe('restorePropertyName', () => {
+ setCurrentFields(prev => ({ ...prev, name: fields.name }))
+ })
+
+ useSubscribe('fieldChangeSuccess', () => {
+ isAddingNewField && setIsAddingNewField(false)
+ advancedEditing && setAdvancedEditing(false)
+ })
+
+ const emitPropertyNameChange = useCallback(() => {
+ emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields })
+ }, [fields, currentFields, path, parentPath, emit])
+
+ const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => {
+ emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, type } })
+ }, [fields, currentFields, path, parentPath, emit])
+
+ const emitPropertyRequiredToggle = useCallback(() => {
+ emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields })
+ }, [emit, path, parentPath, fields, currentFields])
+
+ const emitPropertyOptionsChange = useCallback((options: Options) => {
+ emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } })
+ }, [emit, path, parentPath, fields, currentFields])
+
+ const emitPropertyDelete = useCallback(() => {
+ emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields })
+ }, [emit, path, parentPath, fields, currentFields])
+
+ const emitPropertyAdd = useCallback(() => {
+ emit('addField', { path })
+ }, [emit, path])
+
+ const emitFieldChange = useCallback(() => {
+ emit('fieldChange', { path, parentPath, oldFields: fields, fields: currentFields })
+ }, [emit, path, parentPath, fields, currentFields])
+
+ const handlePropertyNameChange = useCallback((e: React.ChangeEvent) => {
+ setCurrentFields(prev => ({ ...prev, name: e.target.value }))
+ }, [])
+
+ const handlePropertyNameBlur = useCallback(() => {
+ if (isAdvancedEditing) return
+ emitPropertyNameChange()
+ }, [isAdvancedEditing, emitPropertyNameChange])
+
+ const handleTypeChange = useCallback((item: TypeItem) => {
+ setCurrentFields(prev => ({ ...prev, type: item.value }))
+ if (isAdvancedEditing) return
+ emitPropertyTypeChange(item.value)
+ }, [isAdvancedEditing, emitPropertyTypeChange])
+
+ const toggleRequired = useCallback(() => {
+ setCurrentFields(prev => ({ ...prev, required: !prev.required }))
+ if (isAdvancedEditing) return
+ emitPropertyRequiredToggle()
+ }, [isAdvancedEditing, emitPropertyRequiredToggle])
+
+ const handleDescriptionChange = useCallback((e: React.ChangeEvent) => {
+ setCurrentFields(prev => ({ ...prev, description: e.target.value }))
+ }, [])
+
+ const handleDescriptionBlur = useCallback(() => {
+ if (isAdvancedEditing) return
+ emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum })
+ }, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
+
+ const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
+ let enumValue: any = options.enum
+ if (enumValue === '') {
+ enumValue = undefined
+ }
+ else {
+ enumValue = options.enum.replace(/\s/g, '').split(',')
+ if (currentFields.type === Type.number)
+ enumValue = (enumValue as SchemaEnumType).map(value => Number(value)).filter(num => !Number.isNaN(num))
+ }
+ setCurrentFields(prev => ({ ...prev, enum: enumValue }))
+ if (isAdvancedEditing) return
+ emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue })
+ }, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
+
+ const handleDelete = useCallback(() => {
+ blurWithActions.current = true
+ emitPropertyDelete()
+ }, [emitPropertyDelete])
+
+ const handleAdvancedEdit = useCallback(() => {
+ setBackupFields({ ...currentFields })
+ setAdvancedEditing(true)
+ }, [currentFields, setAdvancedEditing])
+
+ const handleAddChildField = useCallback(() => {
+ blurWithActions.current = true
+ emitPropertyAdd()
+ }, [emitPropertyAdd])
+
+ const handleConfirm = useCallback(() => {
+ emitFieldChange()
+ }, [emitFieldChange])
+
+ const handleCancel = useCallback(() => {
+ if (isAddingNewField) {
+ blurWithActions.current = true
+ emit('restoreSchema')
+ setIsAddingNewField(false)
+ return
+ }
+ if (backupFields) {
+ setCurrentFields(backupFields)
+ setBackupFields(null)
+ }
+ setAdvancedEditing(false)
+ }, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields])
+
+ useUnmount(() => {
+ if (isAdvancedEditing || blurWithActions.current) return
+ emitFieldChange()
+ })
+
+ return (
+
+
+
+
+
+ {
+ currentFields.required && (
+
+ {t('workflow.nodes.llm.jsonSchema.required')}
+
+ )
+ }
+
+
+
+ {isAdvancedEditing ? (
+
+ ) : (
+
+ )}
+
+
+ {(fields.description || isAdvancedEditing) && (
+
+ e.key === 'Enter' && e.currentTarget.blur()}
+ />
+
+ )}
+
+ {isAdvancedEditing && hasAdvancedOptions && (
+
+ )}
+
+ )
+}
+
+export default EditCard
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx
new file mode 100644
index 0000000000..c7179408cf
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx
@@ -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 = ({
+ defaultValue,
+ toggleRequired,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+ {t('workflow.nodes.llm.jsonSchema.required')}
+
+
+ )
+}
+
+export default React.memo(RequiredSwitch)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx
new file mode 100644
index 0000000000..84d75e1ada
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx
@@ -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 = ({
+ items,
+ currentValue,
+ onSelect,
+ popupClassName,
+}) => {
+ const [open, setOpen] = useState(false)
+
+ return (
+
+ setOpen(v => !v)}>
+
+ {currentValue}
+
+
+
+
+
+ {items.map((item) => {
+ const isSelected = item.value === currentValue
+ return (
{
+ onSelect(item)
+ setOpen(false)
+ }}
+ >
+ {item.text}
+ {isSelected && }
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default TypeSelector
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts
new file mode 100644
index 0000000000..470a322b13
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts
@@ -0,0 +1,441 @@
+import produce from 'immer'
+import type { VisualEditorProps } from '.'
+import { useMittContext } from './context'
+import { useVisualEditorStore } from './store'
+import type { EditData } from './edit-card'
+import { ArrayType, type Field, Type } from '../../../types'
+import Toast from '@/app/components/base/toast'
+import { findPropertyWithPath } from '../../../utils'
+
+type ChangeEventParams = {
+ path: string[],
+ parentPath: string[],
+ oldFields: EditData,
+ fields: EditData,
+}
+
+type AddEventParams = {
+ path: string[]
+}
+
+export const useSchemaNodeOperations = (props: VisualEditorProps) => {
+ const { schema: jsonSchema, onChange } = props
+ const backupSchema = useVisualEditorStore(state => state.backupSchema)
+ const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema)
+ const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
+ const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
+ const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
+ const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
+ const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
+ const { emit, useSubscribe } = useMittContext()
+
+ useSubscribe('restoreSchema', () => {
+ if (backupSchema) {
+ onChange(backupSchema)
+ setBackupSchema(null)
+ }
+ })
+
+ useSubscribe('quitEditing', (params) => {
+ const { callback } = params as any
+ callback?.(backupSchema)
+ if (backupSchema) {
+ onChange(backupSchema)
+ setBackupSchema(null)
+ }
+ isAddingNewField && setIsAddingNewField(false)
+ advancedEditing && setAdvancedEditing(false)
+ setHoveringProperty(null)
+ })
+
+ useSubscribe('propertyNameChange', (params) => {
+ const { parentPath, oldFields, fields } = params as ChangeEventParams
+ const { name: oldName } = oldFields
+ const { name: newName } = fields
+ const newSchema = produce(jsonSchema, (draft) => {
+ if (oldName === newName) return
+ const schema = findPropertyWithPath(draft, parentPath) as Field
+
+ if (schema.type === Type.object) {
+ const properties = schema.properties || {}
+ if (properties[newName]) {
+ Toast.notify({
+ type: 'error',
+ message: 'Property name already exists',
+ })
+ emit('restorePropertyName')
+ return
+ }
+
+ const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
+ acc[key === oldName ? newName : key] = value
+ return acc
+ }, {} as Record)
+
+ 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)
+ const required = schema.items.required || []
+ const newRequired = produce(required, (draft) => {
+ const index = draft.indexOf(oldName)
+ if (index !== -1)
+ draft.splice(index, 1, newName)
+ })
+
+ schema.items.properties = newProperties
+ schema.items.required = newRequired
+ }
+ })
+ onChange(newSchema)
+ })
+
+ useSubscribe('propertyTypeChange', (params) => {
+ const { path, oldFields, fields } = params as ChangeEventParams
+ const { type: oldType } = oldFields
+ const { type: newType } = fields
+ if (oldType === newType) return
+ const newSchema = produce(jsonSchema, (draft) => {
+ const schema = findPropertyWithPath(draft, path) as Field
+
+ if (schema.type === Type.object) {
+ delete schema.properties
+ delete schema.required
+ }
+ if (schema.type === Type.array)
+ delete schema.items
+ switch (newType) {
+ case Type.object:
+ schema.type = Type.object
+ schema.properties = {}
+ schema.required = []
+ schema.additionalProperties = false
+ break
+ case ArrayType.string:
+ schema.type = Type.array
+ schema.items = {
+ type: Type.string,
+ }
+ break
+ case ArrayType.number:
+ schema.type = Type.array
+ schema.items = {
+ type: Type.number,
+ }
+ break
+ // case ArrayType.boolean:
+ // schema.type = Type.array
+ // schema.items = {
+ // type: Type.boolean,
+ // }
+ // break
+ case ArrayType.object:
+ schema.type = Type.array
+ schema.items = {
+ type: Type.object,
+ properties: {},
+ required: [],
+ additionalProperties: false,
+ }
+ break
+ default:
+ schema.type = newType as Type
+ }
+ })
+ onChange(newSchema)
+ })
+
+ useSubscribe('propertyRequiredToggle', (params) => {
+ const { parentPath, fields } = params as ChangeEventParams
+ const { name } = fields
+ const newSchema = produce(jsonSchema, (draft) => {
+ const schema = findPropertyWithPath(draft, parentPath) as Field
+
+ if (schema.type === Type.object) {
+ const required = schema.required || []
+ const newRequired = required.includes(name)
+ ? required.filter(item => item !== name)
+ : [...required, name]
+ schema.required = newRequired
+ }
+ if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
+ const required = schema.items.required || []
+ const newRequired = required.includes(name)
+ ? required.filter(item => item !== name)
+ : [...required, name]
+ schema.items.required = newRequired
+ }
+ })
+ onChange(newSchema)
+ })
+
+ useSubscribe('propertyOptionsChange', (params) => {
+ const { path, fields } = params as ChangeEventParams
+ const newSchema = produce(jsonSchema, (draft) => {
+ const schema = findPropertyWithPath(draft, path) as Field
+ schema.description = fields.description
+ schema.enum = fields.enum
+ })
+ onChange(newSchema)
+ })
+
+ useSubscribe('propertyDelete', (params) => {
+ const { parentPath, fields } = params as ChangeEventParams
+ const { name } = fields
+ const newSchema = produce(jsonSchema, (draft) => {
+ const schema = findPropertyWithPath(draft, parentPath) as Field
+ if (schema.type === Type.object && schema.properties) {
+ delete schema.properties[name]
+ schema.required = schema.required?.filter(item => item !== name)
+ }
+ if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) {
+ delete schema.items.properties[name]
+ schema.items.required = schema.items.required?.filter(item => item !== name)
+ }
+ })
+ onChange(newSchema)
+ })
+
+ useSubscribe('addField', (params) => {
+ advancedEditing && setAdvancedEditing(false)
+ setBackupSchema(jsonSchema)
+ const { path } = params as AddEventParams
+ setIsAddingNewField(true)
+ const newSchema = produce(jsonSchema, (draft) => {
+ const schema = findPropertyWithPath(draft, path) as Field
+ if (schema.type === Type.object) {
+ schema.properties = {
+ ...(schema.properties || {}),
+ '': {
+ type: Type.string,
+ },
+ }
+ setHoveringProperty([...path, 'properties', ''].join('.'))
+ }
+ if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
+ schema.items.properties = {
+ ...(schema.items.properties || {}),
+ '': {
+ type: Type.string,
+ },
+ }
+ setHoveringProperty([...path, 'items', 'properties', ''].join('.'))
+ }
+ })
+ onChange(newSchema)
+ })
+
+ useSubscribe('fieldChange', (params) => {
+ let samePropertyNameError = false
+ const { parentPath, oldFields, fields } = params as ChangeEventParams
+ const newSchema = produce(jsonSchema, (draft) => {
+ const parentSchema = findPropertyWithPath(draft, parentPath) as Field
+ const { name: oldName, type: oldType, required: oldRequired } = oldFields
+ const { name: newName, type: newType, required: newRequired } = fields
+ if (parentSchema.type === Type.object && parentSchema.properties) {
+ // name change
+ if (oldName !== newName) {
+ const properties = parentSchema.properties
+ if (properties[newName]) {
+ Toast.notify({
+ type: 'error',
+ message: 'Property name already exists',
+ })
+ samePropertyNameError = true
+ }
+
+ const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
+ acc[key === oldName ? newName : key] = value
+ return acc
+ }, {} as Record)
+
+ 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)
+ 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')
+ })
+}
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx
new file mode 100644
index 0000000000..1df42532a6
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx
@@ -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 = (props) => {
+ const { schema } = props
+ useSchemaNodeOperations(props)
+
+ return (
+
+
+
+ )
+}
+
+export default VisualEditor
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx
new file mode 100644
index 0000000000..70a6b861ad
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx
@@ -0,0 +1,194 @@
+import type { FC } from 'react'
+import React, { useMemo, useState } from 'react'
+import { type Field, Type } from '../../../types'
+import classNames from '@/utils/classnames'
+import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react'
+import { getFieldType, getHasChildren } from '../../../utils'
+import Divider from '@/app/components/base/divider'
+import EditCard from './edit-card'
+import Card from './card'
+import { useVisualEditorStore } from './store'
+import { useDebounceFn } from 'ahooks'
+import AddField from './add-field'
+import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
+
+type SchemaNodeProps = {
+ name: string
+ required: boolean
+ schema: Field
+ path: string[]
+ parentPath?: string[]
+ depth: number
+}
+
+// Support 10 levels of indentation
+const indentPadding: Record = {
+ 0: 'pl-0',
+ 1: 'pl-[20px]',
+ 2: 'pl-[40px]',
+ 3: 'pl-[60px]',
+ 4: 'pl-[80px]',
+ 5: 'pl-[100px]',
+ 6: 'pl-[120px]',
+ 7: 'pl-[140px]',
+ 8: 'pl-[160px]',
+ 9: 'pl-[180px]',
+ 10: 'pl-[200px]',
+}
+
+const indentLeft: Record = {
+ 0: 'left-0',
+ 1: 'left-[20px]',
+ 2: 'left-[40px]',
+ 3: 'left-[60px]',
+ 4: 'left-[80px]',
+ 5: 'left-[100px]',
+ 6: 'left-[120px]',
+ 7: 'left-[140px]',
+ 8: 'left-[160px]',
+ 9: 'left-[180px]',
+ 10: 'left-[200px]',
+}
+
+const SchemaNode: FC = ({
+ name,
+ required,
+ schema,
+ path,
+ parentPath,
+ depth,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(true)
+ const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty)
+ const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
+ const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
+ const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
+
+ const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string | null) => {
+ setHoveringProperty(path)
+ }, { wait: 50 })
+
+ const hasChildren = useMemo(() => getHasChildren(schema), [schema])
+ const type = useMemo(() => getFieldType(schema), [schema])
+ const isHovering = hoveringProperty === path.join('.')
+
+ const handleExpand = () => {
+ setIsExpanded(!isExpanded)
+ }
+
+ const handleMouseEnter = () => {
+ if (advancedEditing || isAddingNewField) return
+ setHoveringPropertyDebounced(path.join('.'))
+ }
+
+ const handleMouseLeave = () => {
+ if (advancedEditing || isAddingNewField) return
+ setHoveringPropertyDebounced(null)
+ }
+
+ return (
+
+
+ {depth > 0 && hasChildren && (
+
+
+
+ )}
+
+
+ {(isHovering && depth > 0) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && (
+ <>
+ {schema.type === Type.object && schema.properties && (
+ Object.entries(schema.properties).map(([key, childSchema]) => (
+
+ ))
+ )}
+
+ {schema.type === Type.array
+ && schema.items
+ && schema.items.type === Type.object
+ && schema.items.properties
+ && (
+ Object.entries(schema.items.properties).map(([key, childSchema]) => (
+
+ ))
+ )}
+ >
+ )}
+
+ {
+ depth === 0 && !isAddingNewField && (
+
+ )
+ }
+
+ )
+}
+
+export default React.memo(SchemaNode)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts
new file mode 100644
index 0000000000..3dbd6676dc
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts
@@ -0,0 +1,34 @@
+import { useContext } from 'react'
+import { createStore, useStore } from 'zustand'
+import type { SchemaRoot } from '../../../types'
+import { VisualEditorContext } from './context'
+
+type VisualEditorStore = {
+ hoveringProperty: string | null
+ setHoveringProperty: (propertyPath: string | null) => void
+ isAddingNewField: boolean
+ setIsAddingNewField: (isAdding: boolean) => void
+ advancedEditing: boolean
+ setAdvancedEditing: (isEditing: boolean) => void
+ backupSchema: SchemaRoot | null
+ setBackupSchema: (schema: SchemaRoot | null) => void
+}
+
+export const createVisualEditorStore = () => createStore(set => ({
+ hoveringProperty: null,
+ setHoveringProperty: (propertyPath: string | null) => set({ hoveringProperty: propertyPath }),
+ isAddingNewField: false,
+ setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }),
+ advancedEditing: false,
+ setAdvancedEditing: (isEditing: boolean) => set({ advancedEditing: isEditing }),
+ backupSchema: null,
+ setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }),
+}))
+
+export const useVisualEditorStore = (selector: (state: VisualEditorStore) => T): T => {
+ const store = useContext(VisualEditorContext)
+ if (!store)
+ throw new Error('Missing VisualEditorContext.Provider in the tree')
+
+ return useStore(store, selector)
+}
diff --git a/web/app/components/workflow/nodes/llm/components/structure-output.tsx b/web/app/components/workflow/nodes/llm/components/structure-output.tsx
new file mode 100644
index 0000000000..b20820df2e
--- /dev/null
+++ b/web/app/components/workflow/nodes/llm/components/structure-output.tsx
@@ -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 = ({
+ className,
+ value,
+ onChange,
+}) => {
+ const { t } = useTranslation()
+ const [showConfig, {
+ setTrue: showConfigModal,
+ setFalse: hideConfigModal,
+ }] = useBoolean(false)
+
+ const handleChange = useCallback((value: SchemaRoot) => {
+ onChange({
+ schema: value,
+ })
+ }, [onChange])
+ return (
+
+
+
+
structured_output
+
object
+
+
+
+ {(value?.schema && value.schema.properties && Object.keys(value.schema.properties).length > 0) ? (
+
) : (
+
{t('app.structOutput.notConfiguredTip')}
+ )}
+
+ {showConfig && (
+
+ )}
+
+ )
+}
+export default React.memo(StructureOutput)
diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx
index 58b036317d..2335fa0c80 100644
--- a/web/app/components/workflow/nodes/llm/panel.tsx
+++ b/web/app/components/workflow/nodes/llm/panel.tsx
@@ -20,6 +20,9 @@ import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/c
import ResultPanel from '@/app/components/workflow/run/result-panel'
import Tooltip from '@/app/components/base/tooltip'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
+import StructureOutput from './components/structure-output'
+import Switch from '@/app/components/base/switch'
+import { RiAlertFill, RiQuestionLine } from '@remixicon/react'
const i18nPrefix = 'workflow.nodes.llm'
@@ -64,6 +67,11 @@ const Panel: FC> = ({
contexts,
setContexts,
runningStatus,
+ isModelSupportStructuredOutput,
+ structuredOutputCollapsed,
+ setStructuredOutputCollapsed,
+ handleStructureOutputEnableChange,
+ handleStructureOutputChange,
handleRun,
handleStop,
varInputs,
@@ -282,13 +290,57 @@ const Panel: FC> = ({
/>
-
+
+ {!isModelSupportStructuredOutput && (
+
+ {t('app.structOutput.modelNotSupported')}
+ {t('app.structOutput.modelNotSupportedTip')}
+
+ }>
+
+
+
+
+ )}
+ {t('app.structOutput.structured')}
+ {t('app.structOutput.structuredTip')}
+ }>
+
+
+
+
+
+
+ }
+ >
<>
+ {inputs.structured_output_enabled && (
+ <>
+
+
+ >
+ )}
>
{isShowSingleRun && (
diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts
index 6b2d27e70f..13db9e4031 100644
--- a/web/app/components/workflow/nodes/llm/use-config.ts
+++ b/web/app/components/workflow/nodes/llm/use-config.ts
@@ -9,9 +9,10 @@ import {
} from '../../hooks'
import useAvailableVarList from '../_base/hooks/use-available-var-list'
import useConfigVision from '../../hooks/use-config-vision'
-import type { LLMNodeType } from './types'
-import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
+import type { LLMNodeType, StructuredOutput } from './types'
+import { useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
+ ModelFeatureEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
@@ -277,6 +278,30 @@ const useConfig = (id: string, payload: LLMNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
+ // structure output
+ const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
+ const isModelSupportStructuredOutput = modelList
+ ?.find(provideItem => provideItem.provider === model?.provider)
+ ?.models.find(modelItem => modelItem.model === model?.name)
+ ?.features?.includes(ModelFeatureEnum.StructuredOutput)
+
+ const [structuredOutputCollapsed, setStructuredOutputCollapsed] = useState(true)
+ const handleStructureOutputEnableChange = useCallback((enabled: boolean) => {
+ const newInputs = produce(inputs, (draft) => {
+ draft.structured_output_enabled = enabled
+ })
+ setInputs(newInputs)
+ if (enabled)
+ setStructuredOutputCollapsed(false)
+ }, [inputs, setInputs])
+
+ const handleStructureOutputChange = useCallback((newOutput: StructuredOutput) => {
+ const newInputs = produce(inputs, (draft) => {
+ draft.structured_output = newOutput
+ })
+ setInputs(newInputs)
+ }, [inputs, setInputs])
+
const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type)
}, [])
@@ -408,6 +433,11 @@ const useConfig = (id: string, payload: LLMNodeType) => {
setContexts,
varInputs,
runningStatus,
+ isModelSupportStructuredOutput,
+ handleStructureOutputChange,
+ structuredOutputCollapsed,
+ setStructuredOutputCollapsed,
+ handleStructureOutputEnableChange,
handleRun,
handleStop,
runResult,
diff --git a/web/app/components/workflow/nodes/llm/utils.ts b/web/app/components/workflow/nodes/llm/utils.ts
index 5f6b0864d6..b29646de66 100644
--- a/web/app/components/workflow/nodes/llm/utils.ts
+++ b/web/app/components/workflow/nodes/llm/utils.ts
@@ -1,5 +1,336 @@
-import type { LLMNodeType } from './types'
+import { ArrayType, Type } from './types'
+import type { ArrayItems, Field, LLMNodeType } from './types'
+import type { Schema, ValidationError } from 'jsonschema'
+import { Validator } from 'jsonschema'
+import produce from 'immer'
+import { z } from 'zod'
export const checkNodeValid = (payload: LLMNodeType) => {
return true
}
+
+export const getFieldType = (field: Field) => {
+ const { type, items } = field
+ if (type !== Type.array || !items)
+ return type
+
+ return ArrayType[items.type]
+}
+
+export const getHasChildren = (schema: Field) => {
+ const complexTypes = [Type.object, Type.array]
+ if (!complexTypes.includes(schema.type))
+ return false
+ if (schema.type === Type.object)
+ return schema.properties && Object.keys(schema.properties).length > 0
+ if (schema.type === Type.array)
+ return schema.items && schema.items.type === Type.object && schema.items.properties && Object.keys(schema.items.properties).length > 0
+}
+
+export const getTypeOf = (target: any) => {
+ if (target === null) return 'null'
+ if (typeof target !== 'object') {
+ return typeof target
+ }
+ else {
+ return Object.prototype.toString
+ .call(target)
+ .slice(8, -1)
+ .toLocaleLowerCase()
+ }
+}
+
+export const inferType = (value: any): Type => {
+ const type = getTypeOf(value)
+ if (type === 'array') return Type.array
+ // type boolean will be treated as string
+ if (type === 'boolean') return Type.string
+ if (type === 'number') return Type.number
+ if (type === 'string') return Type.string
+ if (type === 'object') return Type.object
+ return Type.string
+}
+
+export const jsonToSchema = (json: any): Field => {
+ const schema: Field = {
+ type: inferType(json),
+ }
+
+ if (schema.type === Type.object) {
+ schema.properties = {}
+ schema.required = []
+ schema.additionalProperties = false
+
+ Object.entries(json).forEach(([key, value]) => {
+ schema.properties![key] = jsonToSchema(value)
+ schema.required!.push(key)
+ })
+ }
+ else if (schema.type === Type.array) {
+ schema.items = jsonToSchema(json[0]) as ArrayItems
+ }
+
+ return schema
+}
+
+export const checkJsonDepth = (json: any) => {
+ if (!json || getTypeOf(json) !== 'object')
+ return 0
+
+ let maxDepth = 0
+
+ if (getTypeOf(json) === 'array') {
+ if (json[0] && getTypeOf(json[0]) === 'object')
+ maxDepth = checkJsonDepth(json[0])
+ }
+ else if (getTypeOf(json) === 'object') {
+ const propertyDepths = Object.values(json).map(value => checkJsonDepth(value))
+ maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
+ }
+
+ return maxDepth
+}
+
+export const checkJsonSchemaDepth = (schema: Field) => {
+ if (!schema || getTypeOf(schema) !== 'object')
+ return 0
+
+ let maxDepth = 0
+
+ if (schema.type === Type.object && schema.properties) {
+ const propertyDepths = Object.values(schema.properties).map(value => checkJsonSchemaDepth(value))
+ maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
+ }
+ else if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
+ maxDepth = checkJsonSchemaDepth(schema.items) + 1
+ }
+
+ return maxDepth
+}
+
+export const findPropertyWithPath = (target: any, path: string[]) => {
+ let current = target
+ for (const key of path)
+ current = current[key]
+ return current
+}
+
+const draft07MetaSchema = {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ $id: 'http://json-schema.org/draft-07/schema#',
+ title: 'Core schema meta-schema',
+ definitions: {
+ schemaArray: {
+ type: 'array',
+ minItems: 1,
+ items: { $ref: '#' },
+ },
+ nonNegativeInteger: {
+ type: 'integer',
+ minimum: 0,
+ },
+ nonNegativeIntegerDefault0: {
+ allOf: [
+ { $ref: '#/definitions/nonNegativeInteger' },
+ { default: 0 },
+ ],
+ },
+ simpleTypes: {
+ enum: [
+ 'array',
+ 'boolean',
+ 'integer',
+ 'null',
+ 'number',
+ 'object',
+ 'string',
+ ],
+ },
+ stringArray: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ default: [],
+ },
+ },
+ type: ['object', 'boolean'],
+ properties: {
+ $id: {
+ type: 'string',
+ format: 'uri-reference',
+ },
+ $schema: {
+ type: 'string',
+ format: 'uri',
+ },
+ $ref: {
+ type: 'string',
+ format: 'uri-reference',
+ },
+ title: {
+ type: 'string',
+ },
+ description: {
+ type: 'string',
+ },
+ default: true,
+ readOnly: {
+ type: 'boolean',
+ default: false,
+ },
+ examples: {
+ type: 'array',
+ items: true,
+ },
+ multipleOf: {
+ type: 'number',
+ exclusiveMinimum: 0,
+ },
+ maximum: {
+ type: 'number',
+ },
+ exclusiveMaximum: {
+ type: 'number',
+ },
+ minimum: {
+ type: 'number',
+ },
+ exclusiveMinimum: {
+ type: 'number',
+ },
+ maxLength: { $ref: '#/definitions/nonNegativeInteger' },
+ minLength: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
+ pattern: {
+ type: 'string',
+ format: 'regex',
+ },
+ additionalItems: { $ref: '#' },
+ items: {
+ anyOf: [
+ { $ref: '#' },
+ { $ref: '#/definitions/schemaArray' },
+ ],
+ default: true,
+ },
+ maxItems: { $ref: '#/definitions/nonNegativeInteger' },
+ minItems: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
+ uniqueItems: {
+ type: 'boolean',
+ default: false,
+ },
+ contains: { $ref: '#' },
+ maxProperties: { $ref: '#/definitions/nonNegativeInteger' },
+ minProperties: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
+ required: { $ref: '#/definitions/stringArray' },
+ additionalProperties: { $ref: '#' },
+ definitions: {
+ type: 'object',
+ additionalProperties: { $ref: '#' },
+ default: {},
+ },
+ properties: {
+ type: 'object',
+ additionalProperties: { $ref: '#' },
+ default: {},
+ },
+ patternProperties: {
+ type: 'object',
+ additionalProperties: { $ref: '#' },
+ propertyNames: { format: 'regex' },
+ default: {},
+ },
+ dependencies: {
+ type: 'object',
+ additionalProperties: {
+ anyOf: [
+ { $ref: '#' },
+ { $ref: '#/definitions/stringArray' },
+ ],
+ },
+ },
+ propertyNames: { $ref: '#' },
+ const: true,
+ enum: {
+ type: 'array',
+ items: true,
+ minItems: 1,
+ uniqueItems: true,
+ },
+ type: {
+ anyOf: [
+ { $ref: '#/definitions/simpleTypes' },
+ {
+ type: 'array',
+ items: { $ref: '#/definitions/simpleTypes' },
+ minItems: 1,
+ uniqueItems: true,
+ },
+ ],
+ },
+ format: { type: 'string' },
+ allOf: { $ref: '#/definitions/schemaArray' },
+ anyOf: { $ref: '#/definitions/schemaArray' },
+ oneOf: { $ref: '#/definitions/schemaArray' },
+ not: { $ref: '#' },
+ },
+ default: true,
+} as unknown as Schema
+
+const validator = new Validator()
+
+export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
+ const schema = produce(schemaToValidate, (draft: any) => {
+ // Make sure the schema has the $schema property for draft-07
+ if (!draft.$schema)
+ draft.$schema = 'http://json-schema.org/draft-07/schema#'
+ })
+
+ const result = validator.validate(schema, draft07MetaSchema, {
+ nestedErrors: true,
+ throwError: false,
+ })
+
+ // Access errors from the validation result
+ const errors = result.valid ? [] : result.errors || []
+
+ return errors
+}
+
+export const getValidationErrorMessage = (errors: ValidationError[]) => {
+ const message = errors.map((error) => {
+ return `Error: ${error.path.join('.')} ${error.message} Details: ${JSON.stringify(error.stack)}`
+ }).join('; ')
+ return message
+}
+
+export const convertBooleanToString = (schema: any) => {
+ if (schema.type === Type.boolean)
+ schema.type = Type.string
+ if (schema.type === Type.array && schema.items && schema.items.type === Type.boolean)
+ schema.items.type = Type.string
+ if (schema.type === Type.object) {
+ schema.properties = Object.entries(schema.properties).reduce((acc, [key, value]) => {
+ acc[key] = convertBooleanToString(value)
+ return acc
+ }, {} as any)
+ }
+ if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
+ schema.items.properties = Object.entries(schema.items.properties).reduce((acc, [key, value]) => {
+ acc[key] = convertBooleanToString(value)
+ return acc
+ }, {} as any)
+ }
+ return schema
+}
+
+const schemaRootObject = z.object({
+ type: z.literal('object'),
+ properties: z.record(z.string(), z.any()),
+ required: z.array(z.string()),
+ additionalProperties: z.boolean().optional(),
+})
+
+export const preValidateSchema = (schema: any) => {
+ const result = schemaRootObject.safeParse(schema)
+ return result
+}
diff --git a/web/app/components/workflow/nodes/tool/panel.tsx b/web/app/components/workflow/nodes/tool/panel.tsx
index b8dc93455a..85966443d5 100644
--- a/web/app/components/workflow/nodes/tool/panel.tsx
+++ b/web/app/components/workflow/nodes/tool/panel.tsx
@@ -17,6 +17,8 @@ import ResultPanel from '@/app/components/workflow/run/result-panel'
import { useToolIcon } from '@/app/components/workflow/hooks'
import { useLogs } from '@/app/components/workflow/run/hooks'
import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log'
+import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
+import { Type } from '../llm/types'
const i18nPrefix = 'workflow.nodes.tool'
@@ -51,6 +53,7 @@ const Panel: FC
> = ({
handleStop,
runResult,
outputSchema,
+ hasObjectOutput,
} = useConfig(id, data)
const toolIcon = useToolIcon(data)
const logsParams = useLogs()
@@ -134,26 +137,45 @@ const Panel: FC> = ({
<>
{outputSchema.map(outputItem => (
-
+
+ {outputItem.value?.type === 'object' ? (
+
+ ) : (
+
+ )}
+
))}
>
diff --git a/web/app/components/workflow/nodes/tool/use-config.ts b/web/app/components/workflow/nodes/tool/use-config.ts
index 36519ce991..38ca5b5195 100644
--- a/web/app/components/workflow/nodes/tool/use-config.ts
+++ b/web/app/components/workflow/nodes/tool/use-config.ts
@@ -262,17 +262,33 @@ const useConfig = (id: string, payload: ToolNodeType) => {
return []
Object.keys(output_schema.properties).forEach((outputKey) => {
const output = output_schema.properties[outputKey]
- res.push({
- name: outputKey,
- type: output.type === 'array'
- ? `Array[${output.items?.type.slice(0, 1).toLocaleUpperCase()}${output.items?.type.slice(1)}]`
- : `${output.type.slice(0, 1).toLocaleUpperCase()}${output.type.slice(1)}`,
- description: output.description,
- })
+ const type = output.type
+ if (type === 'object') {
+ res.push({
+ name: outputKey,
+ value: output,
+ })
+ }
+ else {
+ res.push({
+ name: outputKey,
+ type: output.type === 'array'
+ ? `Array[${output.items?.type.slice(0, 1).toLocaleUpperCase()}${output.items?.type.slice(1)}]`
+ : `${output.type.slice(0, 1).toLocaleUpperCase()}${output.type.slice(1)}`,
+ description: output.description,
+ })
+ }
})
return res
}, [output_schema])
+ const hasObjectOutput = useMemo(() => {
+ if (!output_schema)
+ return false
+ const properties = output_schema.properties
+ return Object.keys(properties).some(key => properties[key].type === 'object')
+ }, [output_schema])
+
return {
readOnly,
inputs,
@@ -302,6 +318,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
handleStop,
runResult,
outputSchema,
+ hasObjectOutput,
}
}
diff --git a/web/config/index.ts b/web/config/index.ts
index b164392c52..3466686293 100644
--- a/web/config/index.ts
+++ b/web/config/index.ts
@@ -285,6 +285,7 @@ export const GITHUB_ACCESS_TOKEN = process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN |
export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl'
export const FULL_DOC_PREVIEW_LENGTH = 50
+export const JSON_SCHEMA_MAX_DEPTH = 10
let loopNodeMaxCount = 100
if (process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT && process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT !== '')
diff --git a/web/hooks/use-mitt.ts b/web/hooks/use-mitt.ts
index 9aea988b6e..584636c8a6 100644
--- a/web/hooks/use-mitt.ts
+++ b/web/hooks/use-mitt.ts
@@ -10,7 +10,7 @@ const merge = >(
export type _Events = Record
-export type UseSubcribeOption = {
+export type UseSubscribeOption = {
/**
* Whether the subscription is enabled.
* @default true
@@ -22,21 +22,21 @@ export type ExtendedOn = {
(
type: Key,
handler: Handler,
- options?: UseSubcribeOption,
+ options?: UseSubscribeOption,
): void;
(
type: '*',
handler: WildcardHandler,
- option?: UseSubcribeOption,
+ option?: UseSubscribeOption,
): void;
}
export type UseMittReturn = {
- useSubcribe: ExtendedOn;
+ useSubscribe: ExtendedOn;
emit: Emitter['emit'];
}
-const defaultSubcribeOption: UseSubcribeOption = {
+const defaultSubscribeOption: UseSubscribeOption = {
enabled: true,
}
@@ -52,12 +52,12 @@ function useMitt(
emitterRef.current = mitt
}
const emitter = emitterRef.current
- const useSubcribe: ExtendedOn = (
+ const useSubscribe: ExtendedOn = (
type: string,
handler: any,
- option?: UseSubcribeOption,
+ option?: UseSubscribeOption,
) => {
- const { enabled } = merge(defaultSubcribeOption, option)
+ const { enabled } = merge(defaultSubscribeOption, option)
useEffect(() => {
if (enabled) {
emitter.on(type, handler)
@@ -67,7 +67,7 @@ function useMitt(
}
return {
emit: emitter.emit,
- useSubcribe,
+ useSubscribe,
}
}
diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts
index ef883c0123..8139c1e3f2 100644
--- a/web/i18n/en-US/app.ts
+++ b/web/i18n/en-US/app.ts
@@ -180,6 +180,17 @@ const translation = {
noParams: 'No parameters needed',
},
showMyCreatedAppsOnly: 'Created by me',
+ structOutput: {
+ moreFillTip: 'Showing max 10 levels of nesting',
+ required: 'Required',
+ LLMResponse: 'LLM Response',
+ configure: 'Configure',
+ notConfiguredTip: 'Structured output has not been configured yet',
+ structured: 'Structured',
+ structuredTip: 'Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema',
+ modelNotSupported: 'Model not supported',
+ modelNotSupportedTip: 'The current model does not support this feature and is automatically downgraded to prompt injection.',
+ },
}
export default translation
diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts
index 0811ba73ff..bf2bf83f68 100644
--- a/web/i18n/en-US/common.ts
+++ b/web/i18n/en-US/common.ts
@@ -56,6 +56,7 @@ const translation = {
regenerate: 'Regenerate',
submit: 'Submit',
skip: 'Skip',
+ format: 'Format',
},
errorMsg: {
fieldRequired: '{{field}} is required',
diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts
index bdad0fdaca..543f689611 100644
--- a/web/i18n/en-US/workflow.ts
+++ b/web/i18n/en-US/workflow.ts
@@ -423,6 +423,34 @@ const translation = {
variable: 'Variable',
},
sysQueryInUser: 'sys.query in user message is required',
+ jsonSchema: {
+ title: 'Structured Output Schema',
+ instruction: 'Instruction',
+ promptTooltip: 'Convert the text description into a standardized JSON Schema structure.',
+ promptPlaceholder: 'Describe your JSON Schema...',
+ generate: 'Generate',
+ import: 'Import from JSON',
+ generateJsonSchema: 'Generate JSON Schema',
+ generationTip: 'You can use natural language to quickly create a JSON Schema.',
+ generating: 'Generating JSON Schema...',
+ generatedResult: 'Generated Result',
+ resultTip: 'Here is the generated result. If you\'re not satisfied, you can go back and modify your prompt.',
+ back: 'Back',
+ regenerate: 'Regenerate',
+ apply: 'Apply',
+ doc: 'Learn more about structured output',
+ resetDefaults: 'Reset',
+ required: 'required',
+ addField: 'Add Field',
+ addChildField: 'Add Child Field',
+ showAdvancedOptions: 'Show advanced options',
+ stringValidations: 'String Validations',
+ fieldNamePlaceholder: 'Field Name',
+ descriptionPlaceholder: 'Add description',
+ warningTips: {
+ saveSchema: 'Please finish editing the current field before saving the schema',
+ },
+ },
},
knowledgeRetrieval: {
queryVariable: 'Query Variable',
diff --git a/web/i18n/language.ts b/web/i18n/language.ts
index cd770977bd..c86d31ffa0 100644
--- a/web/i18n/language.ts
+++ b/web/i18n/language.ts
@@ -33,7 +33,7 @@ export const languages = data.languages
export const LanguagesSupported = languages.filter(item => item.supported).map(item => item.value)
export const getLanguage = (locale: string) => {
- if (locale === 'zh-Hans')
+ if (['zh-Hans', 'ja-JP'].includes(locale))
return locale.replace('-', '_')
return LanguagesSupported[0].replace('-', '_')
diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts
index ba44d4db31..7ef8c1b514 100644
--- a/web/i18n/zh-Hans/app.ts
+++ b/web/i18n/zh-Hans/app.ts
@@ -181,6 +181,17 @@ const translation = {
},
openInExplore: '在“探索”中打开',
showMyCreatedAppsOnly: '我创建的',
+ structOutput: {
+ moreFillTip: '最多显示 10 级嵌套',
+ required: '必填',
+ LLMResponse: 'LLM 的响应',
+ configure: '配置',
+ notConfiguredTip: '结构化输出尚未配置',
+ structured: '结构化输出',
+ structuredTip: '结构化输出是一项功能,可确保模型始终生成符合您提供的 JSON 模式的响应',
+ modelNotSupported: '模型不支持',
+ modelNotSupportedTip: '当前模型不支持此功能,将自动降级为提示注入。',
+ },
}
export default translation
diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts
index 9c822885e0..8ed1e28fd8 100644
--- a/web/i18n/zh-Hans/common.ts
+++ b/web/i18n/zh-Hans/common.ts
@@ -56,6 +56,7 @@ const translation = {
regenerate: '重新生成',
submit: '提交',
skip: '跳过',
+ format: '格式化',
},
errorMsg: {
fieldRequired: '{{field}} 为必填项',
diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts
index 4e63a7b8c3..ab56c468ce 100644
--- a/web/i18n/zh-Hans/workflow.ts
+++ b/web/i18n/zh-Hans/workflow.ts
@@ -424,6 +424,34 @@ const translation = {
variable: '变量',
},
sysQueryInUser: 'user message 中必须包含 sys.query',
+ jsonSchema: {
+ title: '结构化输出 Schema',
+ instruction: '指令',
+ promptTooltip: '将文本描述转换为标准化的 JSON Schema 结构',
+ promptPlaceholder: '描述你的 JSON Schema...',
+ generate: '生成',
+ import: '从 JSON 导入',
+ generateJsonSchema: '生成 JSON Schema',
+ generationTip: '可以使用自然语言快速创建 JSON Schema。',
+ generating: '正在为您生成 JSON Schema...',
+ generatedResult: '生成结果',
+ resultTip: '以下是生成的结果。如果你对这个结果不满意,可以返回并修改你的提示词。',
+ back: '返回',
+ regenerate: '重新生成',
+ apply: '应用',
+ doc: '了解有关结构化输出的更多信息',
+ resetDefaults: '清空配置',
+ required: '必填',
+ addField: '添加字段',
+ addChildField: '添加子字段',
+ showAdvancedOptions: '显示高级选项',
+ stringValidations: '字符串验证',
+ fieldNamePlaceholder: '字段名',
+ descriptionPlaceholder: '添加描述',
+ warningTips: {
+ saveSchema: '请先完成当前字段的编辑',
+ },
+ },
},
knowledgeRetrieval: {
queryVariable: '查询变量',
diff --git a/web/models/common.ts b/web/models/common.ts
index 0ee164aad8..cb8fb7f2bf 100644
--- a/web/models/common.ts
+++ b/web/models/common.ts
@@ -1,4 +1,5 @@
import type { I18nText } from '@/i18n/language'
+import type { Model } from '@/types/app'
export type CommonResponse = {
result: 'success' | 'fail'
@@ -291,3 +292,13 @@ export type ModerationService = (
text: string
}
) => Promise
+
+export type StructuredOutputRulesRequestBody = {
+ instruction: string
+ model_config: Model
+}
+
+export type StructuredOutputRulesResponse = {
+ output: string
+ error?: string
+}
diff --git a/web/package.json b/web/package.json
index a1af12cff4..b63617f47b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -77,6 +77,7 @@
"immer": "^9.0.19",
"js-audio-recorder": "^1.0.7",
"js-cookie": "^3.0.5",
+ "jsonschema": "^1.5.0",
"jwt-decode": "^4.0.0",
"katex": "^0.16.21",
"ky": "^1.7.2",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index d1c65b6a4a..28822be807 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -163,6 +163,9 @@ importers:
js-cookie:
specifier: ^3.0.5
version: 3.0.5
+ jsonschema:
+ specifier: ^1.5.0
+ version: 1.5.0
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
@@ -6025,6 +6028,9 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
+ jsonschema@1.5.0:
+ resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==}
+
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -15479,6 +15485,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
+ jsonschema@1.5.0: {}
+
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.8
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index 98ab535948..d49f3803ac 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -1,8 +1,10 @@
-import { get } from './base'
+import { get, post } from './base'
import type {
FileUploadConfigResponse,
+ StructuredOutputRulesRequestBody,
+ StructuredOutputRulesResponse,
} from '@/models/common'
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery } from '@tanstack/react-query'
const NAME_SPACE = 'common'
@@ -12,3 +14,15 @@ export const useFileUploadConfig = () => {
queryFn: () => get('/files/upload'),
})
}
+
+export const useGenerateStructuredOutputRules = () => {
+ return useMutation({
+ mutationKey: [NAME_SPACE, 'generate-structured-output-rules'],
+ mutationFn: (body: StructuredOutputRulesRequestBody) => {
+ return post(
+ '/rule-structured-output-generate',
+ { body },
+ )
+ },
+ })
+}
diff --git a/web/tailwind-common-config.ts b/web/tailwind-common-config.ts
index aff4ed4c57..3f64afcc29 100644
--- a/web/tailwind-common-config.ts
+++ b/web/tailwind-common-config.ts
@@ -113,6 +113,7 @@ const config = {
'dataset-option-card-purple-gradient': 'var(--color-dataset-option-card-purple-gradient)',
'dataset-option-card-orange-gradient': 'var(--color-dataset-option-card-orange-gradient)',
'dataset-chunk-list-mask-bg': 'var(--color-dataset-chunk-list-mask-bg)',
+ 'line-divider-bg': 'var(--color-line-divider-bg)',
'dataset-warning-message-bg': 'var(--color-dataset-warning-message-bg)',
'price-premium-badge-background': 'var(--color-premium-badge-background)',
'premium-yearly-tip-text-background': 'var(--color-premium-yearly-tip-text-background)',
diff --git a/web/themes/manual-dark.css b/web/themes/manual-dark.css
index 95d81ca1b9..3c2b143b3c 100644
--- a/web/themes/manual-dark.css
+++ b/web/themes/manual-dark.css
@@ -1,64 +1,64 @@
html[data-theme="dark"] {
- --color-premium-yearly-tip-text-background: linear-gradient(91deg, #FDB022 2.18%, #F79009 108.79%);
- --color-premium-badge-background: linear-gradient(95deg, rgba(103, 111, 131, 0.90) 0%, rgba(73, 84, 100, 0.90) 105.58%), var(--util-colors-gray-gray-200, #18222F);
- --color-premium-text-background: linear-gradient(92deg, rgba(249, 250, 251, 0.95) 0%, rgba(233, 235, 240, 0.95) 97.78%);
- --color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
- --color-grid-mask-background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(24, 24, 25, 0.1) 62.25%, rgba(24, 24, 25, 0.10) 100%);
- --color-chatbot-bg: linear-gradient(180deg,
- rgba(34, 34, 37, 0.9) 0%,
- rgba(29, 29, 32, 0.9) 90.48%);
- --color-chat-bubble-bg: linear-gradient(180deg,
- rgba(200, 206, 218, 0.08) 0%,
- rgba(200, 206, 218, 0.02) 100%);
- --color-chat-input-mask: linear-gradient(180deg,
- rgba(24, 24, 27, 0.04) 0%,
- rgba(24, 24, 27, 0.60) 100%);
- --color-workflow-process-bg: linear-gradient(90deg,
- rgba(24, 24, 27, 0.25) 0%,
- rgba(24, 24, 27, 0.04) 100%);
- --color-workflow-run-failed-bg: linear-gradient(98deg,
- rgba(240, 68, 56, 0.12) 0%,
- rgba(0, 0, 0, 0) 26.01%);
- --color-workflow-batch-failed-bg: linear-gradient(92deg,
- rgba(240, 68, 56, 0.3) 0%,
- rgba(0, 0, 0, 0) 100%);
- --color-marketplace-divider-bg: linear-gradient(90deg,
- rgba(200, 206, 218, 0.14) 0%,
- rgba(0, 0, 0, 0) 100%);
- --color-marketplace-plugin-empty: linear-gradient(180deg,
- rgba(0, 0, 0, 0) 0%,
- #222225 100%);
- --color-toast-success-bg: linear-gradient(92deg,
- rgba(23, 178, 106, 0.3) 0%,
- rgba(0, 0, 0, 0) 100%);
- --color-toast-warning-bg: linear-gradient(92deg,
- rgba(247, 144, 9, 0.3) 0%,
- rgba(0, 0, 0, 0) 100%);
- --color-toast-error-bg: linear-gradient(92deg,
- rgba(240, 68, 56, 0.3) 0%,
- rgba(0, 0, 0, 0) 100%);
- --color-toast-info-bg: linear-gradient(92deg,
- rgba(11, 165, 236, 0.3) 0%);
- --color-account-teams-bg: linear-gradient(271deg,
- rgba(34, 34, 37, 0.9) -0.1%,
- rgba(29, 29, 32, 0.9) 98.26%);
- --color-app-detail-bg: linear-gradient(169deg,
- #1D1D20 1.18%,
- #222225 99.52%);
- --color-app-detail-overlay-bg: linear-gradient(270deg,
- rgba(0, 0, 0, 0.00) 0%,
- rgba(24, 24, 27, 0.02) 8%,
- rgba(24, 24, 27, 0.54) 100%);
- --color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
- --color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
- --color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1D1D20 0%, #222225 100%);
- --color-dataset-child-chunk-expand-btn-bg: linear-gradient(90deg, rgba(24, 24, 27, 0.25) 0%, rgba(24, 24, 27, 0.04) 100%);
- --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #24252E 0%, #1E1E21 100%);
- --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #25242E 0%, #1E1E21 100%);
- --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #2B2322 0%, #1E1E21 100%);
- --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(34, 34, 37, 0.00) 0%, #222225 100%);
- --mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg,
- rgba(24, 24, 27, 0.08) 0%,
- rgba(0, 0, 0, 0) 100%);
- --color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
+ --color-premium-yearly-tip-text-background: linear-gradient(91deg, #FDB022 2.18%, #F79009 108.79%);
+ --color-premium-badge-background: linear-gradient(95deg, rgba(103, 111, 131, 0.90) 0%, rgba(73, 84, 100, 0.90) 105.58%), var(--util-colors-gray-gray-200, #18222F);
+ --color-premium-text-background: linear-gradient(92deg, rgba(249, 250, 251, 0.95) 0%, rgba(233, 235, 240, 0.95) 97.78%);
+ --color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
+ --color-grid-mask-background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(24, 24, 25, 0.1) 62.25%, rgba(24, 24, 25, 0.10) 100%);
+ --color-chatbot-bg: linear-gradient(180deg,
+ rgba(34, 34, 37, 0.9) 0%,
+ rgba(29, 29, 32, 0.9) 90.48%);
+ --color-chat-bubble-bg: linear-gradient(180deg,
+ rgba(200, 206, 218, 0.08) 0%,
+ rgba(200, 206, 218, 0.02) 100%);
+ --color-chat-input-mask: linear-gradient(180deg,
+ rgba(24, 24, 27, 0.04) 0%,
+ rgba(24, 24, 27, 0.60) 100%);
+ --color-workflow-process-bg: linear-gradient(90deg,
+ rgba(24, 24, 27, 0.25) 0%,
+ rgba(24, 24, 27, 0.04) 100%);
+ --color-workflow-run-failed-bg: linear-gradient(98deg,
+ rgba(240, 68, 56, 0.12) 0%,
+ rgba(0, 0, 0, 0) 26.01%);
+ --color-workflow-batch-failed-bg: linear-gradient(92deg,
+ rgba(240, 68, 56, 0.3) 0%,
+ rgba(0, 0, 0, 0) 100%);
+ --color-marketplace-divider-bg: linear-gradient(90deg,
+ rgba(200, 206, 218, 0.14) 0%,
+ rgba(0, 0, 0, 0) 100%);
+ --color-marketplace-plugin-empty: linear-gradient(180deg,
+ rgba(0, 0, 0, 0) 0%,
+ #222225 100%);
+ --color-toast-success-bg: linear-gradient(92deg,
+ rgba(23, 178, 106, 0.3) 0%,
+ rgba(0, 0, 0, 0) 100%);
+ --color-toast-warning-bg: linear-gradient(92deg,
+ rgba(247, 144, 9, 0.3) 0%,
+ rgba(0, 0, 0, 0) 100%);
+ --color-toast-error-bg: linear-gradient(92deg,
+ rgba(240, 68, 56, 0.3) 0%,
+ rgba(0, 0, 0, 0) 100%);
+ --color-toast-info-bg: linear-gradient(92deg,
+ rgba(11, 165, 236, 0.3) 0%);
+ --color-account-teams-bg: linear-gradient(271deg,
+ rgba(34, 34, 37, 0.9) -0.1%,
+ rgba(29, 29, 32, 0.9) 98.26%);
+ --color-app-detail-bg: linear-gradient(169deg,
+ #1D1D20 1.18%,
+ #222225 99.52%);
+ --color-app-detail-overlay-bg: linear-gradient(270deg,
+ rgba(0, 0, 0, 0.00) 0%,
+ rgba(24, 24, 27, 0.02) 8%,
+ rgba(24, 24, 27, 0.54) 100%);
+ --color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
+ --color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
+ --color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1D1D20 0%, #222225 100%);
+ --color-dataset-child-chunk-expand-btn-bg: linear-gradient(90deg, rgba(24, 24, 27, 0.25) 0%, rgba(24, 24, 27, 0.04) 100%);
+ --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #24252E 0%, #1E1E21 100%);
+ --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #25242E 0%, #1E1E21 100%);
+ --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #2B2322 0%, #1E1E21 100%);
+ --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(34, 34, 37, 0.00) 0%, #222225 100%);
+ --mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg,
+ rgba(24, 24, 27, 0.08) 0%,
+ rgba(0, 0, 0, 0) 100%);
+ --color-line-divider-bg: linear-gradient(90deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 100%, );
}
\ No newline at end of file
diff --git a/web/themes/manual-light.css b/web/themes/manual-light.css
index c5a427ec3f..92473320bc 100644
--- a/web/themes/manual-light.css
+++ b/web/themes/manual-light.css
@@ -1,13 +1,54 @@
html[data-theme="light"] {
+ --color-premium-yearly-tip-text-background: linear-gradient(91deg, #F79009 2.18%, #DC6803 108.79%);
+ --color-premium-badge-background: linear-gradient(95deg, rgba(152, 162, 178, 0.90) 0%, rgba(103, 111, 131, 0.90) 105.58%);
+ --color-premium-text-background: linear-gradient(92deg, rgba(252, 252, 253, 0.95) 0%, rgba(242, 244, 247, 0.95) 97.78%);
+ --color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
+ --color-grid-mask-background: linear-gradient(0deg, #FFF 0%, rgba(217, 217, 217, 0.10) 62.25%, rgba(217, 217, 217, 0.10) 100%);
--color-chatbot-bg: linear-gradient(180deg,
- rgba(249, 250, 251, 0.9) 0%,
- rgba(242, 244, 247, 0.9) 90.48%);
+ rgba(249, 250, 251, 0.9) 0%,
+ rgba(242, 244, 247, 0.9) 90.48%);
--color-chat-bubble-bg: linear-gradient(180deg,
- #fff 0%,
- rgba(255, 255, 255, 0.6) 100%);
+ #fff 0%,
+ rgba(255, 255, 255, 0.6) 100%);
+ --color-chat-input-mask: linear-gradient(180deg,
+ rgba(255, 255, 255, 0.01) 0%,
+ #F2F4F7 100%);
--color-workflow-process-bg: linear-gradient(90deg,
- rgba(200, 206, 218, 0.2) 0%,
- rgba(200, 206, 218, 0.04) 100%);
+ rgba(200, 206, 218, 0.2) 0%,
+ rgba(200, 206, 218, 0.04) 100%);
+ --color-workflow-run-failed-bg: linear-gradient(98deg,
+ rgba(240, 68, 56, 0.10) 0%,
+ rgba(255, 255, 255, 0) 26.01%);
+ --color-workflow-batch-failed-bg: linear-gradient(92deg,
+ rgba(240, 68, 56, 0.25) 0%,
+ rgba(255, 255, 255, 0) 100%);
+ --color-marketplace-divider-bg: linear-gradient(90deg,
+ rgba(16, 24, 40, 0.08) 0%,
+ rgba(255, 255, 255, 0) 100%);
+ --color-marketplace-plugin-empty: linear-gradient(180deg,
+ rgba(255, 255, 255, 0) 0%,
+ #fcfcfd 100%);
+ --color-toast-success-bg: linear-gradient(92deg,
+ rgba(23, 178, 106, 0.25) 0%,
+ rgba(255, 255, 255, 0) 100%);
+ --color-toast-warning-bg: linear-gradient(92deg,
+ rgba(247, 144, 9, 0.25) 0%,
+ rgba(255, 255, 255, 0) 100%);
+ --color-toast-error-bg: linear-gradient(92deg,
+ rgba(240, 68, 56, 0.25) 0%,
+ rgba(255, 255, 255, 0) 100%);
+ --color-toast-info-bg: linear-gradient(92deg,
+ rgba(11, 165, 236, 0.25) 0%);
+ --color-account-teams-bg: linear-gradient(271deg,
+ rgba(249, 250, 251, 0.9) -0.1%,
+ rgba(242, 244, 247, 0.9) 98.26%);
+ --color-app-detail-bg: linear-gradient(169deg,
+ #F2F4F7 1.18%,
+ #F9FAFB 99.52%);
+ --color-app-detail-overlay-bg: linear-gradient(270deg,
+ rgba(0, 0, 0, 0.00) 0%,
+ rgba(16, 24, 40, 0.01) 8%,
+ rgba(16, 24, 40, 0.18) 100%);
--color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #F2F4F7 0%, #F9FAFB 100%);
@@ -15,50 +56,9 @@ html[data-theme="light"] {
--color-dataset-option-card-blue-gradient: linear-gradient(90deg, #F2F4F7 0%, #F9FAFB 100%);
--color-dataset-option-card-purple-gradient: linear-gradient(90deg, #F0EEFA 0%, #F9FAFB 100%);
--color-dataset-option-card-orange-gradient: linear-gradient(90deg, #F8F2EE 0%, #F9FAFB 100%);
- --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FFF 100%);
+ --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FCFCFD 100%);
--mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg,
- rgba(200, 206, 218, 0.2) 0%,
- rgba(255, 255, 255, 0) 100%);
- --color-premium-yearly-tip-text-background: linear-gradient(91deg, #F79009 2.18%, #DC6803 108.79%);
- --color-premium-badge-background: linear-gradient(95deg, rgba(152, 162, 178, 0.90) 0%, rgba(103, 111, 131, 0.90) 105.58%);
- --color-premium-text-background: linear-gradient(92deg, rgba(252, 252, 253, 0.95) 0%, rgba(242, 244, 247, 0.95) 97.78%);
- --color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%);
- --color-grid-mask-background: linear-gradient(0deg, #FFF 0%, rgba(217, 217, 217, 0.10) 62.25%, rgba(217, 217, 217, 0.10) 100%);
- --color-chat-input-mask: linear-gradient(180deg,
- rgba(255, 255, 255, 0.01) 0%,
- #F2F4F7 100%);
- --color-workflow-run-failed-bg: linear-gradient(98deg,
- rgba(240, 68, 56, 0.10) 0%,
- rgba(255, 255, 255, 0) 26.01%);
- --color-workflow-batch-failed-bg: linear-gradient(92deg,
- rgba(240, 68, 56, 0.25) 0%,
- rgba(255, 255, 255, 0) 100%);
- --color-marketplace-divider-bg: linear-gradient(90deg,
- rgba(16, 24, 40, 0.08) 0%,
- rgba(255, 255, 255, 0) 100%);
- --color-marketplace-plugin-empty: linear-gradient(180deg,
- rgba(255, 255, 255, 0) 0%,
- #fcfcfd 100%);
- --color-toast-success-bg: linear-gradient(92deg,
- rgba(23, 178, 106, 0.25) 0%,
- rgba(255, 255, 255, 0) 100%);
- --color-toast-warning-bg: linear-gradient(92deg,
- rgba(247, 144, 9, 0.25) 0%,
- rgba(255, 255, 255, 0) 100%);
- --color-toast-error-bg: linear-gradient(92deg,
- rgba(240, 68, 56, 0.25) 0%,
- rgba(255, 255, 255, 0) 100%);
- --color-toast-info-bg: linear-gradient(92deg,
- rgba(11, 165, 236, 0.25) 0%);
- --color-account-teams-bg: linear-gradient(271deg,
- rgba(249, 250, 251, 0.9) -0.1%,
- rgba(242, 244, 247, 0.9) 98.26%);
- --color-app-detail-bg: linear-gradient(169deg,
- #F2F4F7 1.18%,
- #F9FAFB 99.52%);
- --color-app-detail-overlay-bg: linear-gradient(270deg,
- rgba(0, 0, 0, 0.00) 0%,
- rgba(16, 24, 40, 0.01) 8%,
- rgba(16, 24, 40, 0.18) 100%);
- --color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
+ rgba(200, 206, 218, 0.2) 0%,
+ rgba(255, 255, 255, 0) 100%);
+ --color-line-divider-bg: linear-gradient(90deg, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
}
\ No newline at end of file
From 4247a6b8070ad2a780775400e250dda654c15eaf Mon Sep 17 00:00:00 2001
From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Date: Fri, 18 Apr 2025 05:06:09 -0400
Subject: [PATCH 06/25] fix: reset_password security issue (#18363)
---
.../console/auth/forgot_password.py | 18 +++++++++++++--
api/controllers/console/auth/login.py | 4 +++-
api/controllers/console/wraps.py | 13 +++++++++++
api/services/account_service.py | 22 +++++++++++++++----
4 files changed, 50 insertions(+), 7 deletions(-)
diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py
index dc0009f36e..d4a33645ab 100644
--- a/api/controllers/console/auth/forgot_password.py
+++ b/api/controllers/console/auth/forgot_password.py
@@ -16,7 +16,7 @@ from controllers.console.auth.error import (
PasswordMismatchError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
-from controllers.console.wraps import setup_required
+from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from libs.helper import email, extract_remote_ip
@@ -30,6 +30,7 @@ from services.feature_service import FeatureService
class ForgotPasswordSendEmailApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
@@ -62,6 +63,7 @@ class ForgotPasswordSendEmailApi(Resource):
class ForgotPasswordCheckApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=str, required=True, location="json")
@@ -86,12 +88,21 @@ class ForgotPasswordCheckApi(Resource):
AccountService.add_forgot_password_error_rate_limit(args["email"])
raise EmailCodeError()
+ # Verified, revoke the first token
+ AccountService.revoke_reset_password_token(args["token"])
+
+ # Refresh token data by generating a new token
+ _, new_token = AccountService.generate_reset_password_token(
+ user_email, code=args["code"], additional_data={"phase": "reset"}
+ )
+
AccountService.reset_forgot_password_error_rate_limit(args["email"])
- return {"is_valid": True, "email": token_data.get("email")}
+ return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
class ForgotPasswordResetApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
@@ -107,6 +118,9 @@ class ForgotPasswordResetApi(Resource):
reset_data = AccountService.get_reset_password_data(args["token"])
if not reset_data:
raise InvalidTokenError()
+ # Must use token in reset phase
+ if reset_data.get("phase", "") != "reset":
+ raise InvalidTokenError()
# Revoke token to prevent reuse
AccountService.revoke_reset_password_token(args["token"])
diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py
index 41362e9fa2..16c1dcc441 100644
--- a/api/controllers/console/auth/login.py
+++ b/api/controllers/console/auth/login.py
@@ -22,7 +22,7 @@ from controllers.console.error import (
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
)
-from controllers.console.wraps import setup_required
+from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created
from libs.helper import email, extract_remote_ip
from libs.password import valid_password
@@ -38,6 +38,7 @@ class LoginApi(Resource):
"""Resource for user login."""
@setup_required
+ @email_password_login_enabled
def post(self):
"""Authenticate user and login."""
parser = reqparse.RequestParser()
@@ -110,6 +111,7 @@ class LogoutApi(Resource):
class ResetPasswordSendEmailApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py
index 6caaae87f4..e5e8038ad7 100644
--- a/api/controllers/console/wraps.py
+++ b/api/controllers/console/wraps.py
@@ -210,3 +210,16 @@ def enterprise_license_required(view):
return view(*args, **kwargs)
return decorated
+
+
+def email_password_login_enabled(view):
+ @wraps(view)
+ def decorated(*args, **kwargs):
+ features = FeatureService.get_system_features()
+ if features.enable_email_password_login:
+ return view(*args, **kwargs)
+
+ # otherwise, return 403
+ abort(403)
+
+ return decorated
diff --git a/api/services/account_service.py b/api/services/account_service.py
index ada8109067..f930ef910b 100644
--- a/api/services/account_service.py
+++ b/api/services/account_service.py
@@ -407,10 +407,8 @@ class AccountService:
raise PasswordResetRateLimitExceededError()
- code = "".join([str(random.randint(0, 9)) for _ in range(6)])
- token = TokenManager.generate_token(
- account=account, email=email, token_type="reset_password", additional_data={"code": code}
- )
+ code, token = cls.generate_reset_password_token(account_email, account)
+
send_reset_password_mail_task.delay(
language=language,
to=account_email,
@@ -419,6 +417,22 @@ class AccountService:
cls.reset_password_rate_limiter.increment_rate_limit(account_email)
return token
+ @classmethod
+ def generate_reset_password_token(
+ cls,
+ email: str,
+ account: Optional[Account] = None,
+ code: Optional[str] = None,
+ additional_data: dict[str, Any] = {},
+ ):
+ if not code:
+ code = "".join([str(random.randint(0, 9)) for _ in range(6)])
+ additional_data["code"] = code
+ token = TokenManager.generate_token(
+ account=account, email=email, token_type="reset_password", additional_data=additional_data
+ )
+ return code, token
+
@classmethod
def revoke_reset_password_token(cls, token: str):
TokenManager.revoke_token(token, "reset_password")
From 7ba3e599d2994a66f345447a08dc12b166807262 Mon Sep 17 00:00:00 2001
From: NFish
Date: Fri, 18 Apr 2025 17:14:51 +0800
Subject: [PATCH 07/25] fix: update reset password token when email code verify
success (#18364)
---
web/app/reset-password/check-code/page.tsx | 6 +++++-
web/service/common.ts | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx
index c6242b4504..480b513112 100644
--- a/web/app/reset-password/check-code/page.tsx
+++ b/web/app/reset-password/check-code/page.tsx
@@ -39,7 +39,11 @@ export default function CheckCode() {
}
setIsLoading(true)
const ret = await verifyResetPasswordCode({ email, code, token })
- ret.is_valid && router.push(`/reset-password/set-password?${searchParams.toString()}`)
+ if (ret.is_valid) {
+ const params = new URLSearchParams(searchParams)
+ params.set('token', encodeURIComponent(ret.token))
+ router.push(`/reset-password/set-password?${params.toString()}`)
+ }
}
catch (error) { console.error(error) }
finally {
diff --git a/web/service/common.ts b/web/service/common.ts
index 9e0ea4aef1..e76cfb4196 100644
--- a/web/service/common.ts
+++ b/web/service/common.ts
@@ -338,7 +338,7 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') =>
post('/forgot-password', { body: { email, language } })
export const verifyResetPasswordCode = (body: { email: string; code: string; token: string }) =>
- post('/forgot-password/validity', { body })
+ post('/forgot-password/validity', { body })
export const sendDeleteAccountCode = () =>
get('/account/delete/verify')
From 20df6e9c008110633a4b4bc84e7ef17e695572a6 Mon Sep 17 00:00:00 2001
From: souljoy
Date: Fri, 18 Apr 2025 18:50:03 +0800
Subject: [PATCH 08/25] Add docker environment variable PIP_MIRROR_URL for
sandbox (#18371)
---
docker/docker-compose-template.yaml | 1 +
docker/docker-compose.middleware.yaml | 1 +
docker/docker-compose.yaml | 1 +
3 files changed, 3 insertions(+)
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index a8f7b755fb..c6d41849ef 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -130,6 +130,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
+ PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml
index 27d6d660d0..1702a5395f 100644
--- a/docker/docker-compose.middleware.yaml
+++ b/docker/docker-compose.middleware.yaml
@@ -60,6 +60,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
+ PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index e01b9f7e9a..def4b77c65 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -603,6 +603,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
+ PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
From 44a2eca449cc106d5dcd2a670b2a5daa58d633e8 Mon Sep 17 00:00:00 2001
From: -LAN-
Date: Fri, 18 Apr 2025 21:06:24 +0900
Subject: [PATCH 09/25] refactor: Refactors workflow node execution handling
(#18382)
Signed-off-by: -LAN-
---
api/core/plugin/backwards_invocation/node.py | 2 ++
.../workflow_node_execution_repository.py | 9 +++++++
api/models/workflow.py | 2 ++
.../sqlalchemy_repository.py | 24 ++++++++++++++++-
api/services/workflow_run_service.py | 27 ++++++++++---------
api/services/workflow_service.py | 12 +++++++--
api/tasks/remove_app_and_related_data_task.py | 25 +++++++++--------
.../test_sqlalchemy_repository.py | 24 +++++++++++++++++
8 files changed, 98 insertions(+), 27 deletions(-)
diff --git a/api/core/plugin/backwards_invocation/node.py b/api/core/plugin/backwards_invocation/node.py
index f402da030f..db07e52f3f 100644
--- a/api/core/plugin/backwards_invocation/node.py
+++ b/api/core/plugin/backwards_invocation/node.py
@@ -39,6 +39,7 @@ class PluginNodeBackwardsInvocation(BaseBackwardsInvocation):
:param query: str
:return: dict
"""
+ # FIXME(-LAN-): Avoid import service into core
workflow_service = WorkflowService()
node_id = "1919810"
node_data = ParameterExtractorNodeData(
@@ -89,6 +90,7 @@ class PluginNodeBackwardsInvocation(BaseBackwardsInvocation):
:param query: str
:return: dict
"""
+ # FIXME(-LAN-): Avoid import service into core
workflow_service = WorkflowService()
node_id = "1919810"
node_data = QuestionClassifierNodeData(
diff --git a/api/core/repository/workflow_node_execution_repository.py b/api/core/repository/workflow_node_execution_repository.py
index 6dea4566de..9bb790cb0f 100644
--- a/api/core/repository/workflow_node_execution_repository.py
+++ b/api/core/repository/workflow_node_execution_repository.py
@@ -86,3 +86,12 @@ class WorkflowNodeExecutionRepository(Protocol):
execution: The WorkflowNodeExecution instance to update
"""
...
+
+ def clear(self) -> None:
+ """
+ Clear all WorkflowNodeExecution records based on implementation-specific criteria.
+
+ This method is intended to be used for bulk deletion operations, such as removing
+ all records associated with a specific app_id and tenant_id in multi-tenant implementations.
+ """
+ ...
diff --git a/api/models/workflow.py b/api/models/workflow.py
index 045fa0aaa0..51f2f4cc9f 100644
--- a/api/models/workflow.py
+++ b/api/models/workflow.py
@@ -630,6 +630,7 @@ class WorkflowNodeExecution(Base):
@property
def created_by_account(self):
created_by_role = CreatedByRole(self.created_by_role)
+ # TODO(-LAN-): Avoid using db.session.get() here.
return db.session.get(Account, self.created_by) if created_by_role == CreatedByRole.ACCOUNT else None
@property
@@ -637,6 +638,7 @@ class WorkflowNodeExecution(Base):
from models.model import EndUser
created_by_role = CreatedByRole(self.created_by_role)
+ # TODO(-LAN-): Avoid using db.session.get() here.
return db.session.get(EndUser, self.created_by) if created_by_role == CreatedByRole.END_USER else None
@property
diff --git a/api/repositories/workflow_node_execution/sqlalchemy_repository.py b/api/repositories/workflow_node_execution/sqlalchemy_repository.py
index c9c6e70ff3..0594d816a2 100644
--- a/api/repositories/workflow_node_execution/sqlalchemy_repository.py
+++ b/api/repositories/workflow_node_execution/sqlalchemy_repository.py
@@ -6,7 +6,7 @@ import logging
from collections.abc import Sequence
from typing import Optional
-from sqlalchemy import UnaryExpression, asc, desc, select
+from sqlalchemy import UnaryExpression, asc, delete, desc, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
@@ -168,3 +168,25 @@ class SQLAlchemyWorkflowNodeExecutionRepository:
session.merge(execution)
session.commit()
+
+ def clear(self) -> None:
+ """
+ Clear all WorkflowNodeExecution records for the current tenant_id and app_id.
+
+ This method deletes all WorkflowNodeExecution records that match the tenant_id
+ and app_id (if provided) associated with this repository instance.
+ """
+ with self._session_factory() as session:
+ stmt = delete(WorkflowNodeExecution).where(WorkflowNodeExecution.tenant_id == self._tenant_id)
+
+ if self._app_id:
+ stmt = stmt.where(WorkflowNodeExecution.app_id == self._app_id)
+
+ result = session.execute(stmt)
+ session.commit()
+
+ deleted_count = result.rowcount
+ logger.info(
+ f"Cleared {deleted_count} workflow node execution records for tenant {self._tenant_id}"
+ + (f" and app {self._app_id}" if self._app_id else "")
+ )
diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py
index 0ddd18ea27..ff3b33eecd 100644
--- a/api/services/workflow_run_service.py
+++ b/api/services/workflow_run_service.py
@@ -2,13 +2,14 @@ import threading
from typing import Optional
import contexts
+from core.repository import RepositoryFactory
+from core.repository.workflow_node_execution_repository import OrderConfig
from extensions.ext_database import db
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import WorkflowRunTriggeredFrom
from models.model import App
from models.workflow import (
WorkflowNodeExecution,
- WorkflowNodeExecutionTriggeredFrom,
WorkflowRun,
)
@@ -127,17 +128,17 @@ class WorkflowRunService:
if not workflow_run:
return []
- node_executions = (
- db.session.query(WorkflowNodeExecution)
- .filter(
- WorkflowNodeExecution.tenant_id == app_model.tenant_id,
- WorkflowNodeExecution.app_id == app_model.id,
- WorkflowNodeExecution.workflow_id == workflow_run.workflow_id,
- WorkflowNodeExecution.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value,
- WorkflowNodeExecution.workflow_run_id == run_id,
- )
- .order_by(WorkflowNodeExecution.index.desc())
- .all()
+ # Use the repository to get the node executions
+ repository = RepositoryFactory.create_workflow_node_execution_repository(
+ params={
+ "tenant_id": app_model.tenant_id,
+ "app_id": app_model.id,
+ "session_factory": db.session.get_bind,
+ }
)
- return node_executions
+ # Use the repository to get the node executions with ordering
+ order_config = OrderConfig(order_by=["index"], order_direction="desc")
+ node_executions = repository.get_by_workflow_run(workflow_run_id=run_id, order_config=order_config)
+
+ return list(node_executions)
diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py
index 992942fc70..b88c7b296d 100644
--- a/api/services/workflow_service.py
+++ b/api/services/workflow_service.py
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.model_runtime.utils.encoders import jsonable_encoder
+from core.repository import RepositoryFactory
from core.variables import Variable
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.errors import WorkflowNodeRunFailedError
@@ -282,8 +283,15 @@ class WorkflowService:
workflow_node_execution.created_by = account.id
workflow_node_execution.workflow_id = draft_workflow.id
- db.session.add(workflow_node_execution)
- db.session.commit()
+ # Use the repository to save the workflow node execution
+ repository = RepositoryFactory.create_workflow_node_execution_repository(
+ params={
+ "tenant_id": app_model.tenant_id,
+ "app_id": app_model.id,
+ "session_factory": db.session.get_bind,
+ }
+ )
+ repository.save(workflow_node_execution)
return workflow_node_execution
diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py
index c3910e2be3..4542b1b923 100644
--- a/api/tasks/remove_app_and_related_data_task.py
+++ b/api/tasks/remove_app_and_related_data_task.py
@@ -7,6 +7,7 @@ from celery import shared_task # type: ignore
from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError
+from core.repository import RepositoryFactory
from extensions.ext_database import db
from models.dataset import AppDatasetJoin
from models.model import (
@@ -30,7 +31,7 @@ from models.model import (
)
from models.tools import WorkflowToolProvider
from models.web import PinnedConversation, SavedMessage
-from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowNodeExecution, WorkflowRun
+from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowRun
@shared_task(queue="app_deletion", bind=True, max_retries=3)
@@ -187,18 +188,20 @@ def _delete_app_workflow_runs(tenant_id: str, app_id: str):
def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
- def del_workflow_node_execution(workflow_node_execution_id: str):
- db.session.query(WorkflowNodeExecution).filter(WorkflowNodeExecution.id == workflow_node_execution_id).delete(
- synchronize_session=False
- )
-
- _delete_records(
- """select id from workflow_node_executions where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
- {"tenant_id": tenant_id, "app_id": app_id},
- del_workflow_node_execution,
- "workflow node execution",
+ # Create a repository instance for WorkflowNodeExecution
+ repository = RepositoryFactory.create_workflow_node_execution_repository(
+ params={
+ "tenant_id": tenant_id,
+ "app_id": app_id,
+ "session_factory": db.session.get_bind,
+ }
)
+ # Use the clear method to delete all records for this tenant_id and app_id
+ repository.clear()
+
+ logging.info(click.style(f"Deleted workflow node executions for tenant {tenant_id} and app {app_id}", fg="green"))
+
def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):
def del_workflow_app_log(workflow_app_log_id: str):
diff --git a/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py b/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py
index f31adab2a8..36847f8a13 100644
--- a/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py
+++ b/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py
@@ -152,3 +152,27 @@ def test_update(repository, session):
# Assert session.merge was called
session_obj.merge.assert_called_once_with(execution)
+
+
+def test_clear(repository, session, mocker: MockerFixture):
+ """Test clear method."""
+ session_obj, _ = session
+ # Set up mock
+ mock_delete = mocker.patch("repositories.workflow_node_execution.sqlalchemy_repository.delete")
+ mock_stmt = mocker.MagicMock()
+ mock_delete.return_value = mock_stmt
+ mock_stmt.where.return_value = mock_stmt
+
+ # Mock the execute result with rowcount
+ mock_result = mocker.MagicMock()
+ mock_result.rowcount = 5 # Simulate 5 records deleted
+ session_obj.execute.return_value = mock_result
+
+ # Call method
+ repository.clear()
+
+ # Assert delete was called with correct parameters
+ mock_delete.assert_called_once_with(WorkflowNodeExecution)
+ mock_stmt.where.assert_called()
+ session_obj.execute.assert_called_once_with(mock_stmt)
+ session_obj.commit.assert_called_once()
From 00d9f037b5a099f9a40f6f5f77ed37e7d45d2839 Mon Sep 17 00:00:00 2001
From: kurokobo
Date: Sat, 19 Apr 2025 11:12:12 +0900
Subject: [PATCH 10/25] fix: correct icons for gpt-4 series from non-openai
providers (#18387)
---
.../account-setting/model-provider-page/model-icon/index.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx
index 025cb87dc1..9019051989 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx
@@ -23,9 +23,9 @@ const ModelIcon: FC = ({
isDeprecated = false,
}) => {
const language = useLanguage()
- if (provider?.provider.includes('openai') && modelName?.includes('gpt-4o'))
+ if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.includes('gpt-4o'))
return
- if (provider?.provider.includes('openai') && modelName?.startsWith('gpt-4'))
+ if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('gpt-4'))
return
if (provider?.icon_small) {
From 1e32175cdce8d4b3a1b9658db1d099aae475117b Mon Sep 17 00:00:00 2001
From: crazywoola <100913391+crazywoola@users.noreply.github.com>
Date: Sat, 19 Apr 2025 11:59:00 +0800
Subject: [PATCH 11/25] Feat/music annotation (#18391)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../components/base/markdown-blocks/music.tsx | 36 ++++++++
web/app/components/base/markdown.tsx | 83 +++++++++++--------
web/app/styles/markdown.scss | 3 +
web/package.json | 1 +
web/pnpm-lock.yaml | 8 ++
5 files changed, 95 insertions(+), 36 deletions(-)
create mode 100644 web/app/components/base/markdown-blocks/music.tsx
diff --git a/web/app/components/base/markdown-blocks/music.tsx b/web/app/components/base/markdown-blocks/music.tsx
new file mode 100644
index 0000000000..aedddf905c
--- /dev/null
+++ b/web/app/components/base/markdown-blocks/music.tsx
@@ -0,0 +1,36 @@
+import abcjs from 'abcjs'
+import { useEffect, useRef } from 'react'
+import 'abcjs/abcjs-audio.css'
+
+const MarkdownMusic = ({ children }: { children: React.ReactNode }) => {
+ const containerRef = useRef(null)
+ const controlsRef = useRef(null)
+
+ useEffect(() => {
+ if (containerRef.current && controlsRef.current) {
+ if (typeof children === 'string') {
+ const visualObjs = abcjs.renderAbc(containerRef.current, children)
+ const synthControl = new abcjs.synth.SynthController()
+ synthControl.load(controlsRef.current, {}, { displayPlay: true })
+ const synth = new abcjs.synth.CreateSynth()
+ const visualObj = visualObjs[0]
+ synth.init({ visualObj }).then(() => {
+ synthControl.setTune(visualObj, false)
+ })
+ containerRef.current.style.overflow = 'auto'
+ }
+ }
+ }, [children])
+
+ return (
+
+ )
+}
+MarkdownMusic.displayName = 'MarkdownMusic'
+
+export default MarkdownMusic
diff --git a/web/app/components/base/markdown.tsx b/web/app/components/base/markdown.tsx
index d50c397177..52b880affa 100644
--- a/web/app/components/base/markdown.tsx
+++ b/web/app/components/base/markdown.tsx
@@ -23,6 +23,7 @@ import VideoGallery from '@/app/components/base/video-gallery'
import AudioGallery from '@/app/components/base/audio-gallery'
import MarkdownButton from '@/app/components/base/markdown-blocks/button'
import MarkdownForm from '@/app/components/base/markdown-blocks/form'
+import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
import ThinkBlock from '@/app/components/base/markdown-blocks/think-block'
import { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme'
@@ -51,6 +52,7 @@ const capitalizationLanguageNameMap: Record = {
json: 'JSON',
latex: 'Latex',
svg: 'SVG',
+ abc: 'ABC',
}
const getCorrectCapitalizationLanguageName = (language: string) => {
if (!language)
@@ -137,45 +139,54 @@ const CodeBlock: any = memo(({ inline, className, children, ...props }: any) =>
const renderCodeContent = useMemo(() => {
const content = String(children).replace(/\n$/, '')
- if (language === 'mermaid' && isSVG) {
- return
- }
- else if (language === 'echarts') {
- return (
-
+ switch (language) {
+ case 'mermaid':
+ if (isSVG)
+ return
+ break
+ case 'echarts':
+ return (
+
+
+
+
+
+ )
+ case 'svg':
+ if (isSVG) {
+ return (
+
+
+
+ )
+ }
+ break
+ case 'abc':
+ return (
-
+
-
- )
+ )
+ default:
+ return (
+
+ {content}
+
+ )
}
- else if (language === 'svg' && isSVG) {
- return (
-
-
-
- )
- }
- else {
- return (
-
- {content}
-
- )
- }
- }, [language, match, props, children, chartData, isSVG])
+ }, [children, language, isSVG, chartData, props, theme, match])
if (inline || !match)
return {children}
diff --git a/web/app/styles/markdown.scss b/web/app/styles/markdown.scss
index f1f2a7d670..bd9c7343f3 100644
--- a/web/app/styles/markdown.scss
+++ b/web/app/styles/markdown.scss
@@ -1039,3 +1039,6 @@
.markdown-body .react-syntax-highlighter-line-number {
color: var(--color-text-quaternary);
}
+.markdown-body .abcjs-inline-audio .abcjs-btn {
+ display: flex !important;
+}
diff --git a/web/package.json b/web/package.json
index b63617f47b..0dd0b72bc2 100644
--- a/web/package.json
+++ b/web/package.json
@@ -57,6 +57,7 @@
"@tanstack/react-form": "^1.3.3",
"@tanstack/react-query": "^5.60.5",
"@tanstack/react-query-devtools": "^5.60.5",
+ "abcjs": "^6.4.4",
"ahooks": "^3.8.4",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 28822be807..bf39194eaa 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -103,6 +103,9 @@ importers:
'@tanstack/react-query-devtools':
specifier: ^5.60.5
version: 5.72.2(@tanstack/react-query@5.72.2(react@19.0.0))(react@19.0.0)
+ abcjs:
+ specifier: ^6.4.4
+ version: 6.4.4
ahooks:
specifier: ^3.8.4
version: 3.8.4(react@19.0.0)
@@ -3416,6 +3419,9 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+ abcjs@6.4.4:
+ resolution: {integrity: sha512-dT3Z2vb8yihbiPMzSoup0JOcvO2je4qpFNlTD+kS5VBelE3AASAs18dS5qeMWkZeqCz7kI/hz62B2lpMDugWLA==}
+
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -12127,6 +12133,8 @@ snapshots:
abbrev@1.1.1:
optional: true
+ abcjs@6.4.4: {}
+
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
From 93c1ee225e72a40a9be5d8fc84abc096531631db Mon Sep 17 00:00:00 2001
From: crazywoola <100913391+crazywoola@users.noreply.github.com>
Date: Sat, 19 Apr 2025 14:46:10 +0800
Subject: [PATCH 12/25] fix: styles and missing imports (#18396)
---
web/app/components/base/markdown-blocks/music.tsx | 11 ++++++-----
.../workflow/panel/debug-and-preview/index.tsx | 10 +++++-----
2 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/web/app/components/base/markdown-blocks/music.tsx b/web/app/components/base/markdown-blocks/music.tsx
index aedddf905c..7edd1713c9 100644
--- a/web/app/components/base/markdown-blocks/music.tsx
+++ b/web/app/components/base/markdown-blocks/music.tsx
@@ -9,7 +9,10 @@ const MarkdownMusic = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
if (containerRef.current && controlsRef.current) {
if (typeof children === 'string') {
- const visualObjs = abcjs.renderAbc(containerRef.current, children)
+ const visualObjs = abcjs.renderAbc(containerRef.current, children, {
+ add_classes: true, // Add classes to SVG elements for cursor tracking
+ responsive: 'resize', // Make notation responsive
+ })
const synthControl = new abcjs.synth.SynthController()
synthControl.load(controlsRef.current, {}, { displayPlay: true })
const synth = new abcjs.synth.CreateSynth()
@@ -23,11 +26,9 @@ const MarkdownMusic = ({ children }: { children: React.ReactNode }) => {
}, [children])
return (
-
+
)
}
diff --git a/web/app/components/workflow/panel/debug-and-preview/index.tsx b/web/app/components/workflow/panel/debug-and-preview/index.tsx
index c33a6355f2..b63b7af16c 100644
--- a/web/app/components/workflow/panel/debug-and-preview/index.tsx
+++ b/web/app/components/workflow/panel/debug-and-preview/index.tsx
@@ -10,10 +10,10 @@ import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import {
- useEdgesInteractions,
- useNodesInteractions,
useWorkflowInteractions,
} from '../../hooks'
+import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
+import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { BlockEnum } from '../../types'
import type { StartNodeType } from '../../nodes/start/types'
import ChatWrapper from './chat-wrapper'
@@ -32,8 +32,8 @@ const DebugAndPreview = () => {
const { t } = useTranslation()
const chatRef = useRef({ handleRestart: noop })
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
- const { handleNodeCancelRunningStatus } = useNodesInteractions()
- const { handleEdgeCancelRunningStatus } = useEdgesInteractions()
+ const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
+ const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const varList = useStore(s => s.conversationVariables)
const [expanded, setExpanded] = useState(true)
const nodes = useNodes
()
@@ -116,7 +116,7 @@ const DebugAndPreview = () => {
- {expanded && }
+ {expanded && }
)}
From 9a3acdcff81cc68e2ce1ff24c9d89fa536ee9e7c Mon Sep 17 00:00:00 2001
From: Kalo Chin <91766386+fdb02983rhy@users.noreply.github.com>
Date: Sat, 19 Apr 2025 18:25:52 +0900
Subject: [PATCH 13/25] Fix: agent app debug re-rendering issue (#18389)
---
web/app/components/base/chat/chat/answer/index.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx
index 3722556931..a0a9323729 100644
--- a/web/app/components/base/chat/chat/answer/index.tsx
+++ b/web/app/components/base/chat/chat/answer/index.tsx
@@ -234,4 +234,6 @@ const Answer: FC = ({
)
}
-export default memo(Answer)
+export default memo(Answer, (prevProps, nextProps) =>
+ prevProps.responding === false && nextProps.responding === false,
+)
From 9bd8e6270257448e09628db8e8e89b9fb43b4d25 Mon Sep 17 00:00:00 2001
From: CorrectRoad
Date: Mon, 21 Apr 2025 09:51:39 +0800
Subject: [PATCH 14/25] fix: bump the minimal node requirement to fix eslint
fail (#17938)
---
web/README.md | 2 +-
web/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/README.md b/web/README.md
index 3236347e80..3d9fd2de87 100644
--- a/web/README.md
+++ b/web/README.md
@@ -7,7 +7,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
### Run by source code
Before starting the web frontend service, please make sure the following environment is ready.
-- [Node.js](https://nodejs.org) >= v18.x
+- [Node.js](https://nodejs.org) >= v22.11.x
- [pnpm](https://pnpm.io) v10.x
First, install the dependencies:
diff --git a/web/package.json b/web/package.json
index 0dd0b72bc2..714ec87695 100644
--- a/web/package.json
+++ b/web/package.json
@@ -3,7 +3,7 @@
"version": "1.2.0",
"private": true,
"engines": {
- "node": ">=18.18.0"
+ "node": ">=v22.11.0"
},
"scripts": {
"dev": "cross-env NODE_OPTIONS='--inspect' next dev",
From c768d97637712914e7302ac567c72f09c63e5916 Mon Sep 17 00:00:00 2001
From: Ethan <118581835+realethanhsu@users.noreply.github.com>
Date: Mon, 21 Apr 2025 10:04:33 +0800
Subject: [PATCH 15/25] feat: update privacy policy URL and add validation for
privacy policy link (#18422)
---
web/app/components/app/overview/settings/index.tsx | 13 ++++++++++++-
web/i18n/de-DE/app-overview.ts | 1 +
web/i18n/en-US/app-overview.ts | 1 +
web/i18n/es-ES/app-overview.ts | 1 +
web/i18n/fa-IR/app-overview.ts | 1 +
web/i18n/fr-FR/app-overview.ts | 1 +
web/i18n/hi-IN/app-overview.ts | 1 +
web/i18n/it-IT/app-overview.ts | 1 +
web/i18n/ja-JP/app-overview.ts | 1 +
web/i18n/ko-KR/app-overview.ts | 1 +
web/i18n/pl-PL/app-overview.ts | 1 +
web/i18n/pt-BR/app-overview.ts | 1 +
web/i18n/ro-RO/app-overview.ts | 1 +
web/i18n/ru-RU/app-overview.ts | 1 +
web/i18n/sl-SI/app-overview.ts | 1 +
web/i18n/th-TH/app-overview.ts | 1 +
web/i18n/tr-TR/app-overview.ts | 1 +
web/i18n/uk-UA/app-overview.ts | 1 +
web/i18n/vi-VN/app-overview.ts | 1 +
web/i18n/zh-Hans/app-overview.ts | 1 +
web/i18n/zh-Hant/app-overview.ts | 1 +
21 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx
index 896229d433..679d616e54 100644
--- a/web/app/components/app/overview/settings/index.tsx
+++ b/web/app/components/app/overview/settings/index.tsx
@@ -162,11 +162,22 @@ const SettingsModal: FC = ({
return check
}
+ const validatePrivacyPolicy = (privacyPolicy: string | null) => {
+ if (privacyPolicy === null || privacyPolicy?.length === 0)
+ return true
+
+ return privacyPolicy.startsWith('http://') || privacyPolicy.startsWith('https://')
+ }
+
if (inputInfo !== null) {
if (!validateColorHex(inputInfo.chatColorTheme)) {
notify({ type: 'error', message: t(`${prefixSettings}.invalidHexMessage`) })
return
}
+ if (!validatePrivacyPolicy(inputInfo.privacyPolicy)) {
+ notify({ type: 'error', message: t(`${prefixSettings}.invalidPrivacyPolicy`) })
+ return
+ }
}
setSaveLoading(true)
@@ -410,7 +421,7 @@ const SettingsModal: FC = ({
}}
+ components={{ privacyPolicyLink: }}
/>
Date: Mon, 21 Apr 2025 04:06:21 +0200
Subject: [PATCH 16/25] fix: correct unsupported German date format on document
list page (#18426)
---
web/i18n/de-DE/dataset-hit-testing.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/i18n/de-DE/dataset-hit-testing.ts b/web/i18n/de-DE/dataset-hit-testing.ts
index cb96b22689..840a3c81f1 100644
--- a/web/i18n/de-DE/dataset-hit-testing.ts
+++ b/web/i18n/de-DE/dataset-hit-testing.ts
@@ -1,7 +1,7 @@
const translation = {
title: 'Abruf-Test',
desc: 'Testen Sie die Treffereffektivität des Wissens anhand des gegebenen Abfragetextes.',
- dateTimeFormat: 'MM/TT/JJJJ hh:mm A',
+ dateTimeFormat: 'MM/DD/YYYY hh:mm A',
recents: 'Kürzlich',
table: {
header: {
From 62e7fa1f6313005f38e42ec04dab33ee346b1808 Mon Sep 17 00:00:00 2001
From: doskoi <50610194+t-daisuke@users.noreply.github.com>
Date: Mon, 21 Apr 2025 13:41:31 +0900
Subject: [PATCH 17/25] =?UTF-8?q?"fix:=20Changed=20the=20translated=20text?=
=?UTF-8?q?=20from=20'=E9=9A=9C=E5=AE=B3=E8=80=85'=20(#18427)"=20(#18438)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/i18n/ja-JP/plugin.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts
index cdd0e443e4..6189ef9c9b 100644
--- a/web/i18n/ja-JP/plugin.ts
+++ b/web/i18n/ja-JP/plugin.ts
@@ -70,7 +70,7 @@ const translation = {
endpointDeleteTip: 'エンドポイントを削除',
modelNum: '{{num}} モデルが含まれています',
serviceOk: 'サービスは正常です',
- disabled: '障害者',
+ disabled: 'サービスは無効化されています',
endpoints: 'エンドポイント',
endpointsTip: 'このプラグインはエンドポイントを介して特定の機能を提供し、現在のワークスペースのために複数のエンドポイントセットを構成できます。',
configureModel: 'モデルを設定する',
From 2157d9e17ee3f6896a46e7c2d86e123039b799ee Mon Sep 17 00:00:00 2001
From: doskoi <50610194+t-daisuke@users.noreply.github.com>
Date: Mon, 21 Apr 2025 15:12:21 +0900
Subject: [PATCH 18/25] =?UTF-8?q?fix:=20update=20Japanese=20translation=20?=
=?UTF-8?q?for=20'from'=20in=20plugin.ts=20to=20improve=20c=E2=80=A6=20(#1?=
=?UTF-8?q?8449)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/i18n/ja-JP/plugin.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts
index 6189ef9c9b..9b3014c022 100644
--- a/web/i18n/ja-JP/plugin.ts
+++ b/web/i18n/ja-JP/plugin.ts
@@ -191,7 +191,7 @@ const translation = {
installingWithError: '{{installingLength}}個のプラグインをインストール中、{{successLength}}件成功、{{errorLength}}件失敗',
installing: '{{installingLength}}個のプラグインをインストール中、0個完了。',
},
- from: 'から',
+ from: 'インストール元',
install: '{{num}} インストール',
installAction: 'インストール',
installFrom: 'インストール元',
From e0e8667a0b24100f0e603143b78596fa66765b85 Mon Sep 17 00:00:00 2001
From: doskoi <50610194+t-daisuke@users.noreply.github.com>
Date: Mon, 21 Apr 2025 15:12:44 +0900
Subject: [PATCH 19/25] =?UTF-8?q?fix:=20translate=20'back'=20to=20'?=
=?UTF-8?q?=E6=88=BB=E3=82=8B'=20in=20Japanese=20plugin=20localization=20(?=
=?UTF-8?q?#18444)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/i18n/ja-JP/plugin.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts
index 9b3014c022..be72c665a4 100644
--- a/web/i18n/ja-JP/plugin.ts
+++ b/web/i18n/ja-JP/plugin.ts
@@ -134,7 +134,7 @@ const translation = {
install: 'インストール',
dropPluginToInstall: 'プラグインパッケージをここにドロップしてインストールします',
installPlugin: 'プラグインをインストールする',
- back: 'バック',
+ back: '戻る',
uploadingPackage: '{{packageName}}をアップロード中...',
},
installFromGitHub: {
From 0ba37592f798640f7a7a80655bcb3c43c269d678 Mon Sep 17 00:00:00 2001
From: doskoi <50610194+t-daisuke@users.noreply.github.com>
Date: Mon, 21 Apr 2025 15:14:09 +0900
Subject: [PATCH 20/25] fix: update Japanese translation for document link in
plugin.ts, translation for "endpointsDocLink" label (#18446)
---
web/i18n/ja-JP/plugin.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts
index be72c665a4..cb2a5f4f80 100644
--- a/web/i18n/ja-JP/plugin.ts
+++ b/web/i18n/ja-JP/plugin.ts
@@ -80,7 +80,7 @@ const translation = {
configureApp: 'アプリを設定する',
endpointDeleteContent: '{{name}}を削除しますか?',
actionNum: '{{num}} {{action}} が含まれています',
- endpointsDocLink: '文書を表示する',
+ endpointsDocLink: 'ドキュメントを表示する',
switchVersion: 'スイッチ版',
},
debugInfo: {
From 5d9c67e97e606fb7e7d795b4373f13486efefa97 Mon Sep 17 00:00:00 2001
From: Joel
Date: Mon, 21 Apr 2025 14:15:38 +0800
Subject: [PATCH 21/25] fix: handle array item type error in struct output
(#18452)
---
.../nodes/_base/components/variable/utils.ts | 23 +++++++++++++++----
.../components/workflow/nodes/llm/types.ts | 3 +++
2 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
index 7299bcb34e..e6434cd8c5 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts
+++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
@@ -57,7 +57,14 @@ const inputVarTypeToVarType = (type: InputVarType): VarType => {
} as any)[type] || VarType.string
}
-const structTypeToVarType = (type: Type): VarType => {
+const structTypeToVarType = (type: Type, isArray?: boolean): VarType => {
+ if (isArray) {
+ return ({
+ [Type.string]: VarType.arrayString,
+ [Type.number]: VarType.arrayNumber,
+ [Type.object]: VarType.arrayObject,
+ } as any)[type] || VarType.string
+ }
return ({
[Type.string]: VarType.string,
[Type.number]: VarType.number,
@@ -82,9 +89,12 @@ const findExceptVarInStructuredProperties = (properties: Record {
const item = properties[key]
const isObj = item.type === Type.object
+ const isArray = item.type === Type.array
+ const arrayType = item.items?.type
+
if (!isObj && !filterVar({
variable: key,
- type: structTypeToVarType(item.type),
+ type: structTypeToVarType(isArray ? arrayType! : item.type, isArray),
}, [key])) {
delete properties[key]
return
@@ -103,9 +113,11 @@ const findExceptVarInStructuredOutput = (structuredOutput: StructuredOutput, fil
Object.keys(properties).forEach((key) => {
const item = properties[key]
const isObj = item.type === Type.object
+ const isArray = item.type === Type.array
+ const arrayType = item.items?.type
if (!isObj && !filterVar({
variable: key,
- type: structTypeToVarType(item.type),
+ type: structTypeToVarType(isArray ? arrayType! : item.type, isArray),
}, [key])) {
delete properties[key]
return
@@ -1314,9 +1326,12 @@ const varToValueSelectorList = (v: Var, parentValueSelector: ValueSelector, res:
}
if (isStructuredOutput) {
Object.keys((v.children as StructuredOutput)?.schema?.properties || {}).forEach((key) => {
+ const type = (v.children as StructuredOutput)?.schema?.properties[key].type
+ const isArray = type === Type.array
+ const arrayType = (v.children as StructuredOutput)?.schema?.properties[key].items?.type
varToValueSelectorList({
variable: key,
- type: structTypeToVarType((v.children as StructuredOutput)?.schema?.properties[key].type),
+ type: structTypeToVarType(isArray ? arrayType! : type, isArray),
}, [...parentValueSelector, v.variable], res)
})
}
diff --git a/web/app/components/workflow/nodes/llm/types.ts b/web/app/components/workflow/nodes/llm/types.ts
index a4931f7017..c1d33a2e92 100644
--- a/web/app/components/workflow/nodes/llm/types.ts
+++ b/web/app/components/workflow/nodes/llm/types.ts
@@ -25,6 +25,9 @@ export enum Type {
boolean = 'boolean',
object = 'object',
array = 'array',
+ arrayString = 'array[string]',
+ arrayNumber = 'array[number]',
+ arrayObject = 'array[object]',
}
export enum ArrayType {
From cb69cb2d64fedcd186cd8fedb8d6ad623401653a Mon Sep 17 00:00:00 2001
From: Will
Date: Mon, 21 Apr 2025 14:18:32 +0800
Subject: [PATCH 22/25] fix weird syntax error (#18454)
---
.../tools/utils/dataset_retriever/dataset_retriever_tool.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py b/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py
index f661294ec4..f5838c3b76 100644
--- a/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py
+++ b/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py
@@ -94,7 +94,7 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool):
"title": item.metadata.get("title"),
"content": item.page_content,
}
- context_list.append(source)
+ context_list.append(source)
for hit_callback in self.hit_callbacks:
hit_callback.return_retriever_resource_info(context_list)
From f191d372f0c90571c1e388e2d6fe6924b1657da4 Mon Sep 17 00:00:00 2001
From: Novice <857526207@qq.com>
Date: Mon, 21 Apr 2025 15:09:49 +0800
Subject: [PATCH 23/25] fix(promptMessage): correct field_serializer
implementation for content serialization (#18458)
---
.../model_runtime/entities/message_entities.py | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/api/core/model_runtime/entities/message_entities.py b/api/core/model_runtime/entities/message_entities.py
index 977678b893..3bed2460dd 100644
--- a/api/core/model_runtime/entities/message_entities.py
+++ b/api/core/model_runtime/entities/message_entities.py
@@ -1,8 +1,8 @@
from collections.abc import Sequence
from enum import Enum, StrEnum
-from typing import Optional
+from typing import Any, Optional, Union
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_serializer, field_validator
class PromptMessageRole(Enum):
@@ -135,6 +135,16 @@ class PromptMessage(BaseModel):
"""
return not self.content
+ @field_serializer("content")
+ def serialize_content(
+ self, content: Optional[Union[str, Sequence[PromptMessageContent]]]
+ ) -> Optional[str | list[dict[str, Any] | PromptMessageContent] | Sequence[PromptMessageContent]]:
+ if content is None or isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ return [item.model_dump() if hasattr(item, "model_dump") else item for item in content]
+ return content
+
class UserPromptMessage(PromptMessage):
"""
From 30c051d48553abf3c0c6793ab1277769e7338c90 Mon Sep 17 00:00:00 2001
From: doskoi <50610194+t-daisuke@users.noreply.github.com>
Date: Mon, 21 Apr 2025 18:56:31 +0900
Subject: [PATCH 24/25] =?UTF-8?q?fix:=20update=20Japanese=20translation=20?=
=?UTF-8?q?for=20'switchVersion'=20in=20plugin.ts=20to=20=E2=80=A6=20(#184?=
=?UTF-8?q?69)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/i18n/ja-JP/plugin.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts
index cb2a5f4f80..1d2f1a2fb5 100644
--- a/web/i18n/ja-JP/plugin.ts
+++ b/web/i18n/ja-JP/plugin.ts
@@ -81,7 +81,7 @@ const translation = {
endpointDeleteContent: '{{name}}を削除しますか?',
actionNum: '{{num}} {{action}} が含まれています',
endpointsDocLink: 'ドキュメントを表示する',
- switchVersion: 'スイッチ版',
+ switchVersion: 'バージョンの切り替え',
},
debugInfo: {
title: 'デバッグ',
From 7b6523e54dec4a181f5ffac57932a99e706a2e64 Mon Sep 17 00:00:00 2001
From: tmuife <43266626@qq.com>
Date: Mon, 21 Apr 2025 17:56:57 +0800
Subject: [PATCH 25/25] Update Oracle db connection library and change
connection pool to single connection (#18466)
---
.../rag/datasource/vdb/oracle/oraclevector.py | 176 ++++++++++--------
api/pyproject.toml | 2 +-
api/uv.lock | 26 +--
3 files changed, 117 insertions(+), 87 deletions(-)
diff --git a/api/core/rag/datasource/vdb/oracle/oraclevector.py b/api/core/rag/datasource/vdb/oracle/oraclevector.py
index 4af2578197..63695e6f3f 100644
--- a/api/core/rag/datasource/vdb/oracle/oraclevector.py
+++ b/api/core/rag/datasource/vdb/oracle/oraclevector.py
@@ -2,12 +2,12 @@ import array
import json
import re
import uuid
-from contextlib import contextmanager
from typing import Any
import jieba.posseg as pseg # type: ignore
import numpy
import oracledb
+from oracledb.connection import Connection
from pydantic import BaseModel, model_validator
from configs import dify_config
@@ -70,6 +70,7 @@ class OracleVector(BaseVector):
super().__init__(collection_name)
self.pool = self._create_connection_pool(config)
self.table_name = f"embedding_{collection_name}"
+ self.config = config
def get_type(self) -> str:
return VectorType.ORACLE
@@ -107,16 +108,19 @@ class OracleVector(BaseVector):
outconverter=self.numpy_converter_out,
)
+ def _get_connection(self) -> Connection:
+ connection = oracledb.connect(user=self.config.user, password=self.config.password, dsn=self.config.dsn)
+ return connection
+
def _create_connection_pool(self, config: OracleVectorConfig):
pool_params = {
"user": config.user,
"password": config.password,
"dsn": config.dsn,
"min": 1,
- "max": 50,
+ "max": 5,
"increment": 1,
}
-
if config.is_autonomous:
pool_params.update(
{
@@ -125,22 +129,8 @@ class OracleVector(BaseVector):
"wallet_password": config.wallet_password,
}
)
-
return oracledb.create_pool(**pool_params)
- @contextmanager
- def _get_cursor(self):
- conn = self.pool.acquire()
- conn.inputtypehandler = self.input_type_handler
- conn.outputtypehandler = self.output_type_handler
- cur = conn.cursor()
- try:
- yield cur
- finally:
- cur.close()
- conn.commit()
- conn.close()
-
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
dimension = len(embeddings[0])
self._create_collection(dimension)
@@ -162,41 +152,68 @@ class OracleVector(BaseVector):
numpy.array(embeddings[i]),
)
)
- # print(f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)")
- with self._get_cursor() as cur:
- cur.executemany(
- f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)", values
- )
+ with self._get_connection() as conn:
+ conn.inputtypehandler = self.input_type_handler
+ conn.outputtypehandler = self.output_type_handler
+ # with conn.cursor() as cur:
+ # cur.executemany(
+ # f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)", values
+ # )
+ # conn.commit()
+ for value in values:
+ with conn.cursor() as cur:
+ try:
+ cur.execute(
+ f"""INSERT INTO {self.table_name} (id, text, meta, embedding)
+ VALUES (:1, :2, :3, :4)""",
+ value,
+ )
+ conn.commit()
+ except Exception as e:
+ print(e)
+ conn.close()
return pks
def text_exists(self, id: str) -> bool:
- with self._get_cursor() as cur:
- cur.execute(f"SELECT id FROM {self.table_name} WHERE id = '%s'" % (id,))
- return cur.fetchone() is not None
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"SELECT id FROM {self.table_name} WHERE id = '%s'" % (id,))
+ return cur.fetchone() is not None
+ conn.close()
def get_by_ids(self, ids: list[str]) -> list[Document]:
- with self._get_cursor() as cur:
- cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN %s", (tuple(ids),))
- docs = []
- for record in cur:
- docs.append(Document(page_content=record[1], metadata=record[0]))
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN %s", (tuple(ids),))
+ docs = []
+ for record in cur:
+ docs.append(Document(page_content=record[1], metadata=record[0]))
+ self.pool.release(connection=conn)
+ conn.close()
return docs
def delete_by_ids(self, ids: list[str]) -> None:
if not ids:
return
- with self._get_cursor() as cur:
- cur.execute(f"DELETE FROM {self.table_name} WHERE id IN %s" % (tuple(ids),))
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"DELETE FROM {self.table_name} WHERE id IN %s" % (tuple(ids),))
+ conn.commit()
+ conn.close()
def delete_by_metadata_field(self, key: str, value: str) -> None:
- with self._get_cursor() as cur:
- cur.execute(f"DELETE FROM {self.table_name} WHERE meta->>%s = %s", (key, value))
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"DELETE FROM {self.table_name} WHERE meta->>%s = %s", (key, value))
+ conn.commit()
+ conn.close()
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
"""
Search the nearest neighbors to a vector.
:param query_vector: The input vector to search for similar items.
+ :param top_k: The number of nearest neighbors to return, default is 5.
:return: List of Documents that are nearest to the query vector.
"""
top_k = kwargs.get("top_k", 4)
@@ -205,20 +222,25 @@ class OracleVector(BaseVector):
if document_ids_filter:
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
where_clause = f"WHERE metadata->>'document_id' in ({document_ids})"
- with self._get_cursor() as cur:
- cur.execute(
- f"SELECT meta, text, vector_distance(embedding,:1) AS distance FROM {self.table_name}"
- f" {where_clause} ORDER BY distance fetch first {top_k} rows only",
- [numpy.array(query_vector)],
- )
- docs = []
- score_threshold = float(kwargs.get("score_threshold") or 0.0)
- for record in cur:
- metadata, text, distance = record
- score = 1 - distance
- metadata["score"] = score
- if score > score_threshold:
- docs.append(Document(page_content=text, metadata=metadata))
+ with self._get_connection() as conn:
+ conn.inputtypehandler = self.input_type_handler
+ conn.outputtypehandler = self.output_type_handler
+ with conn.cursor() as cur:
+ cur.execute(
+ f"""SELECT meta, text, vector_distance(embedding,(select to_vector(:1) from dual),cosine)
+ AS distance FROM {self.table_name}
+ {where_clause} ORDER BY distance fetch first {top_k} rows only""",
+ [numpy.array(query_vector)],
+ )
+ docs = []
+ score_threshold = float(kwargs.get("score_threshold") or 0.0)
+ for record in cur:
+ metadata, text, distance = record
+ score = 1 - distance
+ metadata["score"] = score
+ if score > score_threshold:
+ docs.append(Document(page_content=text, metadata=metadata))
+ conn.close()
return docs
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
@@ -228,7 +250,7 @@ class OracleVector(BaseVector):
top_k = kwargs.get("top_k", 5)
# just not implement fetch by score_threshold now, may be later
- # score_threshold = float(kwargs.get("score_threshold") or 0.0)
+ score_threshold = float(kwargs.get("score_threshold") or 0.0)
if len(query) > 0:
# Check which language the query is in
zh_pattern = re.compile("[\u4e00-\u9fa5]+")
@@ -239,7 +261,7 @@ class OracleVector(BaseVector):
words = pseg.cut(query)
current_entity = ""
for word, pos in words:
- if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名,ns: 地名,nt: 机构名
+ if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名, ns: 地名, nt: 机构名
current_entity += word
else:
if current_entity:
@@ -260,30 +282,35 @@ class OracleVector(BaseVector):
for token in all_tokens:
if token not in stop_words:
entities.append(token)
- with self._get_cursor() as cur:
- document_ids_filter = kwargs.get("document_ids_filter")
- where_clause = ""
- if document_ids_filter:
- document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
- where_clause = f" AND metadata->>'document_id' in ({document_ids}) "
- cur.execute(
- f"select meta, text, embedding FROM {self.table_name}"
- f"WHERE CONTAINS(text, :1, 1) > 0 {where_clause} "
- f"order by score(1) desc fetch first {top_k} rows only",
- [" ACCUM ".join(entities)],
- )
- docs = []
- for record in cur:
- metadata, text, embedding = record
- docs.append(Document(page_content=text, vector=embedding, metadata=metadata))
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ document_ids_filter = kwargs.get("document_ids_filter")
+ where_clause = ""
+ if document_ids_filter:
+ document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
+ where_clause = f" AND metadata->>'document_id' in ({document_ids}) "
+ cur.execute(
+ f"""select meta, text, embedding FROM {self.table_name}
+ WHERE CONTAINS(text, :kk, 1) > 0 {where_clause}
+ order by score(1) desc fetch first {top_k} rows only""",
+ kk=" ACCUM ".join(entities),
+ )
+ docs = []
+ for record in cur:
+ metadata, text, embedding = record
+ docs.append(Document(page_content=text, vector=embedding, metadata=metadata))
+ conn.close()
return docs
else:
return [Document(page_content="", metadata={})]
return []
def delete(self) -> None:
- with self._get_cursor() as cur:
- cur.execute(f"DROP TABLE IF EXISTS {self.table_name} cascade constraints")
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"DROP TABLE IF EXISTS {self.table_name} cascade constraints")
+ conn.commit()
+ conn.close()
def _create_collection(self, dimension: int):
cache_key = f"vector_indexing_{self._collection_name}"
@@ -293,11 +320,14 @@ class OracleVector(BaseVector):
if redis_client.get(collection_exist_cache_key):
return
- with self._get_cursor() as cur:
- cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name))
- redis_client.set(collection_exist_cache_key, 1, ex=3600)
- with self._get_cursor() as cur:
- cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
+ with self._get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name))
+ redis_client.set(collection_exist_cache_key, 1, ex=3600)
+ with conn.cursor() as cur:
+ cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
+ conn.commit()
+ conn.close()
class OracleVectorFactory(AbstractVectorFactory):
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 08f9c1e229..4992178423 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -178,7 +178,7 @@ vdb = [
"couchbase~=4.3.0",
"elasticsearch==8.14.0",
"opensearch-py==2.4.0",
- "oracledb~=2.2.1",
+ "oracledb==3.0.0",
"pgvecto-rs[sqlalchemy]~=0.2.1",
"pgvector==0.2.5",
"pymilvus~=2.5.0",
diff --git a/api/uv.lock b/api/uv.lock
index 4384e1abb5..6c8699dd7c 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -1471,7 +1471,7 @@ vdb = [
{ name = "couchbase", specifier = "~=4.3.0" },
{ name = "elasticsearch", specifier = "==8.14.0" },
{ name = "opensearch-py", specifier = "==2.4.0" },
- { name = "oracledb", specifier = "~=2.2.1" },
+ { name = "oracledb", specifier = "==3.0.0" },
{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" },
{ name = "pgvector", specifier = "==0.2.5" },
{ name = "pymilvus", specifier = "~=2.5.0" },
@@ -3600,23 +3600,23 @@ wheels = [
[[package]]
name = "oracledb"
-version = "2.2.1"
+version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/36/fb/3fbacb351833dd794abb184303a5761c4bb33df9d770fd15d01ead2ff738/oracledb-2.2.1.tar.gz", hash = "sha256:8464c6f0295f3318daf6c2c72c83c2dcbc37e13f8fd44e3e39ff8665f442d6b6", size = 580818 }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/39/712f797b75705c21148fa1d98651f63c2e5cc6876e509a0a9e2f5b406572/oracledb-3.0.0.tar.gz", hash = "sha256:64dc86ee5c032febc556798b06e7b000ef6828bb0252084f6addacad3363db85", size = 840431 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/74/b7/a4238295944670fb8cc50a8cc082e0af5a0440bfb1c2bac2b18429c0a579/oracledb-2.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fb6d9a4d7400398b22edb9431334f9add884dec9877fd9c4ae531e1ccc6ee1fd", size = 3551303 },
- { url = "https://files.pythonhosted.org/packages/4f/5f/98481d44976cd2b3086361f2d50026066b24090b0e6cd1f2a12c824e9717/oracledb-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07757c240afbb4f28112a6affc2c5e4e34b8a92e5bb9af81a40fba398da2b028", size = 12258455 },
- { url = "https://files.pythonhosted.org/packages/e9/54/06b2540286e2b63f60877d6f3c6c40747e216b6eeda0756260e194897076/oracledb-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63daec72f853c47179e98493e9b732909d96d495bdceb521c5973a3940d28142", size = 12317476 },
- { url = "https://files.pythonhosted.org/packages/4d/1a/67814439a4e24df83281a72cb0ba433d6b74e1bff52a9975b87a725bcba5/oracledb-2.2.1-cp311-cp311-win32.whl", hash = "sha256:fec5318d1e0ada7e4674574cb6c8d1665398e8b9c02982279107212f05df1660", size = 1369368 },
- { url = "https://files.pythonhosted.org/packages/e3/b8/b2a8f0607be17f58ec6689ad5fd15c2956f4996c64547325e96439570edf/oracledb-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:5134dccb5a11bc755abf02fd49be6dc8141dfcae4b650b55d40509323d00b5c2", size = 1655035 },
- { url = "https://files.pythonhosted.org/packages/24/5b/2fff762243030f31a6b1561fc8eeb142e69ba6ebd3e7fbe4a2c82f0eb6f0/oracledb-2.2.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ac5716bc9a48247fdf563f5f4ec097f5c9f074a60fd130cdfe16699208ca29b5", size = 3583960 },
- { url = "https://files.pythonhosted.org/packages/e6/88/34117ae830e7338af7c0481f1c0fc6eda44d558e12f9203b45b491e53071/oracledb-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c150bddb882b7c73fb462aa2d698744da76c363e404570ed11d05b65811d96c3", size = 11749006 },
- { url = "https://files.pythonhosted.org/packages/9d/58/bac788f18c21f727955652fe238de2d24a12c2b455ed4db18a6d23ff781e/oracledb-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193e1888411bc21187ade4b16b76820bd1e8f216e25602f6cd0a97d45723c1dc", size = 11950663 },
- { url = "https://files.pythonhosted.org/packages/3b/e2/005f66ae919c6f7c73e06863256cf43aa844330e2dc61a5f9779ae44a801/oracledb-2.2.1-cp312-cp312-win32.whl", hash = "sha256:44a960f8bbb0711af222e0a9690e037b6a2a382e0559ae8eeb9cfafe26c7a3bc", size = 1324255 },
- { url = "https://files.pythonhosted.org/packages/e6/25/759eb2143134513382e66d874c4aacfd691dec3fef7141170cfa6c1b154f/oracledb-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:470136add32f0d0084225c793f12a52b61b52c3dc00c9cd388ec6a3db3a7643e", size = 1613047 },
+ { url = "https://files.pythonhosted.org/packages/fa/bf/d872c4b3fc15cd3261fe0ea72b21d181700c92dbc050160e161654987062/oracledb-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:52daa9141c63dfa75c07d445e9bb7f69f43bfb3c5a173ecc48c798fe50288d26", size = 4312963 },
+ { url = "https://files.pythonhosted.org/packages/b1/ea/01ee29e76a610a53bb34fdc1030f04b7669c3f80b25f661e07850fc6160e/oracledb-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af98941789df4c6aaaf4338f5b5f6b7f2c8c3fe6f8d6a9382f177f350868747a", size = 2661536 },
+ { url = "https://files.pythonhosted.org/packages/3d/8e/ad380e34a46819224423b4773e58c350bc6269643c8969604097ced8c3bc/oracledb-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9812bb48865aaec35d73af54cd1746679f2a8a13cbd1412ab371aba2e39b3943", size = 2867461 },
+ { url = "https://files.pythonhosted.org/packages/96/09/ecc4384a27fd6e1e4de824ae9c160e4ad3aaebdaade5b4bdcf56a4d1ff63/oracledb-3.0.0-cp311-cp311-win32.whl", hash = "sha256:6c27fe0de64f2652e949eb05b3baa94df9b981a4a45fa7f8a991e1afb450c8e2", size = 1752046 },
+ { url = "https://files.pythonhosted.org/packages/62/e8/f34bde24050c6e55eeba46b23b2291f2dd7fd272fa8b322dcbe71be55778/oracledb-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:f922709672002f0b40997456f03a95f03e5712a86c61159951c5ce09334325e0", size = 2101210 },
+ { url = "https://files.pythonhosted.org/packages/6f/fc/24590c3a3d41e58494bd3c3b447a62835138e5f9b243d9f8da0cfb5da8dc/oracledb-3.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:acd0e747227dea01bebe627b07e958bf36588a337539f24db629dc3431d3f7eb", size = 4351993 },
+ { url = "https://files.pythonhosted.org/packages/b7/b6/1f3b0b7bb94d53e8857d77b2e8dbdf6da091dd7e377523e24b79dac4fd71/oracledb-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8b402f77c22af031cd0051aea2472ecd0635c1b452998f511aa08b7350c90a4", size = 2532640 },
+ { url = "https://files.pythonhosted.org/packages/72/1a/1815f6c086ab49c00921cf155ff5eede5267fb29fcec37cb246339a5ce4d/oracledb-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:378a27782e9a37918bd07a5a1427a77cb6f777d0a5a8eac9c070d786f50120ef", size = 2765949 },
+ { url = "https://files.pythonhosted.org/packages/33/8d/208900f8d372909792ee70b2daad3f7361181e55f2217c45ed9dff658b54/oracledb-3.0.0-cp312-cp312-win32.whl", hash = "sha256:54a28c2cb08316a527cd1467740a63771cc1c1164697c932aa834c0967dc4efc", size = 1709373 },
+ { url = "https://files.pythonhosted.org/packages/0c/5e/c21754f19c896102793c3afec2277e2180aa7d505e4d7fcca24b52d14e4f/oracledb-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8289bad6d103ce42b140e40576cf0c81633e344d56e2d738b539341eacf65624", size = 2056452 },
]
[[package]]