Merge branch 'fix/workflow-start-form-default-value' into deploy/dev
Some checks are pending
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions

This commit is contained in:
StyleZhang 2024-11-11 14:07:35 +08:00
commit 653542197b
21 changed files with 248 additions and 58 deletions

View File

@ -0,0 +1,3 @@
from .code_executor import CodeExecutor, CodeLanguage
__all__ = ["CodeExecutor", "CodeLanguage"]

View File

@ -1,7 +1,8 @@
import logging
from collections.abc import Mapping
from enum import Enum
from threading import Lock
from typing import Optional
from typing import Any, Optional
from httpx import Timeout, post
from pydantic import BaseModel
@ -117,7 +118,7 @@ class CodeExecutor:
return response.data.stdout or ""
@classmethod
def execute_workflow_code_template(cls, language: CodeLanguage, code: str, inputs: dict) -> dict:
def execute_workflow_code_template(cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]) -> dict:
"""
Execute code
:param language: code language

View File

@ -2,6 +2,8 @@ import json
import re
from abc import ABC, abstractmethod
from base64 import b64encode
from collections.abc import Mapping
from typing import Any
class TemplateTransformer(ABC):
@ -10,7 +12,7 @@ class TemplateTransformer(ABC):
_result_tag: str = "<<RESULT>>"
@classmethod
def transform_caller(cls, code: str, inputs: dict) -> tuple[str, str]:
def transform_caller(cls, code: str, inputs: Mapping[str, Any]) -> tuple[str, str]:
"""
Transform code to python runner
:param code: code
@ -48,13 +50,13 @@ class TemplateTransformer(ABC):
pass
@classmethod
def serialize_inputs(cls, inputs: dict) -> str:
def serialize_inputs(cls, inputs: Mapping[str, Any]) -> str:
inputs_json_str = json.dumps(inputs, ensure_ascii=False).encode()
input_base64_encoded = b64encode(inputs_json_str).decode("utf-8")
return input_base64_encoded
@classmethod
def assemble_runner_script(cls, code: str, inputs: dict) -> str:
def assemble_runner_script(cls, code: str, inputs: Mapping[str, Any]) -> str:
# assemble runner script
script = cls.get_runner_script()
script = script.replace(cls._code_placeholder, code)

View File

@ -55,6 +55,7 @@ class JinaRerankModel(RerankModel):
base_url + "/rerank",
json={"model": model, "query": query, "documents": docs, "top_n": top_n},
headers={"Authorization": f"Bearer {credentials.get('api_key')}"},
timeout=20,
)
response.raise_for_status()
results = response.json()

View File

@ -32,3 +32,15 @@ credentials_for_provider:
placeholder:
en_US: Enter your TTS service API key
zh_Hans: 输入您的 TTS 服务 API 密钥
openai_base_url:
type: text-input
required: false
label:
en_US: OpenAI base URL
zh_Hans: OpenAI base URL
help:
en_US: Please input your OpenAI base URL
zh_Hans: 请输入你的 OpenAI base URL
placeholder:
en_US: Please input your OpenAI base URL
zh_Hans: 请输入你的 OpenAI base URL

View File

@ -5,6 +5,7 @@ import warnings
from typing import Any, Literal, Optional, Union
import openai
from yarl import URL
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.errors import ToolParameterValidationError, ToolProviderCredentialValidationError
@ -53,15 +54,24 @@ class PodcastAudioGeneratorTool(BuiltinTool):
if not host1_voice or not host2_voice:
raise ToolParameterValidationError("Host voices are required")
# Get OpenAI API key from credentials
# Ensure runtime and credentials
if not self.runtime or not self.runtime.credentials:
raise ToolProviderCredentialValidationError("Tool runtime or credentials are missing")
# Get OpenAI API key from credentials
api_key = self.runtime.credentials.get("api_key")
if not api_key:
raise ToolProviderCredentialValidationError("OpenAI API key is missing")
# Get OpenAI base URL
openai_base_url = self.runtime.credentials.get("openai_base_url", None)
openai_base_url = str(URL(openai_base_url) / "v1") if openai_base_url else None
# Initialize OpenAI client
client = openai.OpenAI(api_key=api_key)
client = openai.OpenAI(
api_key=api_key,
base_url=openai_base_url,
)
# Create a thread pool
max_workers = 5

View File

@ -49,8 +49,14 @@ class Limit(BaseModel):
size: int = -1
class ExtractConfig(BaseModel):
enabled: bool = False
serial: str = "1"
class ListOperatorNodeData(BaseNodeData):
variable: Sequence[str] = Field(default_factory=list)
filter_by: FilterBy
order_by: OrderBy
limit: Limit
extract_by: ExtractConfig

View File

@ -58,6 +58,10 @@ class ListOperatorNode(BaseNode[ListOperatorNodeData]):
if self.node_data.filter_by.enabled:
variable = self._apply_filter(variable)
# Extract
if self.node_data.extract_by.enabled:
variable = self._extract_slice(variable)
# Order
if self.node_data.order_by.enabled:
variable = self._apply_order(variable)
@ -140,6 +144,16 @@ class ListOperatorNode(BaseNode[ListOperatorNodeData]):
result = variable.value[: self.node_data.limit.size]
return variable.model_copy(update={"value": result})
def _extract_slice(
self, variable: Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment]
) -> Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment]:
value = int(self.graph_runtime_state.variable_pool.convert_template(self.node_data.extract_by.serial).text) - 1
if len(variable.value) > int(value):
result = variable.value[value]
else:
result = ""
return variable.model_copy(update={"value": [result]})
def _get_file_extract_number_func(*, key: str) -> Callable[[File], int]:
match key:

View File

@ -4,7 +4,14 @@ import pytest
from core.file import File, FileTransferMethod, FileType
from core.variables import ArrayFileSegment
from core.workflow.nodes.list_operator.entities import FilterBy, FilterCondition, Limit, ListOperatorNodeData, OrderBy
from core.workflow.nodes.list_operator.entities import (
ExtractConfig,
FilterBy,
FilterCondition,
Limit,
ListOperatorNodeData,
OrderBy,
)
from core.workflow.nodes.list_operator.exc import InvalidKeyError
from core.workflow.nodes.list_operator.node import ListOperatorNode, _get_file_extract_string_func
from models.workflow import WorkflowNodeExecutionStatus
@ -22,6 +29,7 @@ def list_operator_node():
),
"order_by": OrderBy(enabled=False, value="asc"),
"limit": Limit(enabled=False, size=0),
"extract_by": ExtractConfig(enabled=False, serial="1"),
"title": "Test Title",
}
node_data = ListOperatorNodeData(**config)

View File

@ -173,7 +173,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || ''
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])

View File

@ -159,7 +159,7 @@ export const useEmbeddedChatbot = () => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || ''
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])

View File

@ -1,3 +1,4 @@
import React, { useEffect, useState } from 'react'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@ -32,20 +33,31 @@ const MarkdownForm = ({ node }: any) => {
// </form>
const { onSend } = useChatContext()
const getFormValues = (children: any) => {
const formValues: { [key: string]: any } = {}
children.forEach((child: any) => {
if (child.tagName === SUPPORTED_TAGS.INPUT)
formValues[child.properties.name] = child.properties.value
if (child.tagName === SUPPORTED_TAGS.TEXTAREA)
formValues[child.properties.name] = child.properties.value
const [formValues, setFormValues] = useState<{ [key: string]: any }>({})
useEffect(() => {
const initialValues: { [key: string]: any } = {}
node.children.forEach((child: any) => {
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName))
initialValues[child.properties.name] = child.properties.value
})
return formValues
setFormValues(initialValues)
}, [node.children])
const getFormValues = (children: any) => {
const values: { [key: string]: any } = {}
children.forEach((child: any) => {
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName))
values[child.properties.name] = formValues[child.properties.name]
})
return values
}
const onSubmit = (e: any) => {
e.preventDefault()
const format = node.properties.dataFormat || DATA_FORMAT.TEXT
const result = getFormValues(node.children)
if (format === DATA_FORMAT.JSON) {
onSend?.(JSON.stringify(result))
}
@ -77,36 +89,35 @@ const MarkdownForm = ({ node }: any) => {
</label>
)
}
if (child.tagName === SUPPORTED_TAGS.INPUT) {
if (Object.values(SUPPORTED_TYPES).includes(child.properties.type)) {
if (child.tagName === SUPPORTED_TAGS.INPUT && Object.values(SUPPORTED_TYPES).includes(child.properties.type)) {
return (
<Input
key={index}
type={child.properties.type}
name={child.properties.name}
placeholder={child.properties.placeholder}
value={child.properties.value}
value={formValues[child.properties.name]}
onChange={(e) => {
e.preventDefault()
child.properties.value = e.target.value
setFormValues(prevValues => ({
...prevValues,
[child.properties.name]: e.target.value,
}))
}}
/>
)
}
else {
return <p key={index}>Unsupported input type: {child.properties.type}</p>
}
}
if (child.tagName === SUPPORTED_TAGS.TEXTAREA) {
return (
<Textarea
key={index}
name={child.properties.name}
placeholder={child.properties.placeholder}
value={child.properties.value}
value={formValues[child.properties.name]}
onChange={(e) => {
e.preventDefault()
child.properties.value = e.target.value
setFormValues(prevValues => ({
...prevValues,
[child.properties.name]: e.target.value,
}))
}}
/>
)

View File

@ -15,7 +15,6 @@ import Category from './category'
import Tools from './tools'
import cn from '@/utils/classnames'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import Drawer from '@/app/components/base/drawer'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
@ -44,13 +43,15 @@ const AddToolModal: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [currentType, setCurrentType] = useState('builtin')
const [currentCategory, setCurrentCategory] = useState('')
const [keywords, setKeywords] = useState<string>('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const isMatchingKeywords = (text: string, keywords: string) => {
return text.toLowerCase().includes(keywords.toLowerCase())
}
const [toolList, setToolList] = useState<ToolWithProvider[]>([])
const [listLoading, setListLoading] = useState(true)
const getAllTools = async () => {
@ -82,13 +83,16 @@ const AddToolModal: FC<Props> = ({
else
return toolWithProvider.labels.includes(currentCategory)
}).filter((toolWithProvider) => {
return toolWithProvider.tools.some((tool) => {
return (
isMatchingKeywords(toolWithProvider.name, keywords)
|| toolWithProvider.tools.some((tool) => {
return Object.values(tool.label).some((label) => {
return label.toLowerCase().includes(keywords.toLowerCase())
return isMatchingKeywords(label, keywords)
})
})
)
})
}, [currentType, currentCategory, toolList, keywords, language])
}, [currentType, currentCategory, toolList, keywords])
const {
modelConfig,

View File

@ -11,7 +11,6 @@ import { ToolTypeEnum } from './types'
import Tools from './tools'
import { useToolTabs } from './hooks'
import cn from '@/utils/classnames'
import { useGetLanguage } from '@/context/i18n'
type AllToolsProps = {
searchText: string
@ -21,13 +20,16 @@ const AllTools = ({
searchText,
onSelect,
}: AllToolsProps) => {
const language = useGetLanguage()
const tabs = useToolTabs()
const [activeTab, setActiveTab] = useState(ToolTypeEnum.All)
const buildInTools = useStore(s => s.buildInTools)
const customTools = useStore(s => s.customTools)
const workflowTools = useStore(s => s.workflowTools)
const isMatchingKeywords = (text: string, keywords: string) => {
return text.toLowerCase().includes(keywords.toLowerCase())
}
const tools = useMemo(() => {
let mergedTools: ToolWithProvider[] = []
if (activeTab === ToolTypeEnum.All)
@ -40,11 +42,14 @@ const AllTools = ({
mergedTools = workflowTools
return mergedTools.filter((toolWithProvider) => {
return toolWithProvider.tools.some((tool) => {
return tool.label[language].toLowerCase().includes(searchText.toLowerCase())
return isMatchingKeywords(toolWithProvider.name, searchText)
|| toolWithProvider.tools.some((tool) => {
return Object.values(tool.label).some((label) => {
return isMatchingKeywords(label, searchText)
})
})
}, [activeTab, buildInTools, customTools, workflowTools, searchText, language])
})
}, [activeTab, buildInTools, customTools, workflowTools, searchText])
return (
<div>
<div className='flex items-center px-3 h-8 space-x-1 bg-gray-25 border-b-[0.5px] border-black/[0.08] shadow-xs'>

View File

@ -0,0 +1,51 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { VarType } from '../../../types'
import type { Var } from '../../../types'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import cn from '@/utils/classnames'
import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var'
type Props = {
nodeId: string
readOnly: boolean
value: string
onChange: (value: string) => void
}
const ExtractInput: FC<Props> = ({
nodeId,
readOnly,
value,
onChange,
}) => {
const { t } = useTranslation()
const [isFocus, setIsFocus] = useState(false)
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
filterVar: (varPayload: Var) => {
return [VarType.number].includes(varPayload.type)
},
})
return (
<div className='flex items-start space-x-1'>
<Input
instanceId='http-extract-number'
className={cn(isFocus ? 'shadow-xs bg-gray-50 border-gray-300' : 'bg-gray-100 border-gray-100', 'w-0 grow rounded-lg px-3 py-[6px] border')}
value={value}
onChange={onChange}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={setIsFocus}
placeholder={!readOnly ? t('workflow.nodes.http.extractListPlaceholder')! : ''}
placeholderClassName='!leading-[21px]'
/>
</div >
)
}
export default React.memo(ExtractInput)

View File

@ -12,6 +12,10 @@ const nodeDefault: NodeDefault<ListFilterNodeType> = {
enabled: false,
conditions: [],
},
extract_by: {
enabled: false,
serial: '1',
},
order_by: {
enabled: false,
key: '',

View File

@ -13,6 +13,7 @@ import FilterCondition from './components/filter-condition'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { type NodePanelProps } from '@/app/components/workflow/types'
import Switch from '@/app/components/base/switch'
import ExtractInput from '@/app/components/workflow/nodes/list-operator/components/extract-input'
const i18nPrefix = 'workflow.nodes.listFilter'
@ -32,6 +33,8 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({
filterVar,
handleFilterEnabledChange,
handleFilterChange,
handleExtractsEnabledChange,
handleExtractsChange,
handleLimitChange,
handleOrderByEnabledChange,
handleOrderByKeyChange,
@ -79,6 +82,41 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({
: null}
</Field>
<Split />
<Field
title={t(`${i18nPrefix}.extractsCondition`)}
operations={
<Switch
defaultValue={inputs.extract_by?.enabled}
onChange={handleExtractsEnabledChange}
size='md'
disabled={readOnly}
/>
}
>
{inputs.extract_by?.enabled
? (
<div className='flex items-center justify-between'>
{hasSubVariable && (
<div className='grow mr-2'>
<ExtractInput
value={inputs.extract_by.serial as string}
onChange={handleExtractsChange}
readOnly={readOnly}
nodeId={id}
/>
</div>
)}
</div>
)
: null}
</Field>
<Split />
<LimitConfig
config={inputs.limit}
onChange={handleLimitChange}
readonly={readOnly}
/>
<Split />
<Field
title={t(`${i18nPrefix}.orderBy`)}
operations={
@ -118,13 +156,7 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({
: null}
</Field>
<Split />
<LimitConfig
config={inputs.limit}
onChange={handleLimitChange}
readonly={readOnly}
/>
</div>
<Split />
<div className='px-4 pt-4 pb-2'>
<OutputVars>
<>

View File

@ -25,6 +25,10 @@ export type ListFilterNodeType = CommonNodeType & {
enabled: boolean
conditions: Condition[]
}
extract_by: {
enabled: boolean
serial?: string
}
order_by: {
enabled: boolean
key: ValueSelector | string

View File

@ -119,6 +119,22 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
const handleExtractsEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.extract_by.enabled = enabled
if (enabled)
draft.extract_by.serial = '1'
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleExtractsChange = useCallback((value: string) => {
const newInputs = produce(inputs, (draft) => {
draft.extract_by.serial = value
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleOrderByEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.enabled = enabled
@ -162,6 +178,8 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
handleOrderByEnabledChange,
handleOrderByKeyChange,
handleOrderByTypeChange,
handleExtractsEnabledChange,
handleExtractsChange,
}
}

View File

@ -369,6 +369,7 @@ const translation = {
inputVars: 'Input Variables',
api: 'API',
apiPlaceholder: 'Enter URL, type / insert variable',
extractListPlaceholder: 'Enter list item index, type / insert variable',
notStartWithHttp: 'API should start with http:// or https://',
key: 'Key',
type: 'Type',
@ -605,6 +606,7 @@ const translation = {
inputVar: 'Input Variable',
filterCondition: 'Filter Condition',
filterConditionKey: 'Filter Condition Key',
extractsCondition: 'Extract the N item',
filterConditionComparisonOperator: 'Filter Condition Comparison Operator',
filterConditionComparisonValue: 'Filter Condition value',
selectVariableKeyPlaceholder: 'Select sub variable key',

View File

@ -369,6 +369,7 @@ const translation = {
inputVars: '输入变量',
api: 'API',
apiPlaceholder: '输入 URL输入变量时请键入/',
extractListPlaceholder: '输入提取列表编号,输入变量时请键入‘/',
notStartWithHttp: 'API 应该以 http:// 或 https:// 开头',
key: '键',
type: '类型',
@ -608,6 +609,7 @@ const translation = {
filterConditionComparisonOperator: '过滤条件比较操作符',
filterConditionComparisonValue: '过滤条件比较值',
selectVariableKeyPlaceholder: '选择子变量的 Key',
extractsCondition: '取第 N 项',
limit: '取前 N 项',
orderBy: '排序',
asc: '升序',