This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import {
RiAddLine,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
type Props = {
className?: string
text: string
onClick: () => void
}
const AddButton: FC<Props> = ({
className,
text,
onClick,
}) => {
return (
<Button
className={cn('w-full', className)}
variant='tertiary'
size='medium'
onClick={onClick}
>
<RiAddLine className='mr-1 h-3.5 w-3.5' />
<div>{text}</div>
</Button>
)
}
export default React.memo(AddButton)

View File

@@ -0,0 +1,130 @@
import {
memo,
useCallback,
useMemo,
useRef,
} from 'react'
import { useClickAway } from 'ahooks'
import { useStore } from '../../../store'
import {
useIsChatMode,
useNodeDataUpdate,
useWorkflow,
useWorkflowVariables,
} from '../../../hooks'
import type {
ValueSelector,
Var,
VarType,
} from '../../../types'
import { useVariableAssigner } from '../../variable-assigner/hooks'
import { filterVar } from '../../variable-assigner/utils'
import AddVariablePopup from './add-variable-popup'
type AddVariablePopupWithPositionProps = {
nodeId: string
nodeData: any
}
const AddVariablePopupWithPosition = ({
nodeId,
nodeData,
}: AddVariablePopupWithPositionProps) => {
const ref = useRef<HTMLDivElement>(null)
const showAssignVariablePopup = useStore(s => s.showAssignVariablePopup)
const setShowAssignVariablePopup = useStore(s => s.setShowAssignVariablePopup)
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleAddVariableInAddVariablePopupWithPosition } = useVariableAssigner()
const isChatMode = useIsChatMode()
const { getBeforeNodesInSameBranch } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const outputType = useMemo(() => {
if (!showAssignVariablePopup)
return ''
const groupEnabled = showAssignVariablePopup.variableAssignerNodeData.advanced_settings?.group_enabled
if (!groupEnabled)
return showAssignVariablePopup.variableAssignerNodeData.output_type
const group = showAssignVariablePopup.variableAssignerNodeData.advanced_settings?.groups.find(group => group.groupId === showAssignVariablePopup.variableAssignerNodeHandleId)
return group?.output_type || ''
}, [showAssignVariablePopup])
const availableVars = useMemo(() => {
if (!showAssignVariablePopup)
return []
return getNodeAvailableVars({
parentNode: showAssignVariablePopup.parentNode,
beforeNodes: [
...getBeforeNodesInSameBranch(showAssignVariablePopup.nodeId),
{
id: showAssignVariablePopup.nodeId,
data: showAssignVariablePopup.nodeData,
} as any,
],
hideEnv: true,
hideChatVar: !isChatMode,
isChatMode,
filterVar: filterVar(outputType as VarType),
})
.map(node => ({
...node,
vars: node.isStartNode ? node.vars.filter(v => !v.variable.startsWith('sys.')) : node.vars,
}))
.filter(item => item.vars.length > 0)
}, [showAssignVariablePopup, getNodeAvailableVars, getBeforeNodesInSameBranch, isChatMode, outputType])
useClickAway(() => {
if (nodeData._holdAddVariablePopup) {
handleNodeDataUpdate({
id: nodeId,
data: {
_holdAddVariablePopup: false,
},
})
}
else {
handleNodeDataUpdate({
id: nodeId,
data: {
_showAddVariablePopup: false,
},
})
setShowAssignVariablePopup(undefined)
}
}, ref)
const handleAddVariable = useCallback((value: ValueSelector, varDetail: Var) => {
if (showAssignVariablePopup) {
handleAddVariableInAddVariablePopupWithPosition(
showAssignVariablePopup.nodeId,
showAssignVariablePopup.variableAssignerNodeId,
showAssignVariablePopup.variableAssignerNodeHandleId,
value,
varDetail,
)
}
}, [showAssignVariablePopup, handleAddVariableInAddVariablePopupWithPosition])
if (!showAssignVariablePopup)
return null
return (
<div
className='absolute z-10'
style={{
left: showAssignVariablePopup.x,
top: showAssignVariablePopup.y,
}}
ref={ref}
>
<AddVariablePopup
availableVars={availableVars}
onSelect={handleAddVariable}
/>
</div>
)
}
export default memo(AddVariablePopupWithPosition)

View File

@@ -0,0 +1,37 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
export type AddVariablePopupProps = {
availableVars: NodeOutPutVar[]
onSelect: (value: ValueSelector, item: Var) => void
}
export const AddVariablePopup = ({
availableVars,
onSelect,
}: AddVariablePopupProps) => {
const { t } = useTranslation()
return (
<div className='w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex h-[34px] items-center border-b-[0.5px] border-b-divider-regular px-4 text-[13px] font-semibold text-text-secondary'>
{t('workflow.nodes.variableAssigner.setAssignVariable')}
</div>
<div className='p-1'>
<VarReferenceVars
hideSearch
vars={availableVars}
onChange={onSelect}
isSupportFileVar
/>
</div>
</div>
)
}
export default memo(AddVariablePopup)

View File

@@ -0,0 +1,242 @@
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import type { ReactNode } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import type { Strategy } from './agent-strategy'
import classNames from '@/utils/classnames'
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import Link from 'next/link'
import { InstallPluginButton } from './install-plugin-button'
import ViewTypeSelect, { ViewType } from '../../../block-selector/view-type-select'
import SearchInput from '@/app/components/base/search-input'
import Tools from '../../../block-selector/tools'
import { useTranslation } from 'react-i18next'
import { useStrategyProviders } from '@/service/use-strategy'
import { PluginCategoryEnum, type StrategyPluginDetail } from '@/app/components/plugins/types'
import type { ToolWithProvider } from '../../../types'
import { CollectionType } from '@/app/components/tools/types'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useStrategyInfo } from '../../agent/use-config'
import { SwitchPluginVersion } from './switch-plugin-version'
import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { ToolTipContent } from '@/app/components/base/tooltip/content'
import { useGlobalPublicStore } from '@/context/global-public-context'
const DEFAULT_TAGS: ListProps['tags'] = []
const NotFoundWarn = (props: {
title: ReactNode,
description: ReactNode
}) => {
const { title, description } = props
const { t } = useTranslation()
return <Tooltip
popupContent={
<div className='space-y-1 text-xs'>
<h3 className='font-semibold text-text-primary'>
{title}
</h3>
<p className='tracking-tight text-text-secondary'>
{description}
</p>
<p>
<Link href={'/plugins'} className='tracking-tight text-text-accent'>
{t('workflow.nodes.agent.linkToPlugin')}
</Link>
</p>
</div>
}
>
<div>
<RiErrorWarningFill className='size-4 text-text-destructive' />
</div>
</Tooltip>
}
function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => string): ToolWithProvider[] {
return input.map((item) => {
const res: ToolWithProvider = {
id: item.plugin_unique_identifier,
author: item.declaration.identity.author,
name: item.declaration.identity.name,
description: item.declaration.identity.description as any,
plugin_id: item.plugin_id,
icon: getIcon(item.declaration.identity.icon),
label: item.declaration.identity.label as any,
type: CollectionType.all,
meta: item.meta,
tools: item.declaration.strategies.map(strategy => ({
name: strategy.identity.name,
author: strategy.identity.author,
label: strategy.identity.label as any,
description: strategy.description,
parameters: strategy.parameters as any,
output_schema: strategy.output_schema,
labels: [],
})),
team_credentials: {},
is_team_authorization: true,
allow_delete: false,
labels: [],
}
return res
})
}
export type AgentStrategySelectorProps = {
value?: Strategy,
onChange: (value?: Strategy) => void,
canChooseMCPTool: boolean,
}
export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { value, onChange, canChooseMCPTool } = props
const [open, setOpen] = useState(false)
const [viewType, setViewType] = useState<ViewType>(ViewType.flat)
const [query, setQuery] = useState('')
const stra = useStrategyProviders()
const { getIconUrl } = useGetIcon()
const list = stra.data ? formatStrategy(stra.data, getIconUrl) : undefined
const filteredTools = useMemo(() => {
if (!list) return []
return list.filter(tool => tool.name.toLowerCase().includes(query.toLowerCase()))
}, [query, list])
const { strategyStatus, refetch: refetchStrategyInfo } = useStrategyInfo(
value?.agent_strategy_provider_name,
value?.agent_strategy_name,
)
const showPluginNotInstalledWarn = strategyStatus?.plugin?.source === 'external'
&& !strategyStatus.plugin.installed && !!value
const showUnsupportedStrategy = strategyStatus?.plugin.source === 'external'
&& !strategyStatus?.isExistInPlugin && !!value
const showSwitchVersion = !strategyStatus?.isExistInPlugin
&& strategyStatus?.plugin.source === 'marketplace' && strategyStatus.plugin.installed && !!value
const showInstallButton = !strategyStatus?.isExistInPlugin
&& strategyStatus?.plugin.source === 'marketplace' && !strategyStatus.plugin.installed && !!value
const icon = list?.find(
coll => coll.tools?.find(tool => tool.name === value?.agent_strategy_name),
)?.icon as string | undefined
const { t } = useTranslation()
const wrapElemRef = useRef<HTMLDivElement>(null)
const {
queryPluginsWithDebounced: fetchPlugins,
plugins: notInstalledPlugins = [],
} = useMarketplacePlugins()
useEffect(() => {
if (!enable_marketplace) return
if (query) {
fetchPlugins({
query,
category: PluginCategoryEnum.agent,
})
}
}, [query])
const pluginRef = useRef<ListRef>(null)
return <PortalToFollowElem open={open} onOpenChange={setOpen} placement='bottom'>
<PortalToFollowElemTrigger className='w-full'>
<div
className='flex h-8 w-full select-none items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-state-base-hover-alt'
onClick={() => setOpen(o => !o)}
>
{ }
{icon && <div className='flex h-6 w-6 items-center justify-center'><img
src={icon}
width={20}
height={20}
className='rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
alt='icon'
/></div>}
<p
className={classNames(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
>
{value?.agent_strategy_label || t('workflow.nodes.agent.strategy.selectTip')}
</p>
<div className='ml-auto flex items-center gap-1'>
{showInstallButton && value && <InstallPluginButton
onClick={e => e.stopPropagation()}
size={'small'}
uniqueIdentifier={value.plugin_unique_identifier}
/>}
{showPluginNotInstalledWarn
? <NotFoundWarn
title={t('workflow.nodes.agent.pluginNotInstalled')}
description={t('workflow.nodes.agent.pluginNotInstalledDesc')}
/>
: showUnsupportedStrategy
? <NotFoundWarn
title={t('workflow.nodes.agent.unsupportedStrategy')}
description={t('workflow.nodes.agent.strategyNotFoundDesc')}
/>
: <RiArrowDownSLine className='size-4 text-text-tertiary' />
}
{showSwitchVersion && <SwitchPluginVersion
uniqueIdentifier={value.plugin_unique_identifier}
tooltip={<ToolTipContent
title={t('workflow.nodes.agent.unsupportedStrategy')}>
{t('workflow.nodes.agent.strategyNotFoundDescAndSwitchVersion')}
</ToolTipContent>}
onChange={() => {
refetchStrategyInfo()
}}
/>}
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow'>
<header className='flex gap-1 p-2'>
<SearchInput placeholder={t('workflow.nodes.agent.strategy.searchPlaceholder')} value={query} onChange={setQuery} className={'w-full'} />
<ViewTypeSelect viewType={viewType} onChange={setViewType} />
</header>
<main className="relative flex w-full flex-col overflow-hidden md:max-h-[300px] xl:max-h-[400px] 2xl:max-h-[564px]" ref={wrapElemRef}>
<Tools
tools={filteredTools}
viewType={viewType}
onSelect={(_, tool) => {
onChange({
agent_strategy_name: tool!.tool_name,
agent_strategy_provider_name: tool!.provider_name,
agent_strategy_label: tool!.tool_label,
agent_output_schema: tool!.output_schema || {},
plugin_unique_identifier: tool!.provider_id,
meta: tool!.meta,
})
setOpen(false)
}}
className='h-full max-h-full max-w-none overflow-y-auto'
indexBarClassName='top-0 xl:top-36'
hasSearchText={false}
canNotSelectMultiple
canChooseMCPTool={canChooseMCPTool}
isAgent
/>
{enable_marketplace && <PluginList
ref={pluginRef}
wrapElemRef={wrapElemRef}
list={notInstalledPlugins}
searchText={query}
tags={DEFAULT_TAGS}
disableMaxWidth
/>}
</main>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
})
AgentStrategySelector.displayName = 'AgentStrategySelector'

View File

@@ -0,0 +1,249 @@
import type { CredentialFormSchemaNumberInput, CredentialFormSchemaTextInput } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { type CredentialFormSchema, FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolVarInputs } from '../../tool/types'
import ListEmpty from '@/app/components/base/list-empty'
import { AgentStrategySelector } from './agent-strategy-selector'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import { InputNumber } from '@/app/components/base/input-number'
import Slider from '@/app/components/base/slider'
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
import Field from './field'
import { type ComponentProps, memo } from 'react'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import Editor from './prompt/editor'
import { useWorkflowStore } from '../../../store'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import type { NodeOutPutVar } from '../../../types'
import type { Node } from 'reactflow'
import type { PluginMeta } from '@/app/components/plugins/types'
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
import { AppModeEnum } from '@/types/app'
export type Strategy = {
agent_strategy_provider_name: string
agent_strategy_name: string
agent_strategy_label: string
agent_output_schema: Record<string, any>
plugin_unique_identifier: string
meta?: PluginMeta
}
export type AgentStrategyProps = {
strategy?: Strategy
onStrategyChange: (strategy?: Strategy) => void
formSchema: CredentialFormSchema[]
formValue: ToolVarInputs
onFormValueChange: (value: ToolVarInputs) => void
nodeOutputVars?: NodeOutPutVar[],
availableNodes?: Node[],
nodeId?: string
canChooseMCPTool: boolean
}
type CustomSchema<Type, Field = {}> = Omit<CredentialFormSchema, 'type'> & { type: Type } & Field
type ToolSelectorSchema = CustomSchema<'tool-selector'>
type MultipleToolSelectorSchema = CustomSchema<'array[tools]'>
type CustomField = ToolSelectorSchema | MultipleToolSelectorSchema
export const AgentStrategy = memo((props: AgentStrategyProps) => {
const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId, canChooseMCPTool } = props
const { t } = useTranslation()
const docLink = useDocLink()
const defaultModel = useDefaultModel(ModelTypeEnum.textGeneration)
const renderI18nObject = useRenderI18nObject()
const workflowStore = useWorkflowStore()
const {
setControlPromptEditorRerenderKey,
} = workflowStore.getState()
const override: ComponentProps<typeof Form<CustomField>>['override'] = [
[FormTypeEnum.textNumber, FormTypeEnum.textInput],
(schema, props) => {
switch (schema.type) {
case FormTypeEnum.textInput: {
const def = schema as CredentialFormSchemaTextInput
const value = props.value[schema.variable] || schema.default
const instanceId = schema.variable
const onChange = (value: string) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
const handleGenerated = (value: string) => {
onChange(value)
setControlPromptEditorRerenderKey(Math.random())
}
return <Editor
value={value}
onChange={onChange}
onGenerated={handleGenerated}
instanceId={instanceId}
key={instanceId}
title={renderI18nObject(schema.label)}
headerClassName='bg-transparent px-0 text-text-secondary system-sm-semibold-uppercase'
containerBackgroundClassName='bg-transparent'
gradientBorder={false}
nodeId={nodeId}
isSupportPromptGenerator={!!def.auto_generate?.type}
titleTooltip={schema.tooltip && renderI18nObject(schema.tooltip)}
editorContainerClassName='px-0'
availableNodes={availableNodes}
nodesOutputVars={nodeOutputVars}
isSupportJinja={def.template?.enabled}
required={def.required}
varList={[]}
modelConfig={
defaultModel.data
? {
mode: AppModeEnum.CHAT,
name: defaultModel.data.model,
provider: defaultModel.data.provider.provider,
completion_params: {},
} : undefined
}
placeholderClassName='px-2 py-1'
titleClassName='system-sm-semibold-uppercase text-text-secondary text-[13px]'
inputClassName='px-2 py-1 bg-components-input-bg-normal focus:bg-components-input-bg-active focus:border-components-input-border-active focus:border rounded-lg'
/>
}
case FormTypeEnum.textNumber: {
const def = schema as CredentialFormSchemaNumberInput
if (!def.max || !def.min)
return false
const defaultValue = schema.default ? Number.parseInt(schema.default) : 1
const value = props.value[schema.variable] || defaultValue
const onChange = (value: number) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
return <Field
title={<>
{renderI18nObject(def.label)} {def.required && <span className='text-red-500'>*</span>}
</>}
key={def.variable}
tooltip={def.tooltip && renderI18nObject(def.tooltip)}
inline
>
<div className='flex w-[200px] items-center gap-3'>
<Slider
value={value}
onChange={onChange}
className='w-full'
min={def.min}
max={def.max}
/>
<InputNumber
value={value}
// TODO: maybe empty, handle this
onChange={onChange as any}
defaultValue={defaultValue}
size='regular'
min={def.min}
max={def.max}
className='w-12'
/>
</div>
</Field>
}
}
},
]
const renderField: ComponentProps<typeof Form<CustomField>>['customRenderField'] = (schema, props) => {
switch (schema.type) {
case FormTypeEnum.toolSelector: {
const value = props.value[schema.variable]
const onChange = (value: any) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
return (
<Field
title={<>
{renderI18nObject(schema.label)} {schema.required && <span className='text-red-500'>*</span>}
</>}
tooltip={schema.tooltip && renderI18nObject(schema.tooltip)}
>
<ToolSelector
nodeId={props.nodeId || ''}
nodeOutputVars={props.nodeOutputVars || []}
availableNodes={props.availableNodes || []}
scope={schema.scope}
value={value}
onSelect={item => onChange(item)}
onDelete={() => onChange(null)}
canChooseMCPTool={canChooseMCPTool}
onSelectMultiple={noop}
/>
</Field>
)
}
case FormTypeEnum.multiToolSelector: {
const value = props.value[schema.variable]
const onChange = (value: any) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
return (
<MultipleToolSelector
nodeId={props.nodeId || ''}
nodeOutputVars={props.nodeOutputVars || []}
availableNodes={props.availableNodes || []}
scope={schema.scope}
value={value || []}
label={renderI18nObject(schema.label)}
tooltip={schema.tooltip && renderI18nObject(schema.tooltip)}
onChange={onChange}
supportCollapse
required={schema.required}
canChooseMCPTool={canChooseMCPTool}
/>
)
}
}
}
return <div className='space-y-2'>
<AgentStrategySelector value={strategy} onChange={onStrategyChange} canChooseMCPTool={canChooseMCPTool} />
{
strategy
? <div>
<Form<CustomField>
formSchemas={[
...formSchema,
]}
value={formValue}
onChange={onFormValueChange}
validating={false}
showOnVariableMap={{}}
isEditMode={true}
isAgentStrategy={true}
fieldLabelClassName='uppercase'
customRenderField={renderField}
override={override}
nodeId={nodeId}
nodeOutputVars={nodeOutputVars || []}
availableNodes={availableNodes || []}
canChooseMCPTool={canChooseMCPTool}
/>
</div>
: <ListEmpty
icon={<Agent className='h-5 w-5 shrink-0 text-text-accent' />}
title={t('workflow.nodes.agent.strategy.configureTip')}
description={<div className='text-xs text-text-tertiary'>
{t('workflow.nodes.agent.strategy.configureTipDesc')} <br />
<Link href={docLink('/guides/workflow/node/agent#select-an-agent-strategy', {
'zh-Hans': '/guides/workflow/node/agent#选择-agent-策略',
'ja-JP': '/guides/workflow/node/agent#エージェント戦略の選択',
})}
className='text-text-accent-secondary' target='_blank'>
{t('workflow.nodes.agent.learnMore')}
</Link>
</div>}
/>
}
</div>
})
AgentStrategy.displayName = 'AgentStrategy'

View File

@@ -0,0 +1,38 @@
'use client'
import Checkbox from '@/app/components/base/checkbox'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
name: string
value: boolean
required?: boolean
onChange: (value: boolean) => void
}
const BoolInput: FC<Props> = ({
value,
onChange,
name,
required,
}) => {
const { t } = useTranslation()
const handleChange = useCallback(() => {
onChange(!value)
}, [value, onChange])
return (
<div className='flex h-6 items-center gap-2'>
<Checkbox
className='!h-4 !w-4'
checked={!!value}
onCheck={handleChange}
/>
<div className='system-sm-medium flex items-center gap-1 text-text-secondary'>
{name}
{!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>}
</div>
</div>
)
}
export default React.memo(BoolInput)

View File

@@ -0,0 +1,338 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import {
RiDeleteBinLine,
} from '@remixicon/react'
import type { InputVar } from '../../../../types'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '../../../../types'
import CodeEditor from '../editor/code-editor'
import { CodeLanguage } from '../../../code/types'
import TextEditor from '../editor/text-editor'
import Select from '@/app/components/base/select'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { Resolution, TransferMethod } from '@/types/app'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import cn from '@/utils/classnames'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import BoolInput from './bool-input'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
type Props = {
payload: InputVar
value: any
onChange: (value: any) => void
className?: string
autoFocus?: boolean
inStepRun?: boolean
}
const FormItem: FC<Props> = ({
payload,
value,
onChange,
className,
autoFocus,
inStepRun = false,
}) => {
const { t } = useTranslation()
const { type } = payload
const fileSettings = useHooksStore(s => s.configsMap?.fileSettings)
const handleArrayItemChange = useCallback((index: number) => {
return (newValue: any) => {
const newValues = produce(value, (draft: any) => {
draft[index] = newValue
})
onChange(newValues)
}
}, [value, onChange])
const handleArrayItemRemove = useCallback((index: number) => {
return () => {
const newValues = produce(value, (draft: any) => {
draft.splice(index, 1)
})
onChange(newValues)
}
}, [value, onChange])
const nodeKey = (() => {
if (typeof payload.label === 'object') {
const { nodeType, nodeName, variable, isChatVar } = payload.label
return (
<div className='flex h-full items-center'>
{!isChatVar && (
<div className='flex items-center'>
<div className='p-[1px]'>
<VarBlockIcon type={nodeType || BlockEnum.Start} />
</div>
<div className='mx-0.5 max-w-[150px] truncate text-xs font-medium text-gray-700' title={nodeName}>
{nodeName}
</div>
<Line3 className='mr-0.5'></Line3>
</div>
)}
<div className='flex items-center text-primary-600'>
{!isChatVar && <Variable02 className='h-3.5 w-3.5' />}
{isChatVar && <BubbleX className='h-3.5 w-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('ml-0.5 max-w-[150px] truncate text-xs font-medium', isChatVar && 'text-text-secondary')} title={variable} >
{variable}
</div>
</div>
</div>
)
}
return ''
})()
const isBooleanType = type === InputVarType.checkbox
const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(type)
const isContext = type === InputVarType.contexts
const isIterator = type === InputVarType.iterator
const isIteratorItemFile = isIterator && payload.isFileItem
const singleFileValue = useMemo(() => {
if (payload.variable === '#files#')
return value?.[0] || []
return value ? [value] : []
}, [payload.variable, value])
const handleSingleFileChange = useCallback((files: FileEntity[]) => {
if (payload.variable === '#files#')
onChange(files)
else if (files.length)
onChange(files[0])
else
onChange(null)
}, [onChange, payload.variable])
return (
<div className={cn(className)}>
{!isArrayLikeType && !isBooleanType && (
<div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'>
<div className='truncate'>
{typeof payload.label === 'object' ? nodeKey : payload.label}
</div>
{payload.hide === true ? (
<span className='system-xs-regular text-text-tertiary'>
{t('workflow.panel.optional_and_hidden')}
</span>
) : (
!payload.required && (
<span className='system-xs-regular text-text-tertiary'>
{t('workflow.panel.optional')}
</span>
)
)}
</div>
)}
<div className='grow'>
{
type === InputVarType.textInput && (
<Input
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus}
/>
)
}
{
type === InputVarType.number && (
<Input
type="number"
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus}
/>
)
}
{
type === InputVarType.paragraph && (
<Textarea
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus}
/>
)
}
{
type === InputVarType.select && (
<Select
className="w-full"
defaultValue={value || payload.default || ''}
items={payload.options?.map(option => ({ name: option, value: option })) || []}
onSelect={i => onChange(i.value)}
allowSearch={false}
/>
)
}
{isBooleanType && (
<BoolInput
name={payload.label as string}
value={!!value}
required={payload.required}
onChange={onChange}
/>
)}
{
type === InputVarType.json && (
<CodeEditor
value={value}
title={<span>JSON</span>}
language={CodeLanguage.json}
onChange={onChange}
/>
)
}
{type === InputVarType.jsonObject && (
<CodeEditor
value={value}
language={CodeLanguage.json}
onChange={onChange}
noWrapper
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
placeholder={
<div className='whitespace-pre'>{payload.json_schema}</div>
}
/>
)}
{(type === InputVarType.singleFile) && (
<FileUploaderInAttachmentWrapper
value={singleFileValue}
onChange={handleSingleFileChange}
fileConfig={{
allowed_file_types: inStepRun && (!payload.allowed_file_types || payload.allowed_file_types.length === 0)
? [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
]
: payload.allowed_file_types,
allowed_file_extensions: inStepRun && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0)
? [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
]
: payload.allowed_file_extensions,
allowed_file_upload_methods: inStepRun ? [TransferMethod.local_file, TransferMethod.remote_url] : payload.allowed_file_upload_methods,
number_limits: 1,
fileUploadConfig: fileSettings?.fileUploadConfig,
}}
/>
)}
{(type === InputVarType.multiFiles || isIteratorItemFile) && (
<FileUploaderInAttachmentWrapper
value={value}
onChange={files => onChange(files)}
fileConfig={{
allowed_file_types: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_types || payload.allowed_file_types.length === 0)
? [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
]
: payload.allowed_file_types,
allowed_file_extensions: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0)
? [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
]
: payload.allowed_file_extensions,
allowed_file_upload_methods: (inStepRun || isIteratorItemFile) ? [TransferMethod.local_file, TransferMethod.remote_url] : payload.allowed_file_upload_methods,
number_limits: (inStepRun || isIteratorItemFile) ? 5 : payload.max_length,
fileUploadConfig: fileSettings?.fileUploadConfig,
}}
/>
)}
{
type === InputVarType.files && (
<TextGenerationImageUploader
settings={{
...fileSettings,
detail: fileSettings?.image?.detail || Resolution.high,
transfer_methods: fileSettings?.allowed_file_upload_methods || [],
} as any}
onFilesChange={files => onChange(files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))}
/>
)
}
{
isContext && (
<div className='space-y-2'>
{(value || []).map((item: any, index: number) => (
<CodeEditor
key={index}
value={item}
title={<span>JSON</span>}
headerRight={
(value as any).length > 1
? (<RiDeleteBinLine
onClick={handleArrayItemRemove(index)}
className='mr-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary'
/>)
: undefined
}
language={CodeLanguage.json}
onChange={handleArrayItemChange(index)}
/>
))}
</div>
)
}
{
(isIterator && !isIteratorItemFile) && (
<div className='space-y-2'>
{(value || []).map((item: any, index: number) => (
<TextEditor
key={index}
isInNode
value={item}
title={<span>{t('appDebug.variableConfig.content')} {index + 1} </span>}
onChange={handleArrayItemChange(index)}
headerRight={
(value as any).length > 1
? (<RiDeleteBinLine
onClick={handleArrayItemRemove(index)}
className='mr-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary'
/>)
: undefined
}
/>
))}
</div>
)
}
</div>
</div>
)
}
export default React.memo(FormItem)

View File

@@ -0,0 +1,101 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { produce } from 'immer'
import type { InputVar } from '../../../../types'
import FormItem from './form-item'
import cn from '@/utils/classnames'
import { InputVarType } from '@/app/components/workflow/types'
import AddButton from '@/app/components/base/button/add-button'
import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants'
export type Props = {
className?: string
label?: string
inputs: InputVar[]
values: Record<string, string>
onChange: (newValues: Record<string, any>) => void
}
const Form: FC<Props> = ({
className,
label,
inputs,
values,
onChange,
}) => {
const mapKeysWithSameValueSelector = useMemo(() => {
const keysWithSameValueSelector = (key: string) => {
const targetValueSelector = inputs.find(
item => item.variable === key,
)?.value_selector
if (!targetValueSelector)
return [key]
const result: string[] = []
inputs.forEach((item) => {
if (item.value_selector?.join('.') === targetValueSelector.join('.'))
result.push(item.variable)
})
return result
}
const m = new Map()
for (const input of inputs)
m.set(input.variable, keysWithSameValueSelector(input.variable))
return m
}, [inputs])
const valuesRef = useRef(values)
useEffect(() => {
valuesRef.current = values
}, [values])
const handleChange = useCallback((key: string) => {
const mKeys = mapKeysWithSameValueSelector.get(key) ?? [key]
return (value: any) => {
const newValues = produce(valuesRef.current, (draft) => {
for (const k of mKeys)
draft[k] = value
})
onChange(newValues)
}
}, [valuesRef, onChange, mapKeysWithSameValueSelector])
const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(inputs[0]?.type)
const isIteratorItemFile = inputs[0]?.type === InputVarType.iterator && inputs[0]?.isFileItem
const isContext = inputs[0]?.type === InputVarType.contexts
const handleAddContext = useCallback(() => {
const newValues = produce(values, (draft: any) => {
const key = inputs[0].variable
if (!draft[key])
draft[key] = []
draft[key].push(isContext ? RETRIEVAL_OUTPUT_STRUCT : '')
})
onChange(newValues)
}, [values, onChange, inputs, isContext])
return (
<div className={cn(className, 'space-y-2')}>
{label && (
<div className='mb-1 flex items-center justify-between'>
<div className='system-xs-medium-uppercase flex h-6 items-center text-text-tertiary'>{label}</div>
{isArrayLikeType && !isIteratorItemFile && (
<AddButton onClick={handleAddContext} />
)}
</div>
)}
{inputs.map((input, index) => {
return (
<FormItem
inStepRun
key={index}
payload={input}
value={values[input.variable]}
onChange={handleChange(input.variable)}
/>
)
})}
</div>
)
}
export default React.memo(Form)

View File

@@ -0,0 +1,178 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { Props as FormProps } from './form'
import Form from './form'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { InputVarType } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import { TransferMethod } from '@/types/app'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import type { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import type { Emoji } from '@/app/components/tools/types'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
import PanelWrap from './panel-wrap'
const i18nPrefix = 'workflow.singleRun'
export type BeforeRunFormProps = {
nodeName: string
nodeType?: BlockEnum
toolIcon?: string | Emoji
onHide: () => void
onRun: (submitData: Record<string, any>) => void
onStop: () => void
runningStatus: NodeRunningStatus
forms: FormProps[]
showSpecialResultPanel?: boolean
existVarValuesInForms: Record<string, any>[]
filteredExistVarForms: FormProps[]
} & Partial<SpecialResultPanelProps>
function formatValue(value: string | any, type: InputVarType) {
if(type === InputVarType.checkbox)
return !!value
if(value === undefined || value === null)
return value
if (type === InputVarType.number)
return Number.parseFloat(value)
if (type === InputVarType.json)
return JSON.parse(value)
if (type === InputVarType.contexts) {
return value.map((item: any) => {
return JSON.parse(item)
})
}
if (type === InputVarType.multiFiles)
return getProcessedFiles(value)
if (type === InputVarType.singleFile) {
if (Array.isArray(value))
return getProcessedFiles(value)
if (!value)
return undefined
return getProcessedFiles([value])[0]
}
return value
}
const BeforeRunForm: FC<BeforeRunFormProps> = ({
nodeName,
onHide,
onRun,
forms,
filteredExistVarForms,
existVarValuesInForms,
}) => {
const { t } = useTranslation()
const isFileLoaded = (() => {
if (!forms || forms.length === 0)
return true
// system files
const filesForm = forms.find(item => !!item.values['#files#'])
if (!filesForm)
return true
const files = filesForm.values['#files#'] as any
if (files?.some((item: any) => item.transfer_method === TransferMethod.local_file && !item.upload_file_id))
return false
return true
})()
const handleRun = () => {
let errMsg = ''
forms.forEach((form, i) => {
const existVarValuesInForm = existVarValuesInForms[i]
form.inputs.forEach((input) => {
const value = form.values[input.variable] as any
if (!errMsg && input.required && (input.type !== InputVarType.checkbox) && !(input.variable in existVarValuesInForm) && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0)))
errMsg = t('workflow.errorMsg.fieldRequired', { field: typeof input.label === 'object' ? input.label.variable : input.label })
if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) {
let fileIsUploading = false
if (Array.isArray(value))
fileIsUploading = value.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
else
fileIsUploading = value.transferMethod === TransferMethod.local_file && !value.uploadedId
if (fileIsUploading)
errMsg = t('appDebug.errorMessage.waitForFileUpload')
}
})
})
if (errMsg) {
Toast.notify({
message: errMsg,
type: 'error',
})
return
}
const submitData: Record<string, any> = {}
let parseErrorJsonField = ''
forms.forEach((form) => {
form.inputs.forEach((input) => {
try {
const value = formatValue(form.values[input.variable], input.type)
submitData[input.variable] = value
}
catch {
parseErrorJsonField = input.variable
}
})
})
if (parseErrorJsonField) {
Toast.notify({
message: t('workflow.errorMsg.invalidJson', { field: parseErrorJsonField }),
type: 'error',
})
return
}
onRun(submitData)
}
const hasRun = useRef(false)
useEffect(() => {
// React 18 run twice in dev mode
if(hasRun.current)
return
hasRun.current = true
if(filteredExistVarForms.length === 0)
onRun({})
}, [filteredExistVarForms, onRun])
if(filteredExistVarForms.length === 0)
return null
return (
<PanelWrap
nodeName={nodeName}
onHide={onHide}
>
<div className='h-0 grow overflow-y-auto pb-4'>
<div className='mt-3 space-y-4 px-4'>
{filteredExistVarForms.map((form, index) => (
<div key={index}>
<Form
key={index}
className={cn(index < forms.length - 1 && 'mb-4')}
{...form}
/>
{index < forms.length - 1 && <Split />}
</div>
))}
</div>
<div className='mt-4 flex justify-between space-x-2 px-4' >
<Button disabled={!isFileLoaded} variant='primary' className='w-0 grow space-x-2' onClick={handleRun}>
<div>{t(`${i18nPrefix}.startRun`)}</div>
</Button>
</div>
</div>
</PanelWrap>
)
}
export default React.memo(BeforeRunForm)

View File

@@ -0,0 +1,41 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
} from '@remixicon/react'
const i18nPrefix = 'workflow.singleRun'
export type Props = {
nodeName: string
onHide: () => void
children: React.ReactNode
}
const PanelWrap: FC<Props> = ({
nodeName,
onHide,
children,
}) => {
const { t } = useTranslation()
return (
<div className='absolute inset-0 z-10 rounded-2xl bg-background-overlay-alt'>
<div className='flex h-full flex-col rounded-2xl bg-components-panel-bg'>
<div className='flex h-8 shrink-0 items-center justify-between pl-4 pr-3 pt-3'>
<div className='truncate text-base font-semibold text-text-primary'>
{t(`${i18nPrefix}.testRun`)} {nodeName}
</div>
<div className='ml-2 shrink-0 cursor-pointer p-1' onClick={() => {
onHide()
}}>
<RiCloseLine className='h-4 w-4 text-text-tertiary ' />
</div>
</div>
{children}
</div>
</div>
)
}
export default React.memo(PanelWrap)

View File

@@ -0,0 +1,58 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useBoolean } from 'ahooks'
import cn from 'classnames'
import type { CodeLanguage } from '../../code/types'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import { ActionButton } from '@/app/components/base/action-button'
import { AppModeEnum } from '@/types/app'
import type { GenRes } from '@/service/debug'
import { GetCodeGeneratorResModal } from '@/app/components/app/configuration/config/code-generator/get-code-generator-res'
import { useHooksStore } from '../../../hooks-store'
type Props = {
nodeId: string
currentCode?: string
className?: string
onGenerated?: (prompt: string) => void
codeLanguages: CodeLanguage
}
const CodeGenerateBtn: FC<Props> = ({
nodeId,
currentCode,
className,
codeLanguages,
onGenerated,
}) => {
const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false)
const handleAutomaticRes = useCallback((res: GenRes) => {
onGenerated?.(res.modified)
showAutomaticFalse()
}, [onGenerated, showAutomaticFalse])
const configsMap = useHooksStore(s => s.configsMap)
return (
<div className={cn(className)}>
<ActionButton
className='hover:bg-[#155EFF]/8'
onClick={showAutomaticTrue}>
<Generator className='h-4 w-4 text-primary-600' />
</ActionButton>
{showAutomatic && (
<GetCodeGeneratorResModal
mode={AppModeEnum.CHAT}
isShow={showAutomatic}
codeLanguages={codeLanguages}
onClose={showAutomaticFalse}
onFinished={handleAutomaticRes}
flowId={configsMap?.flowId || ''}
nodeId={nodeId}
currentCode={currentCode}
/>
)}
</div>
)
}
export default React.memo(CodeGenerateBtn)

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import Collapse from '.'
type FieldCollapseProps = {
title: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
}
const FieldCollapse = ({
title,
children,
collapsed,
onCollapse,
operations,
}: FieldCollapseProps) => {
return (
<div className='py-4'>
<Collapse
trigger={
<div className='system-sm-semibold-uppercase flex h-6 cursor-pointer items-center text-text-secondary'>{title}</div>
}
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>
<div className='px-4'>
{children}
</div>
</Collapse>
</div>
)
}
export default FieldCollapse

View File

@@ -0,0 +1,69 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import cn from '@/utils/classnames'
export { default as FieldCollapse } from './field-collapse'
type CollapseProps = {
disabled?: boolean
trigger: React.JSX.Element | ((collapseIcon: React.JSX.Element | null) => React.JSX.Element)
children: React.JSX.Element
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
hideCollapseIcon?: boolean
}
const Collapse = ({
disabled,
trigger,
children,
collapsed,
onCollapse,
operations,
hideCollapseIcon,
}: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
const collapseIcon = useMemo(() => {
if (disabled)
return null
return (
<ArrowDownRoundFill
className={cn(
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapsedMerged && 'rotate-[270deg]',
)}
/>
)
}, [collapsedMerged, disabled])
return (
<>
<div className='group/collapse flex items-center'>
<div
className='ml-4 flex grow items-center'
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)
onCollapse?.(!collapsedMerged)
}
}}
>
{typeof trigger === 'function' ? trigger(collapseIcon) : trigger}
{!hideCollapseIcon && (
<div className='h-4 w-4 shrink-0'>
{collapseIcon}
</div>
)}
</div>
{operations}
</div>
{
!collapsedMerged && children
}
</>
)
}
export default Collapse

View File

@@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import VarReferencePicker from './variable/var-reference-picker'
import ResolutionPicker from '@/app/components/workflow/nodes/llm/components/resolution-picker'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Switch from '@/app/components/base/switch'
import { type ValueSelector, type Var, VarType, type VisionSetting } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
import Tooltip from '@/app/components/base/tooltip'
const i18nPrefix = 'workflow.nodes.llm'
type Props = {
isVisionModel: boolean
readOnly: boolean
enabled: boolean
onEnabledChange: (enabled: boolean) => void
nodeId: string
config?: VisionSetting
onConfigChange: (config: VisionSetting) => void
}
const ConfigVision: FC<Props> = ({
isVisionModel,
readOnly,
enabled,
onEnabledChange,
nodeId,
config = {
detail: Resolution.high,
variable_selector: [],
},
onConfigChange,
}) => {
const { t } = useTranslation()
const filterVar = useCallback((payload: Var) => {
return [VarType.file, VarType.arrayFile].includes(payload.type)
}, [])
const handleVisionResolutionChange = useCallback((resolution: Resolution) => {
const newConfig = produce(config, (draft) => {
draft.detail = resolution
})
onConfigChange(newConfig)
}, [config, onConfigChange])
const handleVarSelectorChange = useCallback((valueSelector: ValueSelector | string) => {
const newConfig = produce(config, (draft) => {
draft.variable_selector = valueSelector as ValueSelector
})
onConfigChange(newConfig)
}, [config, onConfigChange])
return (
<Field
title={t(`${i18nPrefix}.vision`)}
tooltip={t('appDebug.vision.description')!}
operations={
<Tooltip
popupContent={t('appDebug.vision.onlySupportVisionModelTip')!}
disabled={isVisionModel}
>
<Switch disabled={readOnly || !isVisionModel} size='md' defaultValue={!isVisionModel ? false : enabled} onChange={onEnabledChange} />
</Tooltip>
}
>
{(enabled && isVisionModel)
? (
<div>
<VarReferencePicker
className='mb-4'
filterVar={filterVar}
nodeId={nodeId}
value={config.variable_selector || []}
onChange={handleVarSelectorChange}
readonly={readOnly}
/>
<ResolutionPicker
value={config.detail}
onChange={handleVisionResolutionChange}
/>
</div>
)
: null}
</Field>
)
}
export default React.memo(ConfigVision)

View File

@@ -0,0 +1,138 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import copy from 'copy-to-clipboard'
import ToggleExpandBtn from '../toggle-expand-btn'
import CodeGeneratorButton from '../code-generator-button'
import type { CodeLanguage } from '../../../code/types'
import Wrap from './wrap'
import cn from '@/utils/classnames'
import PromptEditorHeightResizeWrap from '@/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap'
import {
Copy,
CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import FileListInLog from '@/app/components/base/file-uploader/file-list-in-log'
import ActionButton from '@/app/components/base/action-button'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
type Props = {
nodeId?: string
className?: string
title: React.JSX.Element | string
headerRight?: React.JSX.Element
children: React.JSX.Element
minHeight?: number
value: string
isFocus: boolean
isInNode?: boolean
onGenerated?: (prompt: string) => void
codeLanguages?: CodeLanguage
fileList?: {
varName: string
list: FileEntity[]
}[]
showFileList?: boolean
showCodeGenerator?: boolean
tip?: React.JSX.Element
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
footer?: React.ReactNode
}
const Base: FC<Props> = ({
nodeId,
className,
title,
headerRight,
children,
minHeight = 120,
value,
isFocus,
isInNode,
onGenerated,
codeLanguages,
fileList = [],
showFileList,
showCodeGenerator = false,
tip,
footer,
}) => {
const ref = useRef<HTMLDivElement>(null)
const {
wrapClassName,
wrapStyle,
isExpand,
setIsExpand,
editorExpandHeight,
} = useToggleExpend({ ref, hasFooter: false, isInNode })
const editorContentMinHeight = minHeight - 28
const [editorContentHeight, setEditorContentHeight] = useState(editorContentMinHeight)
const [isCopied, setIsCopied] = React.useState(false)
const handleCopy = useCallback(() => {
copy(value)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 2000)
}, [value])
return (
<Wrap className={cn(wrapClassName)} style={wrapStyle} isInNode={isInNode} isExpand={isExpand}>
<div ref={ref} className={cn(className, isExpand && 'h-full', 'rounded-lg border', !isFocus ? 'border-transparent bg-components-input-bg-normal' : 'overflow-hidden border-components-input-border-hover bg-components-input-bg-hover')}>
<div className='flex h-7 items-center justify-between pl-3 pr-2 pt-1'>
<div className='system-xs-semibold-uppercase text-text-secondary'>{title}</div>
<div className='flex items-center' onClick={(e) => {
e.nativeEvent.stopImmediatePropagation()
e.stopPropagation()
}}>
{headerRight}
{showCodeGenerator && codeLanguages && (
<div className='ml-1'>
<CodeGeneratorButton
onGenerated={onGenerated}
codeLanguages={codeLanguages}
currentCode={value}
nodeId={nodeId!}
/>
</div>
)}
<ActionButton className='ml-1' onClick={handleCopy}>
{!isCopied
? (
<Copy className='h-4 w-4 cursor-pointer' />
)
: (
<CopyCheck className='h-4 w-4' />
)
}
</ActionButton>
<div className='ml-1'>
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div>
</div>
</div>
{tip && <div className='px-1 py-0.5'>{tip}</div>}
<PromptEditorHeightResizeWrap
height={isExpand ? editorExpandHeight : editorContentHeight}
minHeight={editorContentMinHeight}
onHeightChange={setEditorContentHeight}
hideResize={isExpand}
>
<div className='h-full pb-2 pl-2'>
{children}
</div>
</PromptEditorHeightResizeWrap>
{showFileList && fileList.length > 0 && (
<FileListInLog fileList={fileList} />
)}
{footer}
</div>
</Wrap>
)
}
export default React.memo(Base)

View File

@@ -0,0 +1,170 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import type { Props as EditorProps } from '.'
import Editor from '.'
import cn from '@/utils/classnames'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type { NodeOutPutVar, Variable } from '@/app/components/workflow/types'
const TO_WINDOW_OFFSET = 8
type Props = {
availableVars: NodeOutPutVar[]
varList: Variable[]
onAddVar?: (payload: Variable) => void
} & EditorProps
const CodeEditor: FC<Props> = ({
availableVars,
varList,
onAddVar,
...editorProps
}) => {
const { t } = useTranslation()
const isLeftBraceRef = useRef(false)
const editorRef = useRef(null)
const monacoRef = useRef(null)
const popupRef = useRef<HTMLDivElement>(null)
const [isShowVarPicker, {
setTrue: showVarPicker,
setFalse: hideVarPicker,
}] = useBoolean(false)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
// Listen for cursor position changes
const handleCursorPositionChange = (event: any) => {
const editor: any = editorRef.current
const { position } = event
const text = editor.getModel().getLineContent(position.lineNumber)
const charBefore = text[position.column - 2]
if (['/', '{'].includes(charBefore)) {
isLeftBraceRef.current = charBefore === '{'
const editorRect = editor.getDomNode().getBoundingClientRect()
const cursorCoords = editor.getScrolledVisiblePosition(position)
const popupX = editorRect.left + cursorCoords.left
const popupY = editorRect.top + cursorCoords.top + 20 // Adjust the vertical position as needed
setPopupPosition({ x: popupX, y: popupY })
showVarPicker()
}
else {
hideVarPicker()
}
}
useEffect(() => {
if (isShowVarPicker && popupRef.current) {
const windowWidth = window.innerWidth
const { width, height } = popupRef.current!.getBoundingClientRect()
const newPopupPosition = { ...popupPosition }
if (popupPosition.x + width > windowWidth - TO_WINDOW_OFFSET)
newPopupPosition.x = windowWidth - width - TO_WINDOW_OFFSET
if (popupPosition.y + height > window.innerHeight - TO_WINDOW_OFFSET)
newPopupPosition.y = window.innerHeight - height - TO_WINDOW_OFFSET
if (newPopupPosition.x !== popupPosition.x || newPopupPosition.y !== popupPosition.y)
setPopupPosition(newPopupPosition)
}
}, [isShowVarPicker, popupPosition])
const onEditorMounted = (editor: any, monaco: any) => {
editorRef.current = editor
monacoRef.current = monaco
editor.onDidChangeCursorPosition(handleCursorPositionChange)
}
const getUniqVarName = (varName: string) => {
if (varList.find(v => v.variable === varName)) {
const match = varName.match(/_(\d+)$/)
const index = (() => {
if (match)
return Number.parseInt(match[1]!) + 1
return 1
})()
return getUniqVarName(`${varName.replace(/_(\d+)$/, '')}_${index}`)
}
return varName
}
const getVarName = (varValue: string[]) => {
const existVar = varList.find(v => Array.isArray(v.value_selector) && v.value_selector.join('@@@') === varValue.join('@@@'))
if (existVar) {
return {
name: existVar.variable,
isExist: true,
}
}
const varName = varValue.slice(-1)[0]
return {
name: getUniqVarName(varName),
isExist: false,
}
}
const handleSelectVar = (varValue: string[]) => {
const { name, isExist } = getVarName(varValue)
if (!isExist) {
const newVar: Variable = {
variable: name,
value_selector: varValue,
}
onAddVar?.(newVar)
}
const editor: any = editorRef.current
const monaco: any = monacoRef.current
const position = editor?.getPosition()
// Insert the content at the cursor position
editor?.executeEdits('', [
{
// position.column - 1 to remove the text before the cursor
range: new monaco.Range(position.lineNumber, position.column - 1, position.lineNumber, position.column),
text: `{{ ${name} }${!isLeftBraceRef.current ? '}' : ''}`, // left brace would auto add one right brace
},
])
hideVarPicker()
}
return (
<div className={cn(editorProps.isExpand && 'h-full')}>
<Editor
{...editorProps}
onMount={onEditorMounted}
placeholder={t('workflow.common.jinjaEditorPlaceholder')!}
/>
{isShowVarPicker && (
<div
ref={popupRef}
className='w-[228px] space-y-1 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg'
style={{
position: 'fixed',
top: popupPosition.y,
left: popupPosition.x,
zIndex: 100,
}}
>
<VarReferenceVars
hideSearch
vars={availableVars}
onChange={handleSelectVar}
isSupportFileVar={false}
/>
</div>
)}
</div>
)
}
export default React.memo(CodeEditor)

View File

@@ -0,0 +1,206 @@
'use client'
import type { FC } from 'react'
import Editor, { loader } from '@monaco-editor/react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import Base from '../base'
import cn from '@/utils/classnames'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import {
getFilesInLogs,
} from '@/app/components/base/file-uploader/utils'
import { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme'
import './style.css'
import { noop } from 'lodash-es'
import { basePath } from '@/utils/var'
// load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
if (typeof window !== 'undefined')
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
const CODE_EDITOR_LINE_HEIGHT = 18
export type Props = {
nodeId?: string
value?: string | object
placeholder?: React.JSX.Element | string
onChange?: (value: string) => void
title?: string | React.JSX.Element
language: CodeLanguage
headerRight?: React.JSX.Element
readOnly?: boolean
isJSONStringifyBeauty?: boolean
height?: number
isInNode?: boolean
onMount?: (editor: any, monaco: any) => void
noWrapper?: boolean
isExpand?: boolean
showFileList?: boolean
onGenerated?: (value: string) => void
showCodeGenerator?: boolean
className?: string
tip?: React.JSX.Element
footer?: React.ReactNode
}
export const languageMap = {
[CodeLanguage.javascript]: 'javascript',
[CodeLanguage.python3]: 'python',
[CodeLanguage.json]: 'json',
}
const CodeEditor: FC<Props> = ({
nodeId,
value = '',
placeholder = '',
onChange = noop,
title = '',
headerRight,
language,
readOnly,
isJSONStringifyBeauty,
height,
isInNode,
onMount,
noWrapper,
isExpand,
showFileList,
onGenerated,
showCodeGenerator = false,
className,
tip,
footer,
}) => {
const [isFocus, setIsFocus] = React.useState(false)
const [isMounted, setIsMounted] = React.useState(false)
const minHeight = height || 200
const [editorContentHeight, setEditorContentHeight] = useState(56)
const { theme: appTheme } = useTheme()
const valueRef = useRef(value)
useEffect(() => {
valueRef.current = value
}, [value])
const fileList = useMemo(() => {
if (typeof value === 'object')
return getFilesInLogs(value)
return []
}, [value])
const editorRef = useRef<any>(null)
const resizeEditorToContent = () => {
if (editorRef.current) {
const contentHeight = editorRef.current.getContentHeight() // Math.max(, minHeight)
setEditorContentHeight(contentHeight)
}
}
const handleEditorChange = (value: string | undefined) => {
onChange(value || '')
setTimeout(() => {
resizeEditorToContent()
}, 10)
}
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor
resizeEditorToContent()
editor.onDidFocusEditorText(() => {
setIsFocus(true)
})
editor.onDidBlurEditorText(() => {
setIsFocus(false)
})
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') // Fix: sometimes not load the default theme
onMount?.(editor, monaco)
setIsMounted(true)
}
const outPutValue = (() => {
if (!isJSONStringifyBeauty)
return value as string
try {
return JSON.stringify(value as object, null, 2)
}
catch {
return value as string
}
})()
const theme = useMemo(() => {
if (appTheme === Theme.light)
return 'light'
return 'vs-dark'
}, [appTheme])
const main = (
<>
{/* https://www.npmjs.com/package/@monaco-editor/react */}
<Editor
// className='min-h-[100%]' // h-full
// language={language === CodeLanguage.javascript ? 'javascript' : 'python'}
language={languageMap[language] || 'javascript'}
theme={isMounted ? theme : 'default-theme'} // sometimes not load the default theme
value={outPutValue}
loading={<span className='text-text-primary'>Loading...</span>}
onChange={handleEditorChange}
// https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IEditorOptions.html
options={{
readOnly,
domReadOnly: true,
quickSuggestions: false,
minimap: { enabled: false },
lineNumbersMinChars: 1, // would change line num width
wordWrap: 'on', // auto line wrap
// lineNumbers: (num) => {
// return <div>{num}</div>
// }
// hide ambiguousCharacters warning
unicodeHighlight: {
ambiguousCharacters: false,
},
stickyScroll: { enabled: false },
}}
onMount={handleEditorDidMount}
/>
{!outPutValue && !isFocus && <div className='pointer-events-none absolute left-[36px] top-0 text-[13px] font-normal leading-[18px] text-gray-300'>{placeholder}</div>}
</>
)
return (
<div className={cn(isExpand && 'h-full', className)}>
{noWrapper
? <div className='no-wrapper relative' style={{
height: isExpand ? '100%' : (editorContentHeight) / 2 + CODE_EDITOR_LINE_HEIGHT, // In IDE, the last line can always be in lop line. So there is some blank space in the bottom.
minHeight: CODE_EDITOR_LINE_HEIGHT,
}}>
{main}
</div>
: (
<Base
nodeId={nodeId}
className='relative'
title={title}
value={outPutValue}
headerRight={headerRight}
isFocus={isFocus && !readOnly}
minHeight={minHeight}
isInNode={isInNode}
onGenerated={onGenerated}
codeLanguages={language}
fileList={fileList as any}
showFileList={showFileList}
showCodeGenerator={showCodeGenerator}
tip={tip}
footer={footer}
>
{main}
</Base>
)}
</div>
)
}
export default React.memo(CodeEditor)

View File

@@ -0,0 +1,16 @@
.monaco-editor {
background-color: transparent !important;
outline: none !important;
}
.monaco-editor .monaco-editor-background {
background-color: transparent !important;
}
.monaco-editor .margin {
background-color: transparent !important;
}
/* hide readonly tooltip */
.monaco-editor-overlaymessage {
display: none !important;
}

View File

@@ -0,0 +1,63 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useBoolean } from 'ahooks'
import Base from './base'
type Props = {
value: string
onChange: (value: string) => void
title: React.JSX.Element | string
headerRight?: React.JSX.Element
minHeight?: number
onBlur?: () => void
placeholder?: string
readonly?: boolean
isInNode?: boolean
}
const TextEditor: FC<Props> = ({
value,
onChange,
title,
headerRight,
minHeight,
onBlur,
placeholder,
readonly,
isInNode,
}) => {
const [isFocus, {
setTrue: setIsFocus,
setFalse: setIsNotFocus,
}] = useBoolean(false)
const handleBlur = useCallback(() => {
setIsNotFocus()
onBlur?.()
}, [setIsNotFocus, onBlur])
return (
<div>
<Base
title={title}
value={value}
headerRight={headerRight}
isFocus={isFocus}
minHeight={minHeight}
isInNode={isInNode}
>
<textarea
value={value}
onChange={e => onChange(e.target.value)}
onFocus={setIsFocus}
onBlur={handleBlur}
className='h-full w-full resize-none border-none bg-transparent px-3 text-[13px] font-normal leading-[18px] text-gray-900 placeholder:text-gray-300 focus:outline-none'
placeholder={placeholder}
readOnly={readonly}
/>
</Base>
</div>
)
}
export default React.memo(TextEditor)

View File

@@ -0,0 +1,48 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useStore } from '@/app/components/workflow/store'
type Props = {
isInNode?: boolean
isExpand: boolean
className: string
style: React.CSSProperties
children: React.ReactNode
}
// It doesn't has workflow store
const WrapInWebApp = ({
className,
style,
children,
}: Props) => {
return <div className={className} style={style}>{children}</div>
}
const Wrap = ({
className,
style,
isExpand,
children,
}: Props) => {
const panelWidth = useStore(state => state.panelWidth)
const wrapStyle = (() => {
if (isExpand) {
return {
...style,
width: panelWidth - 1,
}
}
return style
})()
return <div className={className} style={wrapStyle}>{children}</div>
}
const Main: FC<Props> = ({
isInNode,
...otherProps
}: Props) => {
return isInNode ? <Wrap {...otherProps} /> : <WrapInWebApp {...otherProps} />
}
export default React.memo(Main)

View File

@@ -0,0 +1,40 @@
import type { FC, ReactNode } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export enum StartNodeTypeEnum {
Start = 'start',
Trigger = 'trigger',
}
type EntryNodeContainerProps = {
children: ReactNode
customLabel?: string
nodeType?: StartNodeTypeEnum
}
const EntryNodeContainer: FC<EntryNodeContainerProps> = ({
children,
customLabel,
nodeType = StartNodeTypeEnum.Trigger,
}) => {
const { t } = useTranslation()
const label = useMemo(() => {
const translationKey = nodeType === StartNodeTypeEnum.Start ? 'entryNodeStatus' : 'triggerStatus'
return customLabel || t(`workflow.${translationKey}.enabled`)
}, [customLabel, nodeType, t])
return (
<div className="w-fit min-w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-0 pb-0 pt-0.5">
<div className="mb-0.5 flex items-center px-1.5 pt-0.5">
<span className="text-2xs font-semibold uppercase text-text-tertiary">
{label}
</span>
</div>
{children}
</div>
)
}
export default EntryNodeContainer

View File

@@ -0,0 +1,93 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { DefaultValueForm } from './types'
import Input from '@/app/components/base/input'
import { VarType } from '@/app/components/workflow/types'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { useDocLink } from '@/context/i18n'
type DefaultValueProps = {
forms: DefaultValueForm[]
onFormChange: (form: DefaultValueForm) => void
}
const DefaultValue = ({
forms,
onFormChange,
}: DefaultValueProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const getFormChangeHandler = useCallback(({ key, type }: DefaultValueForm) => {
return (payload: any) => {
let value
if (type === VarType.string || type === VarType.number)
value = payload.target.value
if (type === VarType.array || type === VarType.arrayNumber || type === VarType.arrayString || type === VarType.arrayObject || type === VarType.arrayFile || type === VarType.object)
value = payload
onFormChange({ key, type, value })
}
}, [onFormChange])
return (
<div className='px-4 pt-2'>
<div className='body-xs-regular mb-2 text-text-tertiary'>
{t('workflow.nodes.common.errorHandle.defaultValue.desc')}
&nbsp;
<a
href={docLink('/guides/workflow/error-handling/README', {
'zh-Hans': '/guides/workflow/error-handling/readme',
})}
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</div>
<div className='space-y-1'>
{
forms.map((form, index) => {
return (
<div
key={index}
className='py-1'
>
<div className='mb-1 flex items-center'>
<div className='system-sm-medium mr-1 text-text-primary'>{form.key}</div>
<div className='system-xs-regular text-text-tertiary'>{form.type}</div>
</div>
{
(form.type === VarType.string || form.type === VarType.number) && (
<Input
type={form.type}
value={form.value || (form.type === VarType.string ? '' : 0)}
onChange={getFormChangeHandler({ key: form.key, type: form.type })}
/>
)
}
{
(
form.type === VarType.array
|| form.type === VarType.arrayNumber
|| form.type === VarType.arrayString
|| form.type === VarType.arrayObject
|| form.type === VarType.object
) && (
<CodeEditor
language={CodeLanguage.json}
value={form.value}
onChange={getFormChangeHandler({ key: form.key, type: form.type })}
/>
)
}
</div>
)
})
}
</div>
</div>
)
}
export default DefaultValue

View File

@@ -0,0 +1,67 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useUpdateNodeInternals } from 'reactflow'
import { NodeSourceHandle } from '../node-handle'
import { ErrorHandleTypeEnum } from './types'
import type { Node } from '@/app/components/workflow/types'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ErrorHandleOnNodeProps = Pick<Node, 'id' | 'data'>
const ErrorHandleOnNode = ({
id,
data,
}: ErrorHandleOnNodeProps) => {
const { t } = useTranslation()
const { error_strategy } = data
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => {
if (error_strategy === ErrorHandleTypeEnum.failBranch)
updateNodeInternals(id)
}, [error_strategy, id, updateNodeInternals])
if (!error_strategy)
return null
return (
<div className='relative px-3 pb-2 pt-1'>
<div className={cn(
'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]',
data._runningStatus === NodeRunningStatus.Exception && 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover',
)}>
<div className='system-xs-medium-uppercase text-text-tertiary'>
{t('workflow.common.onFailure')}
</div>
<div className={cn(
'system-xs-medium text-text-secondary',
data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning',
)}>
{
error_strategy === ErrorHandleTypeEnum.defaultValue && (
t('workflow.nodes.common.errorHandle.defaultValue.output')
)
}
{
error_strategy === ErrorHandleTypeEnum.failBranch && (
t('workflow.nodes.common.errorHandle.failBranch.title')
)
}
</div>
{
error_strategy === ErrorHandleTypeEnum.failBranch && (
<NodeSourceHandle
id={id}
data={data}
handleId={ErrorHandleTypeEnum.failBranch}
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2 after:!bg-workflow-link-line-failure-button-bg'
nodeSelectorClassName='!bg-workflow-link-line-failure-button-bg'
/>
)
}
</div>
</div>
)
}
export default ErrorHandleOnNode

View File

@@ -0,0 +1,91 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Collapse from '../collapse'
import { ErrorHandleTypeEnum } from './types'
import ErrorHandleTypeSelector from './error-handle-type-selector'
import FailBranchCard from './fail-branch-card'
import DefaultValue from './default-value'
import {
useDefaultValue,
useErrorHandle,
} from './hooks'
import type { DefaultValueForm } from './types'
import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
import Tooltip from '@/app/components/base/tooltip'
type ErrorHandleProps = Pick<Node, 'id' | 'data'>
const ErrorHandle = ({
id,
data,
}: ErrorHandleProps) => {
const { t } = useTranslation()
const { error_strategy, default_value } = data
const {
collapsed,
setCollapsed,
handleErrorHandleTypeChange,
} = useErrorHandle(id, data)
const { handleFormChange } = useDefaultValue(id)
const getHandleErrorHandleTypeChange = useCallback((data: CommonNodeType) => {
return (value: ErrorHandleTypeEnum) => {
handleErrorHandleTypeChange(value, data)
}
}, [handleErrorHandleTypeChange])
const getHandleFormChange = useCallback((data: CommonNodeType) => {
return (v: DefaultValueForm) => {
handleFormChange(v, data)
}
}, [handleFormChange])
return (
<>
<div className='py-4'>
<Collapse
disabled={!error_strategy}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
trigger={
collapseIcon => (
<div className='flex grow items-center justify-between pr-4'>
<div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
{t('workflow.nodes.common.errorHandle.title')}
</div>
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} />
{collapseIcon}
</div>
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
)}
>
<>
{
error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && (
<FailBranchCard />
)
}
{
error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && (
<DefaultValue
forms={default_value}
onFormChange={getHandleFormChange(data)}
/>
)
}
</>
</Collapse>
</div>
</>
)
}
export default ErrorHandle

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { RiAlertFill } from '@remixicon/react'
import { ErrorHandleTypeEnum } from './types'
type ErrorHandleTipProps = {
type?: ErrorHandleTypeEnum
}
const ErrorHandleTip = ({
type,
}: ErrorHandleTipProps) => {
const { t } = useTranslation()
const text = useMemo(() => {
if (type === ErrorHandleTypeEnum.failBranch)
return t('workflow.nodes.common.errorHandle.failBranch.inLog')
if (type === ErrorHandleTypeEnum.defaultValue)
return t('workflow.nodes.common.errorHandle.defaultValue.inLog')
}, [t, type])
if (!type)
return null
return (
<div
className='relative flex rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 pr-[52px] shadow-xs'
>
<div
className='absolute inset-0 rounded-lg opacity-40'
style={{
background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)',
}}
></div>
<RiAlertFill className='mr-1 h-4 w-4 shrink-0 text-text-warning-secondary' />
<div className='system-xs-medium grow text-text-primary'>
{text}
</div>
</div>
)
}
export default ErrorHandleTip

View File

@@ -0,0 +1,97 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { ErrorHandleTypeEnum } from './types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
type ErrorHandleTypeSelectorProps = {
value: ErrorHandleTypeEnum
onSelected: (value: ErrorHandleTypeEnum) => void
}
const ErrorHandleTypeSelector = ({
value,
onSelected,
}: ErrorHandleTypeSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: ErrorHandleTypeEnum.none,
label: t('workflow.nodes.common.errorHandle.none.title'),
description: t('workflow.nodes.common.errorHandle.none.desc'),
},
{
value: ErrorHandleTypeEnum.defaultValue,
label: t('workflow.nodes.common.errorHandle.defaultValue.title'),
description: t('workflow.nodes.common.errorHandle.defaultValue.desc'),
},
{
value: ErrorHandleTypeEnum.failBranch,
label: t('workflow.nodes.common.errorHandle.failBranch.title'),
description: t('workflow.nodes.common.errorHandle.failBranch.desc'),
},
]
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}>
<Button
size='small'
>
{selectedOption?.label}
<RiArrowDownSLine className='h-3.5 w-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onSelected(option.value)
setOpen(false)
}}
>
<div className='mr-1 w-4 shrink-0'>
{
value === option.value && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)
}
</div>
<div className='grow'>
<div className='system-sm-semibold mb-0.5 text-text-secondary'>{option.label}</div>
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
</div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ErrorHandleTypeSelector

View File

@@ -0,0 +1,34 @@
import { RiMindMap } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
const FailBranchCard = () => {
const { t } = useTranslation()
const docLink = useDocLink()
return (
<div className='px-4 pt-2'>
<div className='rounded-[10px] bg-workflow-process-bg p-4'>
<div className='mb-2 flex h-8 w-8 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg'>
<RiMindMap className='h-5 w-5 text-text-tertiary' />
</div>
<div className='system-sm-medium mb-1 text-text-secondary'>
{t('workflow.nodes.common.errorHandle.failBranch.customize')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('workflow.nodes.common.errorHandle.failBranch.customizeTip')}
&nbsp;
<a
href={docLink('/guides/workflow/error-handling/error-type')}
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</div>
</div>
</div>
)
}
export default FailBranchCard

View File

@@ -0,0 +1,123 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import { ErrorHandleTypeEnum } from './types'
import type { DefaultValueForm } from './types'
import { getDefaultValue } from './utils'
import type {
CommonNodeType,
} from '@/app/components/workflow/types'
import {
useEdgesInteractions,
useNodeDataUpdate,
} from '@/app/components/workflow/hooks'
export const useDefaultValue = (
id: string,
) => {
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const handleFormChange = useCallback((
{
key,
value,
type,
}: DefaultValueForm,
data: CommonNodeType,
) => {
const default_value = data.default_value || []
const index = default_value.findIndex(form => form.key === key)
if (index > -1) {
const newDefaultValue = [...default_value]
newDefaultValue[index].value = value
handleNodeDataUpdateWithSyncDraft({
id,
data: {
default_value: newDefaultValue,
},
})
return
}
handleNodeDataUpdateWithSyncDraft({
id,
data: {
default_value: [
...default_value,
{
key,
value,
type,
},
],
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
return {
handleFormChange,
}
}
export const useErrorHandle = (
id: string,
data: CommonNodeType,
) => {
const initCollapsed = useMemo(() => {
if (data.error_strategy === ErrorHandleTypeEnum.none)
return true
return false
}, [data.error_strategy])
const [collapsed, setCollapsed] = useState(initCollapsed)
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
const handleErrorHandleTypeChange = useCallback((value: ErrorHandleTypeEnum, data: CommonNodeType) => {
if (data.error_strategy === value)
return
if (value === ErrorHandleTypeEnum.none) {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
error_strategy: undefined,
default_value: undefined,
},
})
setCollapsed(true)
handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch)
}
if (value === ErrorHandleTypeEnum.failBranch) {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
error_strategy: value,
default_value: undefined,
},
})
setCollapsed(false)
}
if (value === ErrorHandleTypeEnum.defaultValue) {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
error_strategy: value,
default_value: getDefaultValue(data),
},
})
setCollapsed(false)
handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch)
}
}, [id, handleNodeDataUpdateWithSyncDraft, handleEdgeDeleteByDeleteBranch])
return {
collapsed,
setCollapsed,
handleErrorHandleTypeChange,
}
}

View File

@@ -0,0 +1,13 @@
import type { VarType } from '@/app/components/workflow/types'
export enum ErrorHandleTypeEnum {
none = 'none',
failBranch = 'fail-branch',
defaultValue = 'default-value',
}
export type DefaultValueForm = {
key: string
type: VarType
value?: any
}

View File

@@ -0,0 +1,83 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
import {
BlockEnum,
VarType,
} from '@/app/components/workflow/types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
const getDefaultValueByType = (type: VarType) => {
if (type === VarType.string)
return ''
if (type === VarType.number)
return 0
if (type === VarType.object)
return '{}'
if (type === VarType.arrayObject || type === VarType.arrayString || type === VarType.arrayNumber || type === VarType.arrayFile)
return '[]'
return ''
}
export const getDefaultValue = (data: CommonNodeType) => {
const { type } = data
if (type === BlockEnum.LLM) {
return [{
key: 'text',
type: VarType.string,
value: getDefaultValueByType(VarType.string),
}]
}
if (type === BlockEnum.HttpRequest) {
return [
{
key: 'body',
type: VarType.string,
value: getDefaultValueByType(VarType.string),
},
{
key: 'status_code',
type: VarType.number,
value: getDefaultValueByType(VarType.number),
},
{
key: 'headers',
type: VarType.object,
value: getDefaultValueByType(VarType.object),
},
]
}
if (type === BlockEnum.Tool) {
return [
{
key: 'text',
type: VarType.string,
value: getDefaultValueByType(VarType.string),
},
{
key: 'json',
type: VarType.arrayObject,
value: getDefaultValueByType(VarType.arrayObject),
},
]
}
if (type === BlockEnum.Code) {
const { outputs } = data as CodeNodeType
return Object.keys(outputs).map((key) => {
return {
key,
type: outputs[key].type,
value: getDefaultValueByType(outputs[key].type),
}
})
}
return []
}

View File

@@ -0,0 +1,65 @@
'use client'
import type { FC, ReactNode } from 'react'
import React from 'react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
className?: string
title: ReactNode
tooltip?: ReactNode
isSubTitle?: boolean
supportFold?: boolean
children?: React.JSX.Element | string | null
operations?: React.JSX.Element
inline?: boolean
required?: boolean
}
const Field: FC<Props> = ({
className,
title,
isSubTitle,
tooltip,
children,
operations,
inline,
supportFold,
required,
}) => {
const [fold, {
toggle: toggleFold,
}] = useBoolean(true)
return (
<div className={cn(className, inline && 'flex w-full items-center justify-between')}>
<div
onClick={() => supportFold && toggleFold()}
className={cn('flex items-center justify-between', supportFold && 'cursor-pointer')}>
<div className='flex h-6 items-center'>
<div className={cn(isSubTitle ? 'system-xs-medium-uppercase text-text-tertiary' : 'system-sm-semibold-uppercase text-text-secondary')}>
{title} {required && <span className='text-text-destructive'>*</span>}
</div>
{tooltip && (
<Tooltip
popupContent={tooltip}
popupClassName='ml-1'
triggerClassName='w-4 h-4 ml-1'
/>
)}
</div>
<div className='flex'>
{operations && <div>{operations}</div>}
{supportFold && (
<RiArrowDownSLine className='h-4 w-4 cursor-pointer text-text-tertiary transition-transform' style={{ transform: fold ? 'rotate(-90deg)' : 'rotate(0deg)' }} />
)}
</div>
</div>
{children && (!supportFold || (supportFold && !fold)) && <div className={cn(!inline && 'mt-1')}>{children}</div>}
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,78 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SupportUploadFileTypes } from '../../../types'
import cn from '@/utils/classnames'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import TagInput from '@/app/components/base/tag-input'
import Checkbox from '@/app/components/base/checkbox'
import { FileTypeIcon } from '@/app/components/base/file-uploader'
import { noop } from 'lodash-es'
type Props = {
type: SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video | SupportUploadFileTypes.custom
selected: boolean
onToggle: (type: SupportUploadFileTypes) => void
onCustomFileTypesChange?: (customFileTypes: string[]) => void
customFileTypes?: string[]
}
const FileTypeItem: FC<Props> = ({
type,
selected,
onToggle,
customFileTypes = [],
onCustomFileTypesChange = noop,
}) => {
const { t } = useTranslation()
const handleOnSelect = useCallback(() => {
onToggle(type)
}, [onToggle, type])
const isCustomSelected = type === SupportUploadFileTypes.custom && selected
return (
<div
className={cn(
'cursor-pointer select-none rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg',
!isCustomSelected && 'px-3 py-2',
selected && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg',
!selected && 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
)}
onClick={handleOnSelect}
>
{isCustomSelected
? (
<div>
<div className='flex items-center border-b border-divider-subtle p-3 pb-2'>
<FileTypeIcon className='shrink-0' type={type} size='lg' />
<div className='system-sm-medium mx-2 grow text-text-primary'>{t(`appDebug.variableConfig.file.${type}.name`)}</div>
<Checkbox className='shrink-0' checked={selected} />
</div>
<div className='p-3' onClick={e => e.stopPropagation()}>
<TagInput
items={customFileTypes}
onChange={onCustomFileTypesChange}
placeholder={t('appDebug.variableConfig.file.custom.createPlaceholder')!}
/>
</div>
</div>
)
: (
<div className='flex items-center'>
<FileTypeIcon className='shrink-0' type={type} size='lg' />
<div className='mx-2 grow'>
<div className='system-sm-medium text-text-primary'>{t(`appDebug.variableConfig.file.${type}.name`)}</div>
<div className='system-2xs-regular-uppercase mt-1 text-text-tertiary'>{type !== SupportUploadFileTypes.custom ? FILE_EXTS[type].join(', ') : t('appDebug.variableConfig.file.custom.description')}</div>
</div>
<Checkbox className='shrink-0' checked={selected} />
</div>
)}
</div>
)
}
export default React.memo(FileTypeItem)

View File

@@ -0,0 +1,199 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import useSWR from 'swr'
import { produce } from 'immer'
import { useTranslation } from 'react-i18next'
import type { UploadFileSetting } from '../../../types'
import { SupportUploadFileTypes } from '../../../types'
import OptionCard from './option-card'
import FileTypeItem from './file-type-item'
import InputNumberWithSlider from './input-number-with-slider'
import Field from '@/app/components/app/configuration/config-var/config-modal/field'
import { TransferMethod } from '@/types/app'
import { fetchFileUploadConfig } from '@/service/common'
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { formatFileSize } from '@/utils/format'
type Props = {
payload: UploadFileSetting
isMultiple: boolean
inFeaturePanel?: boolean
hideSupportFileType?: boolean
onChange: (payload: UploadFileSetting) => void
}
const FileUploadSetting: FC<Props> = ({
payload,
isMultiple,
inFeaturePanel = false,
hideSupportFileType = false,
onChange,
}) => {
const { t } = useTranslation()
const {
allowed_file_upload_methods,
max_length,
allowed_file_types,
allowed_file_extensions,
} = payload
const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
const {
imgSizeLimit,
docSizeLimit,
audioSizeLimit,
videoSizeLimit,
maxFileUploadLimit,
} = useFileSizeLimit(fileUploadConfigResponse)
const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => {
const newPayload = produce(payload, (draft) => {
if (type === SupportUploadFileTypes.custom) {
if (!draft.allowed_file_types.includes(SupportUploadFileTypes.custom))
draft.allowed_file_types = [SupportUploadFileTypes.custom]
else
draft.allowed_file_types = draft.allowed_file_types.filter(v => v !== type)
}
else {
draft.allowed_file_types = draft.allowed_file_types.filter(v => v !== SupportUploadFileTypes.custom)
if (draft.allowed_file_types.includes(type))
draft.allowed_file_types = draft.allowed_file_types.filter(v => v !== type)
else
draft.allowed_file_types.push(type)
}
})
onChange(newPayload)
}, [onChange, payload])
const handleUploadMethodChange = useCallback((method: TransferMethod) => {
return () => {
const newPayload = produce(payload, (draft) => {
if (method === TransferMethod.all)
draft.allowed_file_upload_methods = [TransferMethod.local_file, TransferMethod.remote_url]
else
draft.allowed_file_upload_methods = [method]
})
onChange(newPayload)
}
}, [onChange, payload])
const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => {
const newPayload = produce(payload, (draft) => {
draft.allowed_file_extensions = customFileTypes.map((v) => {
return v
})
})
onChange(newPayload)
}, [onChange, payload])
const handleMaxUploadNumLimitChange = useCallback((value: number) => {
const newPayload = produce(payload, (draft) => {
draft.max_length = value
})
onChange(newPayload)
}, [onChange, payload])
return (
<div>
{!inFeaturePanel && (
<Field
title={t('appDebug.variableConfig.file.supportFileTypes')}
>
<div className='space-y-1'>
{
[SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => (
<FileTypeItem
key={type}
type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video}
selected={allowed_file_types.includes(type)}
onToggle={handleSupportFileTypeChange}
/>
))
}
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected={allowed_file_types.includes(SupportUploadFileTypes.custom)}
onToggle={handleSupportFileTypeChange}
customFileTypes={allowed_file_extensions}
onCustomFileTypesChange={handleCustomFileTypesChange}
/>
</div>
</Field>
)}
<Field
title={t('appDebug.variableConfig.uploadFileTypes')}
className='mt-4'
>
<div className='grid grid-cols-3 gap-2'>
<OptionCard
title={t('appDebug.variableConfig.localUpload')}
selected={allowed_file_upload_methods.length === 1 && allowed_file_upload_methods.includes(TransferMethod.local_file)}
onSelect={handleUploadMethodChange(TransferMethod.local_file)}
/>
<OptionCard
title="URL"
selected={allowed_file_upload_methods.length === 1 && allowed_file_upload_methods.includes(TransferMethod.remote_url)}
onSelect={handleUploadMethodChange(TransferMethod.remote_url)}
/>
<OptionCard
title={t('appDebug.variableConfig.both')}
selected={allowed_file_upload_methods.includes(TransferMethod.local_file) && allowed_file_upload_methods.includes(TransferMethod.remote_url)}
onSelect={handleUploadMethodChange(TransferMethod.all)}
/>
</div>
</Field>
{isMultiple && (
<Field
className='mt-4'
title={t('appDebug.variableConfig.maxNumberOfUploads')!}
>
<div>
<div className='body-xs-regular mb-1.5 text-text-tertiary'>{t('appDebug.variableConfig.maxNumberTip', {
imgLimit: formatFileSize(imgSizeLimit),
docLimit: formatFileSize(docSizeLimit),
audioLimit: formatFileSize(audioSizeLimit),
videoLimit: formatFileSize(videoSizeLimit),
})}</div>
<InputNumberWithSlider
value={max_length}
min={1}
max={maxFileUploadLimit}
onChange={handleMaxUploadNumLimitChange}
/>
</div>
</Field>
)}
{inFeaturePanel && !hideSupportFileType && (
<Field
title={t('appDebug.variableConfig.file.supportFileTypes')}
className='mt-4'
>
<div className='space-y-1'>
{
[SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => (
<FileTypeItem
key={type}
type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video}
selected={allowed_file_types.includes(type)}
onToggle={handleSupportFileTypeChange}
/>
))
}
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected={allowed_file_types.includes(SupportUploadFileTypes.custom)}
onToggle={handleSupportFileTypeChange}
customFileTypes={allowed_file_extensions}
onCustomFileTypesChange={handleCustomFileTypesChange}
/>
</div>
</Field>
)}
</div>
)
}
export default React.memo(FileUploadSetting)

View File

@@ -0,0 +1,35 @@
'use client'
import type { FC } from 'react'
import cn from '@/utils/classnames'
type Props = {
value: boolean
onChange: (value: boolean) => void
}
const FormInputBoolean: FC<Props> = ({
value,
onChange,
}) => {
return (
<div className='flex w-full space-x-1'>
<div
className={cn(
'system-sm-regular flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
!value && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
value && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
)}
onClick={() => onChange(true)}
>True</div>
<div
className={cn(
'system-sm-regular flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
value && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
!value && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
)}
onClick={() => onChange(false)}
>False</div>
</div>
)
}
export default FormInputBoolean

View File

@@ -0,0 +1,599 @@
'use client'
import type { FC } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { type ResourceVarInputs, VarKindType } from '../types'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { useFetchDynamicOptions } from '@/service/use-plugins'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { Tool } from '@/app/components/tools/types'
import FormInputTypeSwitch from './form-input-type-switch'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import cn from '@/utils/classnames'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import type { Event } from '@/app/components/tools/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import CheckboxList from '@/app/components/base/checkbox-list'
import FormInputBoolean from './form-input-boolean'
type Props = {
readOnly: boolean
nodeId: string
schema: CredentialFormSchema
value: ResourceVarInputs
onChange: (value: any) => void
inPanel?: boolean
currentTool?: Tool | Event
currentProvider?: ToolWithProvider | TriggerWithProvider
showManageInputField?: boolean
onManageInputField?: () => void
extraParams?: Record<string, any>
providerType?: string
disableVariableInsertion?: boolean
}
const FormInputItem: FC<Props> = ({
readOnly,
nodeId,
schema,
value,
onChange,
inPanel,
currentTool,
currentProvider,
showManageInputField,
onManageInputField,
extraParams,
providerType,
disableVariableInsertion = false,
}) => {
const language = useLanguage()
const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null)
const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false)
const {
placeholder,
variable,
type,
_type,
default: defaultValue,
options,
multiple,
scope,
} = schema as any
const varInput = value[variable]
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isBoolean = _type === FormTypeEnum.boolean
const isCheckbox = _type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isDynamicSelect = type === FormTypeEnum.dynamicSelect
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
const isMultipleSelect = multiple && (isSelect || isDynamicSelect)
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
},
})
const targetVarType = () => {
if (isString)
return VarType.string
else if (isNumber)
return VarType.number
else if (type === FormTypeEnum.files)
return VarType.arrayFile
else if (type === FormTypeEnum.file)
return VarType.file
else if (isSelect)
return VarType.string
// else if (isAppSelector)
// return VarType.appSelector
// else if (isModelSelector)
// return VarType.modelSelector
else if (isBoolean)
return VarType.boolean
else if (isObject)
return VarType.object
else if (isArray)
return VarType.arrayObject
else
return VarType.string
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: any) => varPayload.type === VarType.number
else if (isString)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: any) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: any) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
const getVarKindType = () => {
if (isFile)
return VarKindType.variable
if (isSelect || isDynamicSelect || isBoolean || isNumber || isArray || isObject)
return VarKindType.constant
if (isString)
return VarKindType.mixed
}
// Fetch dynamic options hook for tools
const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions(
currentProvider?.plugin_id || '',
currentProvider?.name || '',
currentTool?.name || '',
variable || '',
providerType,
extraParams,
)
// Fetch dynamic options hook for triggers
const { data: triggerDynamicOptions, isLoading: isTriggerOptionsLoading } = useTriggerPluginDynamicOptions({
plugin_id: currentProvider?.plugin_id || '',
provider: currentProvider?.name || '',
action: currentTool?.name || '',
parameter: variable || '',
extra: extraParams,
credential_id: currentProvider?.credential_id || '',
}, isDynamicSelect && providerType === PluginCategoryEnum.trigger && !!currentTool && !!currentProvider)
// Computed values for dynamic options (unified for triggers and tools)
const triggerOptions = triggerDynamicOptions?.options
const dynamicOptions = providerType === PluginCategoryEnum.trigger
? triggerOptions ?? toolsOptions
: toolsOptions
const isLoadingOptions = providerType === PluginCategoryEnum.trigger
? (isTriggerOptionsLoading || isLoadingToolsOptions)
: isLoadingToolsOptions
// Fetch dynamic options for tools only (triggers use hook directly)
useEffect(() => {
const fetchPanelDynamicOptions = async () => {
if (isDynamicSelect && currentTool && currentProvider && (providerType === PluginCategoryEnum.tool || providerType === PluginCategoryEnum.trigger)) {
setIsLoadingToolsOptions(true)
try {
const data = await fetchDynamicOptions()
setToolsOptions(data?.options || [])
}
catch (error) {
console.error('Failed to fetch dynamic options:', error)
setToolsOptions([])
}
finally {
setIsLoadingToolsOptions(false)
}
}
}
fetchPanelDynamicOptions()
}, [
isDynamicSelect,
currentTool?.name,
currentProvider?.name,
variable,
extraParams,
providerType,
fetchDynamicOptions,
])
const handleTypeChange = (newType: string) => {
if (newType === VarKindType.variable) {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.variable,
value: '',
},
})
}
else {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.constant,
value: defaultValue,
},
})
}
}
const handleValueChange = (newValue: any) => {
onChange({
...value,
[variable]: {
...varInput,
type: getVarKindType(),
value: isNumber ? Number.parseFloat(newValue) : newValue,
},
})
}
const getSelectedLabels = (selectedValues: any[]) => {
if (!selectedValues || selectedValues.length === 0)
return ''
const optionsList = isDynamicSelect ? (dynamicOptions || options || []) : (options || [])
const selectedOptions = optionsList.filter((opt: any) =>
selectedValues.includes(opt.value),
)
if (selectedOptions.length <= 2) {
return selectedOptions
.map((opt: any) => opt.label?.[language] || opt.label?.en_US || opt.value)
.join(', ')
}
return `${selectedOptions.length} selected`
}
const handleAppOrModelSelect = (newValue: any) => {
onChange({
...value,
[variable]: {
...varInput,
value: newValue,
},
})
}
const handleVariableSelectorChange = (newValue: ValueSelector | string, variable: string) => {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.variable,
value: newValue || '',
},
})
}
const availableCheckboxOptions = useMemo(() => (
(options || []).filter((option: { show_on?: Array<{ variable: string; value: any }> }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value === showOnItem.value || value[showOnItem.variable] === showOnItem.value)
return true
})
), [options, value])
const checkboxListOptions = useMemo(() => (
availableCheckboxOptions.map((option: { value: string; label: Record<string, string> }) => ({
value: option.value,
label: option.label?.[language] || option.label?.en_US || option.value,
}))
), [availableCheckboxOptions, language])
const checkboxListValue = useMemo(() => {
let current: string[] = []
if (Array.isArray(varInput?.value))
current = varInput.value as string[]
else if (typeof varInput?.value === 'string')
current = [varInput.value as string]
else if (Array.isArray(defaultValue))
current = defaultValue as string[]
const allowedValues = new Set(availableCheckboxOptions.map((option: { value: string }) => option.value))
return current.filter(item => allowedValues.has(item))
}, [varInput?.value, defaultValue, availableCheckboxOptions])
const handleCheckboxListChange = (selected: string[]) => {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.constant,
value: selected,
},
})
}
return (
<div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}>
{showTypeSwitch && (
<FormInputTypeSwitch value={varInput?.type || VarKindType.constant} onChange={handleTypeChange} />
)}
{isString && (
<MixedVariableTextInput
readOnly={readOnly}
value={varInput?.value as string || ''}
onChange={handleValueChange}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
showManageInputField={showManageInputField}
onManageInputField={onManageInputField}
disableVariableInsertion={disableVariableInsertion}
/>
)}
{isNumber && isConstant && (
<Input
className='h-8 grow'
type='number'
value={Number.isNaN(varInput?.value) ? '' : varInput?.value}
onChange={e => handleValueChange(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
)}
{isCheckbox && isConstant && (
<CheckboxList
title={schema.label?.[language] || schema.label?.en_US || variable}
value={checkboxListValue}
onChange={handleCheckboxListChange}
options={checkboxListOptions}
disabled={readOnly}
maxHeight='200px'
/>
)}
{isBoolean && isConstant && (
<FormInputBoolean
value={varInput?.value as boolean}
onChange={handleValueChange}
/>
)}
{isSelect && isConstant && !isMultipleSelect && (
<SimpleSelect
wrapperClassName='h-8 grow'
disabled={readOnly}
defaultValue={varInput?.value}
items={options.filter((option: { show_on: any[] }) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({
value: option.value,
name: option.label[language] || option.label.en_US,
icon: option.icon,
}))}
onSelect={item => handleValueChange(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
renderOption={options.some((opt: any) => opt.icon) ? ({ item }) => (
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span>{item.name}</span>
</div>
) : undefined}
/>
)}
{isSelect && isConstant && isMultipleSelect && (
<Listbox
multiple
value={varInput?.value || []}
onChange={handleValueChange}
disabled={readOnly}
>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={cn('system-sm-regular block truncate text-left',
varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder',
)}>
{getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{options.filter((option: { show_on: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
focus && 'bg-state-base-hover',
)
}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.icon && (
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{option.label[language] || option.label.en_US}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
)}
{isDynamicSelect && !isMultipleSelect && (
<SimpleSelect
wrapperClassName='h-8 grow'
disabled={readOnly || isLoadingOptions}
defaultValue={varInput?.value}
items={(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({
value: option.value,
name: option.label[language] || option.label.en_US,
icon: option.icon,
}))}
onSelect={item => handleValueChange(item.value as string)}
placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)}
renderOption={({ item }) => (
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span>{item.name}</span>
</div>
)}
/>
)}
{isDynamicSelect && isMultipleSelect && (
<Listbox
multiple
value={varInput?.value || []}
onChange={handleValueChange}
disabled={readOnly || isLoadingOptions}
>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={cn('system-sm-regular block truncate text-left',
isLoadingOptions ? 'text-components-input-text-placeholder'
: varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder',
)}>
{isLoadingOptions
? 'Loading...'
: getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoadingOptions ? (
<RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />
) : (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)}
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
focus && 'bg-state-base-hover',
)
}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.icon && (
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{option.label[language] || option.label.en_US}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
)}
{isShowJSONEditor && isConstant && (
<div className='mt-1 w-full'>
<CodeEditor
title='JSON'
value={varInput?.value as any}
isExpand
isInNode
language={CodeLanguage.json}
onChange={handleValueChange}
className='w-full'
placeholder={<div className='whitespace-pre'>{placeholder?.[language] || placeholder?.en_US}</div>}
/>
</div>
)}
{isAppSelector && (
<AppSelector
disabled={readOnly}
scope={scope || 'all'}
value={varInput?.value}
onSelect={handleAppOrModelSelect}
/>
)}
{isModelSelector && isConstant && (
<ModelParameterModal
popupClassName='!w-[387px]'
isAdvancedMode
isInWorkflow
value={varInput?.value}
setModel={handleAppOrModelSelect}
readonly={readOnly}
scope={scope}
/>
)}
{showVariableSelector && (
<VarReferencePicker
zIndex={inPanel ? 1000 : undefined}
className='h-8 grow'
readonly={readOnly}
isShowNodeName
nodeId={nodeId}
value={varInput?.value || []}
onChange={value => handleVariableSelectorChange(value, variable)}
filterVar={getFilterVar()}
schema={schema}
valueTypePlaceHolder={targetVarType()}
currentTool={currentTool}
currentProvider={currentProvider}
isFilterFileVar={isBoolean}
/>
)}
</div>
)
}
export default FormInputItem

View File

@@ -0,0 +1,47 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiEditLine,
} from '@remixicon/react'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import Tooltip from '@/app/components/base/tooltip'
import { VarType } from '@/app/components/workflow/nodes/tool/types'
import cn from '@/utils/classnames'
type Props = {
value: VarType
onChange: (value: VarType) => void
}
const FormInputTypeSwitch: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div className='inline-flex h-8 shrink-0 gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5'>
<Tooltip
popupContent={value === VarType.variable ? '' : t('workflow.nodes.common.typeSwitch.variable')}
>
<div
className={cn('cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover', value === VarType.variable && 'bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg')}
onClick={() => onChange(VarType.variable)}
>
<Variable02 className='h-4 w-4' />
</div>
</Tooltip>
<Tooltip
popupContent={value === VarType.constant ? '' : t('workflow.nodes.common.typeSwitch.input')}
>
<div
className={cn('cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover', value === VarType.constant && 'bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg')}
onClick={() => onChange(VarType.constant)}
>
<RiEditLine className='h-4 w-4' />
</div>
</Tooltip>
</div>
)
}
export default FormInputTypeSwitch

View File

@@ -0,0 +1,25 @@
import classNames from '@/utils/classnames'
import type { ComponentProps, FC, PropsWithChildren, ReactNode } from 'react'
export type GroupLabelProps = ComponentProps<'div'>
export const GroupLabel: FC<GroupLabelProps> = (props) => {
const { children, className, ...rest } = props
return <div {...rest} className={classNames('system-2xs-medium-uppercase mb-1 text-text-tertiary', className)}>
{children}
</div>
}
export type GroupProps = PropsWithChildren<{
label: ReactNode
}>
export const Group: FC<GroupProps> = (props) => {
const { children, label } = props
return <div className={classNames('py-1')}>
{label}
<div className='space-y-0.5'>
{children}
</div>
</div>
}

View File

@@ -0,0 +1,36 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { RiBookOpenLine } from '@remixicon/react'
import { useNodeHelpLink } from '../hooks/use-node-help-link'
import TooltipPlus from '@/app/components/base/tooltip'
import type { BlockEnum } from '@/app/components/workflow/types'
type HelpLinkProps = {
nodeType: BlockEnum
}
const HelpLink = ({
nodeType,
}: HelpLinkProps) => {
const { t } = useTranslation()
const link = useNodeHelpLink(nodeType)
if (!link)
return null
return (
<TooltipPlus
popupContent={t('common.userProfile.helpCenter')}
>
<a
href={link}
target='_blank'
className='mr-1 flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-base-hover'
>
<RiBookOpenLine className='h-4 w-4 text-gray-500' />
</a>
</TooltipPlus>
)
}
export default memo(HelpLink)

View File

@@ -0,0 +1,27 @@
'use client'
import type { FC, ReactNode } from 'react'
import React from 'react'
type Props = {
title: string
content: ReactNode
}
const InfoPanel: FC<Props> = ({
title,
content,
}) => {
return (
<div>
<div className='flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-[5px] py-[3px]'>
<div className='system-2xs-semibold-uppercase uppercase text-text-secondary'>
{title}
</div>
<div className='system-xs-regular break-words text-text-tertiary'>
{content}
</div>
</div>
</div>
)
}
export default React.memo(InfoPanel)

View File

@@ -0,0 +1,12 @@
import { RiAddLine } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
const Add = () => {
return (
<ActionButton>
<RiAddLine className='h-4 w-4' />
</ActionButton>
)
}
export default Add

View File

@@ -0,0 +1,24 @@
import { BoxGroupField } from '@/app/components/workflow/nodes/_base/components/layout'
import Add from './add'
const InputField = () => {
return (
<BoxGroupField
fieldProps={{
supportCollapse: true,
fieldTitleProps: {
title: 'input field',
operation: <Add />,
},
}}
boxGroupProps={{
boxProps: {
withBorderBottom: true,
},
}}
>
input field
</BoxGroupField>
)
}
export default InputField

View File

@@ -0,0 +1,65 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import Slider from '@/app/components/base/slider'
export type InputNumberWithSliderProps = {
value: number
defaultValue?: number
min?: number
max?: number
readonly?: boolean
onChange: (value: number) => void
}
const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
value,
defaultValue = 0,
min,
max,
readonly,
onChange,
}) => {
const handleBlur = useCallback(() => {
if (value === undefined || value === null) {
onChange(defaultValue)
return
}
if (max !== undefined && value > max) {
onChange(max)
return
}
if (min !== undefined && value < min)
onChange(min)
}, [defaultValue, max, min, onChange, value])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(Number.parseFloat(e.target.value))
}, [onChange])
return (
<div className='flex h-8 items-center justify-between space-x-2'>
<input
value={value}
className='block h-8 w-12 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-[13px] text-components-input-text-filled outline-none'
type='number'
min={min}
max={max}
step={1}
onChange={handleChange}
onBlur={handleBlur}
disabled={readonly}
/>
<Slider
className='grow'
value={value}
min={min}
max={max}
step={1}
onChange={onChange}
disabled={readonly}
/>
</div>
)
}
export default React.memo(InputNumberWithSlider)

View File

@@ -0,0 +1,134 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import PromptEditor from '@/app/components/base/prompt-editor'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import Tooltip from '@/app/components/base/tooltip'
import { noop } from 'lodash-es'
import { useStore } from '@/app/components/workflow/store'
type Props = {
instanceId?: string
className?: string
placeholder?: string
placeholderClassName?: string
promptMinHeightClassName?: string
value: string
onChange: (value: string) => void
onFocusChange?: (value: boolean) => void
readOnly?: boolean
justVar?: boolean
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
insertVarTipToLeft?: boolean
}
const Editor: FC<Props> = ({
instanceId,
className,
placeholder,
placeholderClassName,
promptMinHeightClassName = 'min-h-[20px]',
value,
onChange,
onFocusChange,
readOnly,
nodesOutputVars,
availableNodes = [],
insertVarTipToLeft,
}) => {
const { t } = useTranslation()
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean(false)
useEffect(() => {
onFocusChange?.(isFocus)
}, [isFocus])
const pipelineId = useStore(s => s.pipelineId)
const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel)
return (
<div className={cn(className, 'relative')}>
<>
<PromptEditor
instanceId={instanceId}
className={cn(promptMinHeightClassName, '!leading-[18px]')}
placeholder={placeholder}
placeholderClassName={placeholderClassName}
value={value}
contextBlock={{
show: false,
selectable: false,
datasets: [],
onAddContext: noop,
}}
historyBlock={{
show: false,
selectable: false,
history: {
user: 'Human',
assistant: 'Assistant',
},
onEditRole: noop,
}}
queryBlock={{
show: false,
selectable: false,
}}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
width: node.width,
height: node.height,
position: node.position,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
showManageInputField: !!pipelineId,
onManageInputField: () => setShowInputFieldPanel?.(true),
}}
onChange={onChange}
editable={!readOnly}
onBlur={setBlur}
onFocus={setFocus}
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className='absolute inset-0 z-10'></div>}
{isFocus && (
<div className={cn('absolute z-10', insertVarTipToLeft ? 'left-[-12px] top-1.5' : ' right-1 top-[-9px]')}>
<Tooltip
popupContent={`${t('workflow.common.insertVarTip')}`}
>
<div className='cursor-pointer rounded-[5px] border-[0.5px] border-divider-regular bg-components-badge-white-to-dark p-0.5 shadow-lg'>
<Variable02 className='h-3.5 w-3.5 text-components-button-secondary-accent-text' />
</div>
</Tooltip>
</div>
)}
</>
</div >
)
}
export default React.memo(Editor)

View File

@@ -0,0 +1,43 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import {
RiAlignLeft,
RiBracesLine,
RiCheckboxLine,
RiCheckboxMultipleLine,
RiFileCopy2Line,
RiFileList2Line,
RiHashtag,
RiTextSnippet,
} from '@remixicon/react'
import { InputVarType } from '../../../types'
type Props = {
className?: string
type: InputVarType
}
const getIcon = (type: InputVarType) => {
return ({
[InputVarType.textInput]: RiTextSnippet,
[InputVarType.paragraph]: RiAlignLeft,
[InputVarType.select]: RiCheckboxMultipleLine,
[InputVarType.number]: RiHashtag,
[InputVarType.checkbox]: RiCheckboxLine,
[InputVarType.jsonObject]: RiBracesLine,
[InputVarType.singleFile]: RiFileList2Line,
[InputVarType.multiFiles]: RiFileCopy2Line,
} as any)[type] || RiTextSnippet
}
const InputVarTypeIcon: FC<Props> = ({
className,
type,
}) => {
const Icon = getIcon(type)
return (
<Icon className={className} />
)
}
export default React.memo(InputVarTypeIcon)

View File

@@ -0,0 +1,104 @@
import Button from '@/app/components/base/button'
import { RiInstallLine, RiLoader2Line } from '@remixicon/react'
import type { ComponentProps, MouseEventHandler } from 'react'
import { useState } from 'react'
import classNames from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import { TaskStatus } from '@/app/components/plugins/types'
import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/use-plugins'
type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | 'loading'> & {
uniqueIdentifier: string
extraIdentifiers?: string[]
onSuccess?: () => void
}
export const InstallPluginButton = (props: InstallPluginButtonProps) => {
const {
className,
uniqueIdentifier,
extraIdentifiers = [],
onSuccess,
...rest
} = props
const { t } = useTranslation()
const identifiers = Array.from(new Set(
[uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)),
))
const manifest = useCheckInstalled({
pluginIds: identifiers,
enabled: identifiers.length > 0,
})
const install = useInstallPackageFromMarketPlace()
const [isTracking, setIsTracking] = useState(false)
const isLoading = manifest.isLoading || install.isPending || isTracking
const handleInstall: MouseEventHandler = (e) => {
e.stopPropagation()
if (isLoading)
return
setIsTracking(true)
install.mutate(uniqueIdentifier, {
onSuccess: async (response) => {
const finish = async () => {
await manifest.refetch()
onSuccess?.()
setIsTracking(false)
install.reset()
}
if (!response) {
await finish()
return
}
if (response.all_installed) {
await finish()
return
}
const { check } = checkTaskStatus()
try {
const { status } = await check({
taskId: response.task_id,
pluginUniqueIdentifier: uniqueIdentifier,
})
if (status === TaskStatus.failed) {
setIsTracking(false)
install.reset()
return
}
await finish()
}
catch {
setIsTracking(false)
install.reset()
}
},
onError: () => {
setIsTracking(false)
install.reset()
},
})
}
if (!manifest.data) return null
const identifierSet = new Set(identifiers)
const isInstalled = manifest.data.plugins.some(plugin => (
identifierSet.has(plugin.id)
|| (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier))
|| (plugin.plugin_id && identifierSet.has(plugin.plugin_id))
))
if (isInstalled) return null
return <Button
variant={'secondary'}
disabled={isLoading}
{...rest}
onClick={handleInstall}
className={classNames('flex items-center', className)}
>
{!isLoading ? t('workflow.nodes.agent.pluginInstaller.install') : t('workflow.nodes.agent.pluginInstaller.installing')}
{!isLoading ? <RiInstallLine className='ml-1 size-3.5' /> : <RiLoader2Line className='ml-1 size-3.5 animate-spin' />}
</Button>
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { memo } from 'react'
import type {
BoxGroupProps,
FieldProps,
} from '.'
import {
BoxGroup,
Field,
} from '.'
type BoxGroupFieldProps = {
children?: ReactNode
boxGroupProps?: Omit<BoxGroupProps, 'children'>
fieldProps?: Omit<FieldProps, 'children'>
}
export const BoxGroupField = memo(({
children,
fieldProps,
boxGroupProps,
}: BoxGroupFieldProps) => {
return (
<BoxGroup {...boxGroupProps}>
<Field {...fieldProps}>
{children}
</Field>
</BoxGroup>
)
})

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { memo } from 'react'
import {
Box,
Group,
} from '.'
import type {
BoxProps,
GroupProps,
} from '.'
export type BoxGroupProps = {
children?: ReactNode
boxProps?: Omit<BoxProps, 'children'>
groupProps?: Omit<GroupProps, 'children'>
}
export const BoxGroup = memo(({
children,
boxProps,
groupProps,
}: BoxGroupProps) => {
return (
<Box {...boxProps}>
<Group {...groupProps}>
{children}
</Group>
</Box>
)
})

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { memo } from 'react'
import cn from '@/utils/classnames'
export type BoxProps = {
className?: string
children?: ReactNode
withBorderBottom?: boolean
}
export const Box = memo(({
className,
children,
withBorderBottom,
}: BoxProps) => {
return (
<div
className={cn(
'py-2',
withBorderBottom && 'border-b border-divider-subtle',
className,
)}>
{children}
</div>
)
})

View File

@@ -0,0 +1,72 @@
import type { ReactNode } from 'react'
import {
memo,
useState,
} from 'react'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
export type FieldTitleProps = {
title?: string
operation?: ReactNode
subTitle?: string | ReactNode
tooltip?: string
showArrow?: boolean
disabled?: boolean
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}
export const FieldTitle = memo(({
title,
operation,
subTitle,
tooltip,
showArrow,
disabled,
collapsed,
onCollapse,
}: FieldTitleProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
return (
<div className={cn('mb-0.5', !!subTitle && 'mb-1')}>
<div
className='group/collapse flex items-center justify-between py-1'
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)
onCollapse?.(!collapsedMerged)
}
}}
>
<div className='system-sm-semibold-uppercase flex items-center text-text-secondary'>
{title}
{
showArrow && (
<ArrowDownRoundFill
className={cn(
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapsedMerged && 'rotate-[270deg]',
)}
/>
)
}
{
tooltip && (
<Tooltip
popupContent={tooltip}
triggerClassName='w-4 h-4 ml-1'
/>
)
}
</div>
{operation}
</div>
{
subTitle
}
</div>
)
})

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
memo,
useState,
} from 'react'
import type { FieldTitleProps } from '.'
import { FieldTitle } from '.'
export type FieldProps = {
fieldTitleProps?: FieldTitleProps
children?: ReactNode
disabled?: boolean
supportCollapse?: boolean
}
export const Field = memo(({
fieldTitleProps,
children,
supportCollapse,
disabled,
}: FieldProps) => {
const [collapsed, setCollapsed] = useState(false)
return (
<div>
<FieldTitle
{...fieldTitleProps}
collapsed={collapsed}
onCollapse={setCollapsed}
showArrow={supportCollapse}
disabled={disabled}
/>
{supportCollapse && !collapsed && children}
{!supportCollapse && children}
</div>
)
})

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { memo } from 'react'
import type {
FieldProps,
GroupProps,
} from '.'
import {
Field,
Group,
} from '.'
type GroupFieldProps = {
children?: ReactNode
groupProps?: Omit<GroupProps, 'children'>
fieldProps?: Omit<FieldProps, 'children'>
}
export const GroupField = memo(({
children,
fieldProps,
groupProps,
}: GroupFieldProps) => {
return (
<Group {...groupProps}>
<Field {...fieldProps}>
{children}
</Field>
</Group>
)
})

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { memo } from 'react'
import cn from '@/utils/classnames'
export type GroupProps = {
className?: string
children?: ReactNode
withBorderBottom?: boolean
}
export const Group = memo(({
className,
children,
withBorderBottom,
}: GroupProps) => {
return (
<div
className={cn(
'px-4 py-2',
withBorderBottom && 'border-b border-divider-subtle',
className,
)}>
{children}
</div>
)
})

View File

@@ -0,0 +1,7 @@
export * from './box'
export * from './group'
export * from './box-group'
export * from './field-title'
export * from './field'
export * from './group-field'
export * from './box-group-field'

View File

@@ -0,0 +1,18 @@
'use client'
import type { FC } from 'react'
import React from 'react'
type Props = {
children: React.ReactNode
}
const ListNoDataPlaceholder: FC<Props> = ({
children,
}) => {
return (
<div className='system-xs-regular flex min-h-[42px] w-full items-center justify-center rounded-[10px] bg-background-section text-text-tertiary'>
{children}
</div>
)
}
export default React.memo(ListNoDataPlaceholder)

View File

@@ -0,0 +1,22 @@
'use client'
import Tooltip from '@/app/components/base/tooltip'
import { RiAlertFill } from '@remixicon/react'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
const McpToolNotSupportTooltip: FC = () => {
const { t } = useTranslation()
return (
<Tooltip
popupContent={
<div className='w-[256px]'>
{t('plugin.detailPanel.toolSelector.unsupportedMCPTool')}
</div>
}
>
<RiAlertFill className='size-4 text-text-warning-secondary' />
</Tooltip>
)
}
export default React.memo(McpToolNotSupportTooltip)

View File

@@ -0,0 +1,207 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import type { Memory } from '../../../types'
import { MemoryRole } from '../../../types'
import cn from '@/utils/classnames'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Switch from '@/app/components/base/switch'
import Slider from '@/app/components/base/slider'
import Input from '@/app/components/base/input'
const i18nPrefix = 'workflow.nodes.common.memory'
const WINDOW_SIZE_MIN = 1
const WINDOW_SIZE_MAX = 100
const WINDOW_SIZE_DEFAULT = 50
type RoleItemProps = {
readonly: boolean
title: string
value: string
onChange: (value: string) => void
}
const RoleItem: FC<RoleItemProps> = ({
readonly,
title,
value,
onChange,
}) => {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}, [onChange])
return (
<div className='flex items-center justify-between'>
<div className='text-[13px] font-normal text-text-secondary'>{title}</div>
<Input
readOnly={readonly}
value={value}
onChange={handleChange}
className='h-8 w-[200px]'
type='text' />
</div>
)
}
type Props = {
className?: string
readonly: boolean
config: { data?: Memory }
onChange: (memory?: Memory) => void
canSetRoleName?: boolean
}
const MEMORY_DEFAULT: Memory = {
window: { enabled: false, size: WINDOW_SIZE_DEFAULT },
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
}
const MemoryConfig: FC<Props> = ({
className,
readonly,
config = { data: MEMORY_DEFAULT },
onChange,
canSetRoleName = false,
}) => {
const { t } = useTranslation()
const payload = config.data
const handleMemoryEnabledChange = useCallback((enabled: boolean) => {
onChange(enabled ? MEMORY_DEFAULT : undefined)
}, [onChange])
const handleWindowEnabledChange = useCallback((enabled: boolean) => {
const newPayload = produce(config.data || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT }
draft.window.enabled = enabled
})
onChange(newPayload)
}, [config, onChange])
const handleWindowSizeChange = useCallback((size: number | string) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT }
let limitedSize: null | string | number = size
if (limitedSize === '') {
limitedSize = null
}
else {
limitedSize = Number.parseInt(limitedSize as string, 10)
if (isNaN(limitedSize))
limitedSize = WINDOW_SIZE_DEFAULT
if (limitedSize < WINDOW_SIZE_MIN)
limitedSize = WINDOW_SIZE_MIN
if (limitedSize > WINDOW_SIZE_MAX)
limitedSize = WINDOW_SIZE_MAX
}
draft.window.size = limitedSize as number
})
onChange(newPayload)
}, [payload, onChange])
const handleBlur = useCallback(() => {
const payload = config.data
if (!payload)
return
if (payload.window.size === '' || payload.window.size === null)
handleWindowSizeChange(WINDOW_SIZE_DEFAULT)
}, [handleWindowSizeChange, config])
const handleRolePrefixChange = useCallback((role: MemoryRole) => {
return (value: string) => {
const newPayload = produce(config.data || MEMORY_DEFAULT, (draft) => {
if (!draft.role_prefix) {
draft.role_prefix = {
user: '',
assistant: '',
}
}
draft.role_prefix[role] = value
})
onChange(newPayload)
}
}, [config, onChange])
return (
<div className={cn(className)}>
<Field
title={t(`${i18nPrefix}.memory`)}
tooltip={t(`${i18nPrefix}.memoryTip`)!}
operations={
<Switch
defaultValue={!!payload}
onChange={handleMemoryEnabledChange}
size='md'
disabled={readonly}
/>
}
>
{payload && (
<>
{/* window size */}
<div className='flex justify-between'>
<div className='flex h-8 items-center space-x-2'>
<Switch
defaultValue={payload?.window?.enabled}
onChange={handleWindowEnabledChange}
size='md'
disabled={readonly}
/>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.windowSize`)}</div>
</div>
<div className='flex h-8 items-center space-x-2'>
<Slider
className='w-[144px]'
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
/>
<Input
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
wrapperClassName='w-12'
className='appearance-none pr-0'
type='number'
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={e => handleWindowSizeChange(e.target.value)}
onBlur={handleBlur}
disabled={readonly || !payload.window?.enabled}
/>
</div>
</div>
{canSetRoleName && (
<div className='mt-4'>
<div className='text-xs font-medium uppercase leading-6 text-text-tertiary'>{t(`${i18nPrefix}.conversationRoleName`)}</div>
<div className='mt-1 space-y-2'>
<RoleItem
readonly={readonly}
title={t(`${i18nPrefix}.user`)}
value={payload.role_prefix?.user || ''}
onChange={handleRolePrefixChange(MemoryRole.user)}
/>
<RoleItem
readonly={readonly}
title={t(`${i18nPrefix}.assistant`)}
value={payload.role_prefix?.assistant || ''}
onChange={handleRolePrefixChange(MemoryRole.assistant)}
/>
</div>
</div>
)}
</>
)}
</Field>
</div>
)
}
export default React.memo(MemoryConfig)

View File

@@ -0,0 +1,62 @@
import {
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import Placeholder from './placeholder'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type MixedVariableTextInputProps = {
readOnly?: boolean
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
value?: string
onChange?: (text: string) => void
}
const MixedVariableTextInput = ({
readOnly = false,
nodesOutputVars,
availableNodes = [],
value = '',
onChange,
}: MixedVariableTextInputProps) => {
const { t } = useTranslation()
return (
<PromptEditor
wrapperClassName={cn(
'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
)}
className='caret:text-text-accent'
editable={!readOnly}
value={value}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
}}
placeholder={<Placeholder />}
onChange={onChange}
/>
)
}
export default memo(MixedVariableTextInput)

View File

@@ -0,0 +1,52 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { FOCUS_COMMAND } from 'lexical'
import { $insertNodes } from 'lexical'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
import Badge from '@/app/components/base/badge'
const Placeholder = () => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const handleInsert = useCallback((text: string) => {
editor.update(() => {
const textNode = new CustomTextNode(text)
$insertNodes([textNode])
})
editor.dispatchCommand(FOCUS_COMMAND, undefined as any)
}, [editor])
return (
<div
className='pointer-events-auto flex h-full w-full cursor-text items-center px-2'
onClick={(e) => {
e.stopPropagation()
handleInsert('')
}}
>
<div className='flex grow items-center'>
{t('workflow.nodes.tool.insertPlaceholder1')}
<div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div>
<div
className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary'
onMouseDown={((e) => {
e.preventDefault()
e.stopPropagation()
handleInsert('/')
})}
>
{t('workflow.nodes.tool.insertPlaceholder2')}
</div>
</div>
<Badge
className='shrink-0'
text='String'
uppercase={false}
/>
</div>
)
}
export default Placeholder

View File

@@ -0,0 +1,103 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
} from '@remixicon/react'
import {
useAvailableBlocks,
useNodesInteractions,
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import BlockSelector from '@/app/components/workflow/block-selector'
import type {
CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types'
type AddProps = {
nodeId: string
nodeData: CommonNodeType
sourceHandle: string
isParallel?: boolean
isFailBranch?: boolean
}
const Add = ({
nodeId,
nodeData,
sourceHandle,
isParallel,
isFailBranch,
}: AddProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
pluginDefaultValue,
},
{
prevNodeId: nodeId,
prevNodeSourceHandle: sourceHandle,
},
)
}, [handleNodeAdd])
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
}, [])
const tip = useMemo(() => {
if (isFailBranch)
return t('workflow.common.addFailureBranch')
if (isParallel)
return t('workflow.common.addParallelNode')
return t('workflow.panel.selectNextStep')
}, [isFailBranch, isParallel, t])
const renderTrigger = useCallback((open: boolean) => {
return (
<div
className={`
bg-dropzone-bg hover:bg-dropzone-bg-hover relative flex h-9 cursor-pointer items-center rounded-lg border border-dashed
border-divider-regular px-2 text-xs text-text-placeholder
${open && '!bg-components-dropzone-bg-alt'}
${nodesReadOnly && '!cursor-not-allowed'}
`}
>
<div className='mr-1.5 flex h-5 w-5 items-center justify-center rounded-[5px] bg-background-default-dimmed'>
<RiAddLine className='h-3 w-3' />
</div>
<div className='flex items-center uppercase'>
{tip}
</div>
</div>
)
}, [nodesReadOnly, tip])
return (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
disabled={nodesReadOnly}
onSelect={handleSelect}
placement='top'
offset={0}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={availableNextBlocks}
/>
)
}
export default memo(Add)

View File

@@ -0,0 +1,65 @@
import Add from './add'
import Item from './item'
import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ContainerProps = {
nodeId: string
nodeData: CommonNodeType
sourceHandle: string
nextNodes: Node[]
branchName?: string
isFailBranch?: boolean
}
const Container = ({
nodeId,
nodeData,
sourceHandle,
nextNodes,
branchName,
isFailBranch,
}: ContainerProps) => {
return (
<div className={cn(
'space-y-0.5 rounded-[10px] bg-background-section-burn p-0.5',
isFailBranch && 'border-[0.5px] border-state-warning-hover-alt bg-state-warning-hover',
)}>
{
branchName && (
<div
className={cn(
'system-2xs-semibold-uppercase flex items-center truncate px-2 text-text-tertiary',
isFailBranch && 'text-text-warning',
)}
title={branchName}
>
{branchName}
</div>
)
}
{
nextNodes.map(nextNode => (
<Item
key={nextNode.id}
nodeId={nextNode.id}
data={nextNode.data}
sourceHandle='source'
/>
))
}
<Add
isParallel={!!nextNodes.length}
isFailBranch={isFailBranch}
nodeId={nodeId}
nodeData={nodeData}
sourceHandle={sourceHandle}
/>
</div>
)
}
export default Container

View File

@@ -0,0 +1,123 @@
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { isEqual } from 'lodash-es'
import {
getConnectedEdges,
getOutgoers,
useStore,
} from 'reactflow'
import { useToolIcon } from '../../../../hooks'
import BlockIcon from '../../../../block-icon'
import type {
Node,
} from '../../../../types'
import { BlockEnum } from '../../../../types'
import Line from './line'
import Container from './container'
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
type NextStepProps = {
selectedNode: Node
}
const NextStep = ({
selectedNode,
}: NextStepProps) => {
const { t } = useTranslation()
const data = selectedNode.data
const toolIcon = useToolIcon(data)
const branches = useMemo(() => {
return data._targetBranches || []
}, [data])
const edges = useStore(s => s.edges.map(edge => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
})), isEqual)
const nodes = useStore(s => s.getNodes().map(node => ({
id: node.id,
data: node.data,
})), isEqual)
const outgoers = getOutgoers(selectedNode as Node, nodes as Node[], edges)
const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id)
const list = useMemo(() => {
let items = []
if (branches?.length) {
items = branches.map((branch, index) => {
const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id)
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
return {
branch: {
...branch,
name: data.type === BlockEnum.QuestionClassifier ? `${t('workflow.nodes.questionClassifiers.class')} ${index + 1}` : branch.name,
},
nextNodes,
}
})
}
else {
const connected = connectedEdges.filter(edge => edge.sourceHandle === 'source')
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
items = [{
branch: {
id: '',
name: '',
},
nextNodes,
}]
if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) {
const connected = connectedEdges.filter(edge => edge.sourceHandle === ErrorHandleTypeEnum.failBranch)
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
items.push({
branch: {
id: ErrorHandleTypeEnum.failBranch,
name: t('workflow.common.onFailure'),
},
nextNodes,
})
}
}
return items
}, [branches, connectedEdges, data.error_strategy, data.type, outgoers, t])
return (
<div className='flex py-1'>
<div className='relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-background-default shadow-xs'>
<BlockIcon
type={selectedNode!.data.type}
toolIcon={toolIcon}
/>
</div>
<Line
list={list.length ? list.map(item => item.nextNodes.length + 1) : [1]}
/>
<div className='grow space-y-2'>
{
list.map((item, index) => {
return (
<Container
key={index}
nodeId={selectedNode!.id}
nodeData={selectedNode!.data}
sourceHandle={item.branch.id}
nextNodes={item.nextNodes}
branchName={item.branch.name}
isFailBranch={item.branch.id === ErrorHandleTypeEnum.failBranch}
/>
)
})
}
</div>
</div>
)
}
export default memo(NextStep)

View File

@@ -0,0 +1,86 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Operator from './operator'
import type {
CommonNodeType,
} from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import {
useNodesInteractions,
useNodesReadOnly,
useToolIcon,
} from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
type ItemProps = {
nodeId: string
sourceHandle: string
data: CommonNodeType
}
const Item = ({
nodeId,
sourceHandle,
data,
}: ItemProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const toolIcon = useToolIcon(data)
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
return (
<div
className='group relative flex h-9 cursor-pointer items-center rounded-lg border-[0.5px] border-divider-regular bg-background-default px-2 text-xs text-text-secondary shadow-xs last-of-type:mb-0 hover:bg-background-default-hover'
>
<BlockIcon
type={data.type}
toolIcon={toolIcon}
className='mr-1.5 shrink-0'
/>
<div
className='system-xs-medium grow truncate text-text-secondary'
title={data.title}
>
{data.title}
</div>
{
!nodesReadOnly && (
<>
<Button
className='mr-1 hidden shrink-0 group-hover:flex'
size='small'
onClick={() => handleNodeSelect(nodeId)}
>
{t('workflow.common.jumpToNode')}
</Button>
<div
className={cn(
'hidden shrink-0 items-center group-hover:flex',
open && 'flex',
)}
>
<Operator
data={data}
nodeId={nodeId}
sourceHandle={sourceHandle}
open={open}
onOpenChange={handleOpenChange}
/>
</div>
</>
)
}
</div>
)
}
export default memo(Item)

View File

@@ -0,0 +1,73 @@
import { memo } from 'react'
type LineProps = {
list: number[]
}
const Line = ({
list,
}: LineProps) => {
const listHeight = list.map((item) => {
return item * 36 + (item - 1) * 2 + 12 + 6
})
const processedList = listHeight.map((item, index) => {
if (index === 0)
return item
return listHeight.slice(0, index).reduce((acc, cur) => acc + cur, 0) + item
})
const processedListLength = processedList.length
const svgHeight = processedList[processedListLength - 1] + (processedListLength - 1) * 8
return (
<svg className='w-6 shrink-0' style={{ height: svgHeight }}>
{
processedList.map((item, index) => {
const prevItem = index > 0 ? processedList[index - 1] : 0
const space = prevItem + index * 8 + 16
return (
<g key={index}>
{
index === 0 && (
<>
<path
d='M0,18 L24,18'
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
<rect
x={0}
y={16}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</>
)
}
{
index > 0 && (
<path
d={`M0,18 Q12,18 12,28 L12,${space - 10 + 2} Q12,${space + 2} 24,${space + 2}`}
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
)
}
<rect
x={23}
y={space}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</g>
)
})
}
</svg>
)
}
export default memo(Line)

View File

@@ -0,0 +1,129 @@
import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { intersection } from 'lodash-es'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useAvailableBlocks,
useNodesInteractions,
} from '@/app/components/workflow/hooks'
import type {
CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types'
type ChangeItemProps = {
data: CommonNodeType
nodeId: string
sourceHandle: string
}
const ChangeItem = ({
data,
nodeId,
sourceHandle,
}: ChangeItemProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue)
}, [nodeId, sourceHandle, handleNodeChange])
const renderTrigger = useCallback(() => {
return (
<div className='flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'>
{t('workflow.panel.change')}
</div>
)
}, [t])
return (
<BlockSelector
onSelect={handleSelect}
placement='top-end'
offset={{
mainAxis: 6,
crossAxis: 8,
}}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)}
/>
)
}
type OperatorProps = {
open: boolean
onOpenChange: (v: boolean) => void
data: CommonNodeType
nodeId: string
sourceHandle: string
}
const Operator = ({
open,
onOpenChange,
data,
nodeId,
sourceHandle,
}: OperatorProps) => {
const { t } = useTranslation()
const {
handleNodeDelete,
handleNodeDisconnect,
} = useNodesInteractions()
return (
<PortalToFollowElem
placement='bottom-end'
offset={{ mainAxis: 4, crossAxis: -4 }}
open={open}
onOpenChange={onOpenChange}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<Button className='h-6 w-6 p-0'>
<RiMoreFill className='h-4 w-4' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='system-md-regular min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'>
<div className='p-1'>
<ChangeItem
data={data}
nodeId={nodeId}
sourceHandle={sourceHandle}
/>
<div
className='flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleNodeDisconnect(nodeId)}
>
{t('workflow.common.disconnect')}
</div>
</div>
<div className='p-1'>
<div
className='flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleNodeDelete(nodeId)}
>
{t('common.operation.delete')}
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Operator

View File

@@ -0,0 +1,94 @@
import type { FC } from 'react'
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiPlayLargeLine,
} from '@remixicon/react'
import {
useNodesInteractions,
} from '../../../hooks'
import { type Node, NodeRunningStatus } from '../../../types'
import { canRunBySingle } from '../../../utils'
import PanelOperator from './panel-operator'
import {
Stop,
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import { useWorkflowStore } from '@/app/components/workflow/store'
type NodeControlProps = Pick<Node, 'id' | 'data'>
const NodeControl: FC<NodeControlProps> = ({
id,
data,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { handleNodeSelect } = useNodesInteractions()
const workflowStore = useWorkflowStore()
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
}, [])
const isChildNode = !!(data.isInIteration || data.isInLoop)
return (
<div
className={`
absolute -top-7 right-0 hidden h-7 pb-1
${!data._pluginInstallLocked && 'group-hover:flex'}
${data.selected && '!flex'}
${open && '!flex'}
`}
>
<div
className='flex h-6 items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg px-0.5 text-text-tertiary shadow-md backdrop-blur-[5px]'
onClick={e => e.stopPropagation()}
>
{
canRunBySingle(data.type, isChildNode) && (
<div
className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning && 'cursor-pointer hover:bg-state-base-hover'}`}
onClick={() => {
const action = isSingleRunning ? 'stop' : 'run'
const store = workflowStore.getState()
store.setInitShowLastRunTab(true)
store.setPendingSingleRun({
nodeId: id,
action,
})
handleNodeSelect(id)
}}
>
{
isSingleRunning
? <Stop className='h-3 w-3' />
: (
<Tooltip
popupContent={t('workflow.panel.runThisStep')}
asChild={false}
>
<RiPlayLargeLine className='h-3 w-3' />
</Tooltip>
)
}
</div>
)
}
<PanelOperator
id={id}
data={data}
offset={0}
onOpenChange={handleOpenChange}
triggerClassName='!w-5 !h-5'
/>
</div>
</div>
)
}
export default memo(NodeControl)

View File

@@ -0,0 +1,237 @@
import type { MouseEvent } from 'react'
import {
memo,
useCallback,
useEffect,
useState,
} from 'react'
import {
Handle,
Position,
} from 'reactflow'
import { useTranslation } from 'react-i18next'
import {
BlockEnum,
NodeRunningStatus,
} from '../../../types'
import type { Node } from '../../../types'
import BlockSelector from '../../../block-selector'
import type { PluginDefaultValue } from '../../../block-selector/types'
import {
useAvailableBlocks,
useIsChatMode,
useNodesInteractions,
useNodesReadOnly,
} from '../../../hooks'
import {
useStore,
useWorkflowStore,
} from '../../../store'
import cn from '@/utils/classnames'
type NodeHandleProps = {
handleId: string
handleClassName?: string
nodeSelectorClassName?: string
showExceptionStatus?: boolean
} & Pick<Node, 'id' | 'data'>
export const NodeTargetHandle = memo(({
id,
data,
handleId,
handleClassName,
nodeSelectorClassName,
}: NodeHandleProps) => {
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const connected = data._connectedTargetHandleIds?.includes(handleId)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const isConnectable = !!availablePrevBlocks.length
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!connected)
setOpen(v => !v)
}, [connected])
const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
pluginDefaultValue,
},
{
nextNodeId: id,
nextNodeTargetHandle: handleId,
},
)
}, [handleNodeAdd, id, handleId])
return (
<>
<Handle
id={handleId}
type='target'
position={Position.Left}
className={cn(
'z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle',
data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle',
data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle',
!connected && 'after:opacity-0',
(data.type === BlockEnum.Start
|| data.type === BlockEnum.TriggerWebhook
|| data.type === BlockEnum.TriggerSchedule
|| data.type === BlockEnum.TriggerPlugin) && 'opacity-0',
handleClassName,
)}
isConnectable={isConnectable}
onClick={handleHandleClick}
>
{
!connected && isConnectable && !getNodesReadOnly() && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
asChild
placement='left'
triggerClassName={open => `
hidden absolute left-0 top-0 pointer-events-none
${nodeSelectorClassName}
group-hover:!flex
${data.selected && '!flex'}
${open && '!flex'}
`}
availableBlocksTypes={availablePrevBlocks}
/>
)
}
</Handle>
</>
)
})
NodeTargetHandle.displayName = 'NodeTargetHandle'
export const NodeSourceHandle = memo(({
id,
data,
handleId,
handleClassName,
nodeSelectorClassName,
showExceptionStatus,
}: NodeHandleProps) => {
const { t } = useTranslation()
const shouldAutoOpenStartNodeSelector = useStore(s => s.shouldAutoOpenStartNodeSelector)
const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector)
const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode)
const workflowStoreApi = useWorkflowStore()
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const isConnectable = !!availableNextBlocks.length
const isChatMode = useIsChatMode()
const connected = data._connectedSourceHandleIds?.includes(handleId)
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
setOpen(v => !v)
}, [])
const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
pluginDefaultValue,
},
{
prevNodeId: id,
prevNodeSourceHandle: handleId,
},
)
}, [handleNodeAdd, id, handleId])
useEffect(() => {
if (!shouldAutoOpenStartNodeSelector)
return
if (isChatMode) {
setShouldAutoOpenStartNodeSelector?.(false)
return
}
if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) {
setOpen(true)
if (setShouldAutoOpenStartNodeSelector)
setShouldAutoOpenStartNodeSelector(false)
else
workflowStoreApi?.setState?.({ shouldAutoOpenStartNodeSelector: false })
if (setHasSelectedStartNode)
setHasSelectedStartNode(false)
else
workflowStoreApi?.setState?.({ hasSelectedStartNode: false })
}
}, [shouldAutoOpenStartNodeSelector, data.type, isChatMode, setShouldAutoOpenStartNodeSelector, setHasSelectedStartNode, workflowStoreApi])
return (
<Handle
id={handleId}
type='source'
position={Position.Right}
className={cn(
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle',
data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle',
showExceptionStatus && data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle',
!connected && 'after:opacity-0',
handleClassName,
)}
isConnectable={isConnectable}
onClick={handleHandleClick}
>
<div className='absolute -top-1 left-1/2 hidden -translate-x-1/2 -translate-y-full rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg group-hover/handle:block'>
<div className='system-xs-regular text-text-tertiary'>
<div className=' whitespace-nowrap'>
<span className='system-xs-medium text-text-secondary'>{t('workflow.common.parallelTip.click.title')}</span>
{t('workflow.common.parallelTip.click.desc')}
</div>
<div>
<span className='system-xs-medium text-text-secondary'>{t('workflow.common.parallelTip.drag.title')}</span>
{t('workflow.common.parallelTip.drag.desc')}
</div>
</div>
</div>
{
isConnectable && !getNodesReadOnly() && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
asChild
triggerClassName={open => `
hidden absolute top-0 left-0 pointer-events-none
${nodeSelectorClassName}
group-hover:!flex
${data.selected && '!flex'}
${open && '!flex'}
`}
availableBlocksTypes={availableNextBlocks}
/>
)
}
</Handle>
)
})
NodeSourceHandle.displayName = 'NodeSourceHandle'

View File

@@ -0,0 +1,60 @@
import {
memo,
useCallback,
} from 'react'
import type { OnResize } from 'reactflow'
import { NodeResizeControl } from 'reactflow'
import { useNodesInteractions } from '../../../hooks'
import type { CommonNodeType } from '../../../types'
import cn from '@/utils/classnames'
const Icon = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M5.19009 11.8398C8.26416 10.6196 10.7144 8.16562 11.9297 5.08904" stroke="black" strokeOpacity="0.16" strokeWidth="2" strokeLinecap="round" />
</svg>
)
}
type NodeResizerProps = {
nodeId: string
nodeData: CommonNodeType
icon?: React.JSX.Element
minWidth?: number
minHeight?: number
maxWidth?: number
}
const NodeResizer = ({
nodeId,
nodeData,
icon = <Icon />,
minWidth = 258,
minHeight = 152,
maxWidth,
}: NodeResizerProps) => {
const { handleNodeResize } = useNodesInteractions()
const handleResize = useCallback<OnResize>((_, params) => {
handleNodeResize(nodeId, params)
}, [nodeId, handleNodeResize])
return (
<div className={cn(
'hidden group-hover:block',
nodeData.selected && '!block',
)}>
<NodeResizeControl
position='bottom-right'
className='!border-none !bg-transparent'
onResize={handleResize}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
>
<div className='absolute bottom-[1px] right-[1px]'>{icon}</div>
</NodeResizeControl>
</div>
)
}
export default memo(NodeResizer)

View File

@@ -0,0 +1,43 @@
import {
RiAlertFill,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiLoader2Line,
} from '@remixicon/react'
import cn from '@/utils/classnames'
type NodeStatusIconProps = {
status: string
className?: string
}
const NodeStatusIcon = ({
status,
className,
}: NodeStatusIconProps) => {
return (
<>
{
status === 'succeeded' && (
<RiCheckboxCircleFill className={cn('h-4 w-4 shrink-0 text-text-success', className)} />
)
}
{
status === 'failed' && (
<RiErrorWarningLine className={cn('h-4 w-4 shrink-0 text-text-warning', className)} />
)
}
{
(status === 'stopped' || status === 'exception') && (
<RiAlertFill className={cn('h-4 w-4 shrink-0 text-text-warning-secondary', className)} />
)
}
{
status === 'running' && (
<RiLoader2Line className={cn('h-4 w-4 shrink-0 animate-spin text-text-accent', className)} />
)
}
</>
)
}
export default NodeStatusIcon

View File

@@ -0,0 +1,74 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
const variants = cva([], {
variants: {
align: {
left: 'justify-start',
center: 'justify-center',
right: 'justify-end',
},
},
defaultVariants: {
align: 'center',
},
},
)
type Props = {
className?: string
title: string
onSelect: () => void
selected: boolean
disabled?: boolean
align?: 'left' | 'center' | 'right'
tooltip?: string
} & VariantProps<typeof variants>
const OptionCard: FC<Props> = ({
className,
title,
onSelect,
selected,
disabled,
align = 'center',
tooltip,
}) => {
const handleSelect = useCallback(() => {
if (selected || disabled)
return
onSelect()
}, [onSelect, selected, disabled])
return (
<div
className={cn(
'system-sm-regular flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
(!selected && !disabled) && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
selected && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
disabled && 'text-text-disabled',
variants({ align }),
className,
)}
onClick={handleSelect}
>
<span>{title}</span>
{tooltip
&& <Tooltip
popupContent={
<div className='w-[240px]'>
{tooltip}
</div>
}
/>
}
</div>
)
}
export default React.memo(OptionCard)

View File

@@ -0,0 +1,85 @@
'use client'
import type { FC, ReactNode } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
import TreeIndentLine from './variable/object-child-tree-panel/tree-indent-line'
import cn from '@/utils/classnames'
type Props = {
className?: string
title?: string
children: ReactNode
operations?: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}
const OutputVars: FC<Props> = ({
title,
children,
operations,
collapsed,
onCollapse,
}) => {
const { t } = useTranslation()
return (
<FieldCollapse
title={title || t('workflow.nodes.common.outputVars')}
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>
{children}
</FieldCollapse>
)
}
type VarItemProps = {
name: string
type: string
description: string
subItems?: {
name: string
type: string
description: string
}[]
isIndent?: boolean
}
export const VarItem: FC<VarItemProps> = ({
name,
type,
description,
subItems,
isIndent,
}) => {
return (
<div className={cn('flex', isIndent && 'relative left-[-7px]')}>
{isIndent && <TreeIndentLine depth={1} />}
<div className='py-1'>
<div className='flex'>
<div className='flex items-center leading-[18px]'>
<div className='code-sm-semibold text-text-secondary'>{name}</div>
<div className='system-xs-regular ml-2 text-text-tertiary'>{type}</div>
</div>
</div>
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
{description}
{subItems && (
<div className='ml-2 border-l border-gray-200 pl-2'>
{subItems.map((item, index) => (
<VarItem
key={index}
name={item.name}
type={item.type}
description={item.description}
/>
))}
</div>
)}
</div>
</div>
</div>
)
}
export default React.memo(OutputVars)

View File

@@ -0,0 +1,87 @@
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { intersection } from 'lodash-es'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useAvailableBlocks,
useIsChatMode,
useNodesInteractions,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import type {
Node,
OnSelectBlock,
} from '@/app/components/workflow/types'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
type ChangeBlockProps = {
nodeId: string
nodeData: Node['data']
sourceHandle: string
}
const ChangeBlock = ({
nodeId,
nodeData,
sourceHandle,
}: ChangeBlockProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
const isChatMode = useIsChatMode()
const flowType = useHooksStore(s => s.configsMap?.flowType)
const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode
const ignoreNodeIds = useMemo(() => {
if (isTriggerNode(nodeData.type as BlockEnum))
return [nodeId]
return undefined
}, [nodeData.type, nodeId])
const availableNodes = useMemo(() => {
if (availablePrevBlocks.length && availableNextBlocks.length)
return intersection(availablePrevBlocks, availableNextBlocks)
else if (availablePrevBlocks.length)
return availablePrevBlocks
else
return availableNextBlocks
}, [availablePrevBlocks, availableNextBlocks])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue)
}, [handleNodeChange, nodeId, sourceHandle])
const renderTrigger = useCallback(() => {
return (
<div className='flex h-8 w-[232px] cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'>
{t('workflow.panel.changeBlock')}
</div>
)
}, [t])
return (
<BlockSelector
placement='bottom-end'
offset={{
mainAxis: -36,
crossAxis: 4,
}}
onSelect={handleSelect}
trigger={renderTrigger}
popupClassName='min-w-[240px]'
availableBlocksTypes={availableNodes}
showStartTab={showStartTab}
ignoreNodeIds={ignoreNodeIds}
forceEnableStartTab={nodeData.type === BlockEnum.Start}
/>
)
}
export default memo(ChangeBlock)

View File

@@ -0,0 +1,76 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { RiMoreFill } from '@remixicon/react'
import type { OffsetOptions } from '@floating-ui/react'
import PanelOperatorPopup from './panel-operator-popup'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { Node } from '@/app/components/workflow/types'
type PanelOperatorProps = {
id: string
data: Node['data']
triggerClassName?: string
offset?: OffsetOptions
onOpenChange?: (open: boolean) => void
inNode?: boolean
showHelpLink?: boolean
}
const PanelOperator = ({
id,
data,
triggerClassName,
offset = {
mainAxis: 4,
crossAxis: 53,
},
onOpenChange,
showHelpLink = true,
}: PanelOperatorProps) => {
const [open, setOpen] = useState(false)
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
return (
<PortalToFollowElem
placement='bottom-end'
offset={offset}
open={open}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={() => handleOpenChange(!open)}>
<div
className={`
flex h-6 w-6 cursor-pointer items-center justify-center rounded-md
hover:bg-state-base-hover
${open && 'bg-state-base-hover'}
${triggerClassName}
`}
>
<RiMoreFill className={'h-4 w-4 text-text-tertiary'} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<PanelOperatorPopup
id={id}
data={data}
onClosePopup={() => setOpen(false)}
showHelpLink={showHelpLink}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(PanelOperator)

View File

@@ -0,0 +1,171 @@
import {
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import ChangeBlock from './change-block'
import {
canRunBySingle,
} from '@/app/components/workflow/utils'
import {
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import type { Node } from '@/app/components/workflow/types'
type PanelOperatorPopupProps = {
id: string
data: Node['data']
onClosePopup: () => void
showHelpLink?: boolean
}
const PanelOperatorPopup = ({
id,
data,
onClosePopup,
showHelpLink,
}: PanelOperatorPopupProps) => {
const { t } = useTranslation()
const edges = useEdges()
const {
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} = useNodesInteractions()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const edge = edges.find(edge => edge.target === id)
const nodeMetaData = useNodeMetaData({ id, data } as Node)
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly
const isChildNode = !!(data.isInIteration || data.isInLoop)
return (
<div className='w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'>
{
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<>
<div className='p-1'>
{
canRunBySingle(data.type, isChildNode) && (
<div
className={`
flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
hover:bg-state-base-hover
`}
onClick={() => {
handleNodeSelect(id)
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
handleSyncWorkflowDraft(true)
onClosePopup()
}}
>
{t('workflow.panel.runThisStep')}
</div>
)
}
{
showChangeBlock && (
<ChangeBlock
nodeId={id}
nodeData={data}
sourceHandle={edge?.sourceHandle || 'source'}
/>
)
}
</div>
<div className='h-px bg-divider-regular'></div>
</>
)
}
{
!nodesReadOnly && (
<>
{
!nodeMetaData.isSingleton && (
<>
<div className='p-1'>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => {
onClosePopup()
handleNodesCopy(id)
}}
>
{t('workflow.common.copy')}
<ShortcutsName keys={['ctrl', 'c']} />
</div>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => {
onClosePopup()
handleNodesDuplicate(id)
}}
>
{t('workflow.common.duplicate')}
<ShortcutsName keys={['ctrl', 'd']} />
</div>
</div>
<div className='h-px bg-divider-regular'></div>
</>
)
}
{
!nodeMetaData.isUndeletable && (
<>
<div className='p-1'>
<div
className={`
flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
hover:bg-state-destructive-hover hover:text-text-destructive
`}
onClick={() => handleNodeDelete(id)}
>
{t('common.operation.delete')}
<ShortcutsName keys={['del']} />
</div>
</div>
<div className='h-px bg-divider-regular'></div>
</>
)
}
</>
)
}
{
showHelpLink && nodeMetaData.helpLinkUri && (
<>
<div className='p-1'>
<a
href={nodeMetaData.helpLinkUri}
target='_blank'
className='flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
>
{t('workflow.panel.helpLink')}
</a>
</div>
<div className='h-px bg-divider-regular'></div>
</>
)
}
<div className='p-1'>
<div className='px-3 py-2 text-xs text-text-tertiary'>
<div className='mb-1 flex h-[22px] items-center font-medium'>
{t('workflow.panel.about').toLocaleUpperCase()}
</div>
<div className='mb-1 leading-[18px] text-text-secondary'>{nodeMetaData.description}</div>
<div className='leading-[18px]'>
{t('workflow.panel.createdBy')} {nodeMetaData.author}
</div>
</div>
</div>
</div>
)
}
export default memo(PanelOperatorPopup)

View File

@@ -0,0 +1,323 @@
'use client'
import type { FC, ReactNode } from 'react'
import React, { useCallback, useRef } from 'react'
import {
RiDeleteBinLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { BlockEnum, EditionType } from '../../../../types'
import type {
ModelConfig,
Node,
NodeOutPutVar,
Variable,
} from '../../../../types'
import Wrap from '../editor/wrap'
import { CodeLanguage } from '../../../code/types'
import PromptGeneratorBtn from '../../../llm/components/prompt-generator-btn'
import cn from '@/utils/classnames'
import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/toggle-expand-btn'
import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
import PromptEditor from '@/app/components/base/prompt-editor'
import {
Copy,
CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars'
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
headerClassName?: string
instanceId?: string
nodeId?: string
editorId?: string
title: string | React.JSX.Element
value: string
onChange: (value: string) => void
readOnly?: boolean
showRemove?: boolean
onRemove?: () => void
justVar?: boolean
isChatModel?: boolean
isChatApp?: boolean
isShowContext?: boolean
hasSetBlockStatus?: {
context: boolean
history: boolean
query: boolean
}
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
isSupportFileVar?: boolean
isSupportPromptGenerator?: boolean
onGenerated?: (prompt: string) => void
modelConfig?: ModelConfig
// for jinja
isSupportJinja?: boolean
editionType?: EditionType
onEditionTypeChange?: (editionType: EditionType) => void
varList?: Variable[]
handleAddVariable?: (payload: any) => void
containerBackgroundClassName?: string
gradientBorder?: boolean
titleTooltip?: ReactNode
inputClassName?: string
editorContainerClassName?: string
placeholder?: string
placeholderClassName?: string
titleClassName?: string
required?: boolean
}
const Editor: FC<Props> = ({
className,
headerClassName,
instanceId,
nodeId,
editorId,
title,
value,
onChange,
readOnly,
showRemove,
onRemove,
justVar,
isChatModel,
isChatApp,
isShowContext,
hasSetBlockStatus,
nodesOutputVars,
availableNodes = [],
isSupportFileVar,
isSupportPromptGenerator,
isSupportJinja,
editionType,
onEditionTypeChange,
varList = [],
handleAddVariable,
onGenerated,
modelConfig,
containerBackgroundClassName: containerClassName,
gradientBorder = true,
titleTooltip,
inputClassName,
placeholder,
placeholderClassName,
titleClassName,
editorContainerClassName,
required,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
const isShowHistory = !isChatModel && isChatApp
const ref = useRef<HTMLDivElement>(null)
const {
wrapClassName,
wrapStyle,
isExpand,
setIsExpand,
editorExpandHeight,
} = useToggleExpend({ ref, isInNode: true })
const [isCopied, setIsCopied] = React.useState(false)
const handleCopy = useCallback(() => {
copy(value)
setIsCopied(true)
}, [value])
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean(false)
const handleInsertVariable = () => {
setFocus()
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
}
const getVarType = useWorkflowVariableType()
const pipelineId = useStore(s => s.pipelineId)
const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel)
return (
<Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}>
<div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}>
<div className={cn(isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal', isExpand && 'flex h-full flex-col', 'rounded-lg', containerClassName)}>
<div className={cn('flex items-center justify-between pl-3 pr-2 pt-1', headerClassName)}>
<div className='flex gap-2'>
<div className={cn('text-xs font-semibold uppercase leading-4 text-text-secondary', titleClassName)}>{title} {required && <span className='text-text-destructive'>*</span>}</div>
{titleTooltip && <Tooltip popupContent={titleTooltip} />}
</div>
<div className='flex items-center'>
<div className='text-xs font-medium leading-[18px] text-text-tertiary'>{value?.length || 0}</div>
{isSupportPromptGenerator && (
<PromptGeneratorBtn
nodeId={nodeId!}
editorId={editorId}
className='ml-[5px]'
onGenerated={onGenerated}
modelConfig={modelConfig}
currentPrompt={value}
/>
)}
<div className='ml-2 mr-2 h-3 w-px bg-divider-regular'></div>
{/* Operations */}
<div className='flex items-center space-x-[2px]'>
{isSupportJinja && (
<Tooltip
popupContent={
<div>
<div>{t('workflow.common.enableJinja')}</div>
<a className='text-text-accent' target='_blank' href='https://jinja.palletsprojects.com/en/2.10.x/'>{t('workflow.common.learnMore')}</a>
</div>
}
>
<div className={cn(editionType === EditionType.jinja2 && 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover')}>
<Jinja className='h-3 w-6 text-text-quaternary' />
<Switch
size='sm'
defaultValue={editionType === EditionType.jinja2}
onChange={(checked) => {
onEditionTypeChange?.(checked ? EditionType.jinja2 : EditionType.basic)
}}
/>
</div>
</Tooltip>
)}
{!readOnly && (
<Tooltip
popupContent={`${t('workflow.common.insertVarTip')}`}
>
<ActionButton onClick={handleInsertVariable}>
<Variable02 className='h-4 w-4' />
</ActionButton>
</Tooltip>
)}
{showRemove && (
<ActionButton onClick={onRemove}>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
)}
{!isCopied
? (
<ActionButton onClick={handleCopy}>
<Copy className='h-4 w-4' />
</ActionButton>
)
: (
<ActionButton>
<CopyCheck className='h-4 w-4' />
</ActionButton>
)
}
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div>
</div>
</div>
{/* Min: 80 Max: 560. Header: 24 */}
<div className={cn('pb-2', isExpand && 'flex grow flex-col')}>
{!(isSupportJinja && editionType === EditionType.jinja2)
? (
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
<PromptEditor
key={controlPromptEditorRerenderKey}
placeholder={placeholder}
placeholderClassName={placeholderClassName}
instanceId={instanceId}
compact
className={cn('min-h-[56px]', inputClassName)}
style={isExpand ? { height: editorExpandHeight - 5 } : {}}
value={value}
contextBlock={{
show: justVar ? false : isShowContext,
selectable: !hasSetBlockStatus?.context,
canNotAddContext: true,
}}
historyBlock={{
show: justVar ? false : isShowHistory,
selectable: !hasSetBlockStatus?.history,
history: {
user: 'Human',
assistant: 'Assistant',
},
}}
queryBlock={{
show: false, // use [sys.query] instead of query block
selectable: false,
}}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
getVarType: getVarType as any,
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
width: node.width,
height: node.height,
position: node.position,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
showManageInputField: !!pipelineId,
onManageInputField: () => setShowInputFieldPanel?.(true),
}}
onChange={onChange}
onBlur={setBlur}
onFocus={setFocus}
editable={!readOnly}
isSupportFileVar={isSupportFileVar}
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className='absolute inset-0 z-10'></div>}
</div>
)
: (
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
<CodeEditor
availableVars={nodesOutputVars || []}
varList={varList}
onAddVar={handleAddVariable}
isInNode
readOnly={readOnly}
language={CodeLanguage.python3}
value={value}
onChange={onChange}
noWrapper
isExpand={isExpand}
className={inputClassName}
/>
</div>
)}
</div>
</div>
</div>
</Wrap>
)
}
export default React.memo(Editor)

View File

@@ -0,0 +1,65 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { useWorkflow } from '../../../hooks'
import { BlockEnum } from '../../../types'
import { getNodeInfoById, isSystemVar } from './variable/utils'
import {
VariableLabelInText,
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
type Props = {
nodeId: string
value: string
className?: string
}
const VAR_PLACEHOLDER = '@#!@#!'
const ReadonlyInputWithSelectVar: FC<Props> = ({
nodeId,
value,
className,
}) => {
const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const availableNodes = getBeforeNodesInSameBranchIncludeParent(nodeId)
const startNode = availableNodes.find((node: any) => {
return node.data.type === BlockEnum.Start
})
const res = (() => {
const vars: string[] = []
const strWithVarPlaceholder = value.replaceAll(/{{#([^#]*)#}}/g, (_match, p1) => {
vars.push(p1)
return VAR_PLACEHOLDER
})
const html: React.JSX.Element[] = strWithVarPlaceholder.split(VAR_PLACEHOLDER).map((str, index) => {
if (!vars[index])
return <span className='relative top-[-3px] leading-[16px]' key={index}>{str}</span>
const value = vars[index].split('.')
const isSystem = isSystemVar(value)
const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data
const isShowAPart = value.length > 2
return (<span key={index}>
<span className='relative top-[-3px] leading-[16px]'>{str}</span>
<VariableLabelInText
nodeTitle={node?.title}
nodeType={node?.type}
notShowFullPath={isShowAPart}
variables={value}
/>
</span>)
})
return html
})()
return (
<div className={cn('break-all text-xs', className)}>
{res}
</div>
)
}
export default React.memo(ReadonlyInputWithSelectVar)

View File

@@ -0,0 +1,21 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { RiDeleteBinLine } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
type Props = {
className?: string
onClick: (e: React.MouseEvent) => void
}
const Remove: FC<Props> = ({
onClick,
}) => {
return (
<ActionButton size='l' className='group shrink-0 hover:!bg-state-destructive-hover' onClick={onClick}>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary group-hover:text-text-destructive' />
</ActionButton>
)
}
export default React.memo(Remove)

View File

@@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
type Props = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
}
const i18nPrefix = 'workflow.common.effectVarConfirm'
const RemoveVarConfirm: FC<Props> = ({
isShow,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation()
return (
<Confirm
isShow={isShow}
title={t(`${i18nPrefix}.title`)}
content={t(`${i18nPrefix}.content`)}
onConfirm={onConfirm}
onCancel={onCancel}
/>
)
}
export default React.memo(RemoveVarConfirm)

View File

@@ -0,0 +1,41 @@
import {
useCallback,
useState,
} from 'react'
import type { WorkflowRetryConfig } from './types'
import {
useNodeDataUpdate,
} from '@/app/components/workflow/hooks'
import type { NodeTracing } from '@/types/workflow'
export const useRetryConfig = (
id: string,
) => {
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const handleRetryConfigChange = useCallback((value?: WorkflowRetryConfig) => {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
retry_config: value,
},
})
}, [id, handleNodeDataUpdateWithSyncDraft])
return {
handleRetryConfigChange,
}
}
export const useRetryDetailShowInSingleRun = () => {
const [retryDetails, setRetryDetails] = useState<NodeTracing[] | undefined>()
const handleRetryDetailsChange = useCallback((details: NodeTracing[] | undefined) => {
setRetryDetails(details)
}, [])
return {
retryDetails,
handleRetryDetailsChange,
}
}

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAlertFill,
RiCheckboxCircleFill,
RiLoader2Line,
} from '@remixicon/react'
import type { Node } from '@/app/components/workflow/types'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type RetryOnNodeProps = Pick<Node, 'id' | 'data'>
const RetryOnNode = ({
data,
}: RetryOnNodeProps) => {
const { t } = useTranslation()
const { retry_config } = data
const showSelectedBorder = data.selected || data._isBundled || data._isEntering
const {
isRunning,
isSuccessful,
isException,
isFailed,
} = useMemo(() => {
return {
isRunning: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
isSuccessful: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder,
isFailed: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
isException: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
}
}, [data._runningStatus, showSelectedBorder])
const showDefault = !isRunning && !isSuccessful && !isException && !isFailed
if (!retry_config?.retry_enabled)
return null
if (!showDefault && !data._retryIndex)
return null
return (
<div className='mb-1 px-3'>
<div className={cn(
'system-xs-medium-uppercase flex items-center justify-between rounded-md border-[0.5px] border-transparent bg-workflow-block-parma-bg px-[5px] py-1 text-text-tertiary',
isRunning && 'border-state-accent-active bg-state-accent-hover text-text-accent',
isSuccessful && 'border-state-success-active bg-state-success-hover text-text-success',
(isException || isFailed) && 'border-state-warning-active bg-state-warning-hover text-text-warning',
)}>
<div className='flex items-center'>
{
showDefault && (
t('workflow.nodes.common.retry.retryTimes', { times: retry_config.max_retries })
)
}
{
isRunning && (
<>
<RiLoader2Line className='mr-1 h-3.5 w-3.5 animate-spin' />
{t('workflow.nodes.common.retry.retrying')}
</>
)
}
{
isSuccessful && (
<>
<RiCheckboxCircleFill className='mr-1 h-3.5 w-3.5' />
{t('workflow.nodes.common.retry.retrySuccessful')}
</>
)
}
{
(isFailed || isException) && (
<>
<RiAlertFill className='mr-1 h-3.5 w-3.5' />
{t('workflow.nodes.common.retry.retryFailed')}
</>
)
}
</div>
{
!showDefault && !!data._retryIndex && (
<div>
{data._retryIndex}/{data.retry_config?.max_retries}
</div>
)
}
</div>
</div>
)
}
export default RetryOnNode

View File

@@ -0,0 +1,121 @@
import { useTranslation } from 'react-i18next'
import { useRetryConfig } from './hooks'
import s from './style.module.css'
import Switch from '@/app/components/base/switch'
import Slider from '@/app/components/base/slider'
import Input from '@/app/components/base/input'
import type {
Node,
} from '@/app/components/workflow/types'
import Split from '@/app/components/workflow/nodes/_base/components/split'
type RetryOnPanelProps = Pick<Node, 'id' | 'data'>
const RetryOnPanel = ({
id,
data,
}: RetryOnPanelProps) => {
const { t } = useTranslation()
const { handleRetryConfigChange } = useRetryConfig(id)
const { retry_config } = data
const handleRetryEnabledChange = (value: boolean) => {
handleRetryConfigChange({
retry_enabled: value,
max_retries: retry_config?.max_retries || 3,
retry_interval: retry_config?.retry_interval || 1000,
})
}
const handleMaxRetriesChange = (value: number) => {
if (value > 10)
value = 10
else if (value < 1)
value = 1
handleRetryConfigChange({
retry_enabled: true,
max_retries: value,
retry_interval: retry_config?.retry_interval || 1000,
})
}
const handleRetryIntervalChange = (value: number) => {
if (value > 5000)
value = 5000
else if (value < 100)
value = 100
handleRetryConfigChange({
retry_enabled: true,
max_retries: retry_config?.max_retries || 3,
retry_interval: value,
})
}
return (
<>
<div className='pt-2'>
<div className='flex h-10 items-center justify-between px-4 py-2'>
<div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>{t('workflow.nodes.common.retry.retryOnFailure')}</div>
</div>
<Switch
defaultValue={retry_config?.retry_enabled}
onChange={v => handleRetryEnabledChange(v)}
/>
</div>
{
retry_config?.retry_enabled && (
<div className='px-4 pb-2'>
<div className='mb-1 flex w-full items-center'>
<div className='system-xs-medium-uppercase mr-2 grow text-text-secondary'>{t('workflow.nodes.common.retry.maxRetries')}</div>
<Slider
className='mr-3 w-[108px]'
value={retry_config?.max_retries || 3}
onChange={handleMaxRetriesChange}
min={1}
max={10}
/>
<Input
type='number'
wrapperClassName='w-[100px]'
value={retry_config?.max_retries || 3}
onChange={e =>
handleMaxRetriesChange(Number.parseInt(e.currentTarget.value, 10) || 3)
}
min={1}
max={10}
unit={t('workflow.nodes.common.retry.times') || ''}
className={s.input}
/>
</div>
<div className='flex items-center'>
<div className='system-xs-medium-uppercase mr-2 grow text-text-secondary'>{t('workflow.nodes.common.retry.retryInterval')}</div>
<Slider
className='mr-3 w-[108px]'
value={retry_config?.retry_interval || 1000}
onChange={handleRetryIntervalChange}
min={100}
max={5000}
/>
<Input
type='number'
wrapperClassName='w-[100px]'
value={retry_config?.retry_interval || 1000}
onChange={e =>
handleRetryIntervalChange(Number.parseInt(e.currentTarget.value, 10) || 1000)
}
min={100}
max={5000}
unit={t('workflow.nodes.common.retry.ms') || ''}
className={s.input}
/>
</div>
</div>
)
}
</div>
<Split className='mx-4 mt-2' />
</>
)
}
export default RetryOnPanel

View File

@@ -0,0 +1,5 @@
.input::-webkit-inner-spin-button,
.input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}

View File

@@ -0,0 +1,5 @@
export type WorkflowRetryConfig = {
max_retries: number
retry_interval: number
retry_enabled: boolean
}

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,96 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useBoolean, useClickAway } from 'ahooks'
import cn from '@/utils/classnames'
import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
type Item = {
value: string
label: string
}
type Props = {
className?: string
trigger?: React.JSX.Element
DropDownIcon?: any
noLeft?: boolean
options: Item[]
allOptions?: Item[]
value: string
placeholder?: string
onChange: (value: any) => void
uppercase?: boolean
popupClassName?: string
triggerClassName?: string
itemClassName?: string
readonly?: boolean
showChecked?: boolean
}
const TypeSelector: FC<Props> = ({
className,
trigger,
DropDownIcon = ChevronSelectorVertical,
noLeft,
options: list,
allOptions,
value,
placeholder = '',
onChange,
uppercase,
triggerClassName,
popupClassName,
itemClassName,
readonly,
showChecked,
}) => {
const noValue = value === '' || value === undefined || value === null
const item = allOptions ? allOptions.find(item => item.value === value) : list.find(item => item.value === value)
const [showOption, { setFalse: setHide, toggle: toggleShow }] = useBoolean(false)
const ref = React.useRef(null)
useClickAway(() => {
setHide()
}, ref)
return (
<div className={cn(!trigger && !noLeft && 'left-[-8px]', 'relative select-none', className)} ref={ref}>
{trigger
? (
<div
onClick={toggleShow}
className={cn(!readonly && 'cursor-pointer')}
>
{trigger}
</div>
)
: (
<div
onClick={toggleShow}
className={cn(showOption && 'bg-state-base-hover', 'flex h-5 cursor-pointer items-center rounded-md pl-1 pr-0.5 text-xs font-semibold text-text-secondary hover:bg-state-base-hover')}>
<div className={cn('text-sm font-semibold', uppercase && 'uppercase', noValue && 'text-text-tertiary', triggerClassName)}>{!noValue ? item?.label : placeholder}</div>
{!readonly && <DropDownIcon className='h-3 w-3 ' />}
</div>
)}
{(showOption && !readonly) && (
<div className={cn('absolute top-[24px] z-10 w-[120px] select-none rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg', popupClassName)}>
{list.map(item => (
<div
key={item.value}
onClick={() => {
setHide()
onChange(item.value)
}}
className={cn(itemClassName, uppercase && 'uppercase', 'flex h-[30px] min-w-[44px] cursor-pointer items-center justify-between rounded-lg px-3 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover')}
>
<div>{item.label}</div>
{showChecked && item.value === value && <Check className='h-4 w-4 text-text-primary' />}
</div>
))
}
</div>
)
}
</div>
)
}
export default React.memo(TypeSelector)

View File

@@ -0,0 +1,28 @@
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import classNames from '@/utils/classnames'
import { type ComponentProps, type PropsWithChildren, type ReactNode, memo } from 'react'
export type SettingItemProps = PropsWithChildren<{
label: string
status?: 'error' | 'warning'
tooltip?: ReactNode
}>
export const SettingItem = memo(({ label, children, status, tooltip }: SettingItemProps) => {
const indicator: ComponentProps<typeof Indicator>['color'] = status === 'error' ? 'red' : status === 'warning' ? 'yellow' : undefined
const needTooltip = ['error', 'warning'].includes(status as any)
return <div className='relative flex items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1.5 py-1 text-xs font-normal'>
<div className={classNames('system-xs-medium-uppercase max-w-full shrink-0 truncate text-text-tertiary', !!children && 'max-w-[100px]')}>
{label}
</div>
<Tooltip popupContent={tooltip} disabled={!needTooltip}>
<div className='system-xs-medium truncate text-right text-text-secondary'>
{children}
</div>
</Tooltip>
{indicator && <Indicator color={indicator} className='absolute -right-0.5 -top-0.5' />}
</div>
})
SettingItem.displayName = 'SettingItem'

View File

@@ -0,0 +1,18 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
type Props = {
className?: string
}
const Split: FC<Props> = ({
className,
}) => {
return (
<div className={cn(className, 'h-[0.5px] bg-divider-subtle')}>
</div>
)
}
export default React.memo(Split)

View File

@@ -0,0 +1,65 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import VarHighlight from '@/app/components/app/configuration/base/var-highlight'
type Props = {
isFocus?: boolean
onFocus?: () => void
value: string
children?: React.ReactNode
wrapClassName?: string
textClassName?: string
readonly?: boolean
}
const SupportVarInput: FC<Props> = ({
isFocus,
onFocus,
children,
value,
wrapClassName,
textClassName,
readonly,
}) => {
const renderSafeContent = (inputValue: string) => {
const parts = inputValue.split(/(\{\{[^}]+\}\}|\n)/g)
return parts.map((part, index) => {
const variableMatch = part.match(/^\{\{([^}]+)\}\}$/)
if (variableMatch) {
return (
<VarHighlight
key={`var-${index}`}
name={variableMatch[1]}
/>
)
}
if (part === '\n')
return <br key={`br-${index}`} />
return <span key={`text-${index}`}>{part}</span>
})
}
return (
<div
className={
cn(wrapClassName, 'flex h-full w-full')
} onClick={onFocus}
>
{(isFocus && !readonly && children)
? (
children
)
: (
<div
className={cn(textClassName, 'h-full w-0 grow truncate whitespace-nowrap')}
title={value}
>
{renderSafeContent(value || '')}
</div>
)}
</div>
)
}
export default React.memo(SupportVarInput)

View File

@@ -0,0 +1,132 @@
'use client'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { RiArrowLeftRightLine, RiExternalLinkLine } from '@remixicon/react'
import type { ReactNode } from 'react'
import { type FC, useCallback, useState } from 'react'
import { useBoolean } from 'ahooks'
import { useCheckInstalled, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
import cn from '@/utils/classnames'
import PluginMutationModel from '@/app/components/plugins/plugin-mutation-model'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils'
import { Badge as Badge2, BadgeState } from '@/app/components/base/badge/index'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import { getMarketplaceUrl } from '@/utils/var'
export type SwitchPluginVersionProps = {
uniqueIdentifier: string
tooltip?: ReactNode
onChange?: (version: string) => void
className?: string
}
export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => {
const { uniqueIdentifier, tooltip, onChange, className } = props
const [pluginId] = uniqueIdentifier?.split(':') || ['']
const [isShow, setIsShow] = useState(false)
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false)
const [target, setTarget] = useState<{
version: string,
pluginUniqueIden: string;
}>()
const pluginDetails = useCheckInstalled({
pluginIds: [pluginId],
enabled: true,
})
const pluginDetail = pluginDetails.data?.plugins.at(0)
const handleUpdatedFromMarketplace = useCallback(() => {
hideUpdateModal()
pluginDetails.refetch()
onChange?.(target!.version)
}, [hideUpdateModal, onChange, pluginDetails, target])
const { getIconUrl } = useGetIcon()
const icon = pluginDetail?.declaration.icon ? getIconUrl(pluginDetail.declaration.icon) : undefined
const mutation = useUpdatePackageFromMarketPlace()
const install = () => {
mutation.mutate(
{
new_plugin_unique_identifier: target!.pluginUniqueIden,
original_plugin_unique_identifier: uniqueIdentifier,
},
{
onSuccess() {
handleUpdatedFromMarketplace()
},
})
}
const { t } = useTranslation()
// Guard against null/undefined uniqueIdentifier to prevent app crash
if (!uniqueIdentifier || !pluginId)
return null
return <Tooltip popupContent={!isShow && !isShowUpdateModal && tooltip} triggerMethod='hover'>
<div className={cn('flex w-fit items-center justify-center', className)} onClick={e => e.stopPropagation()}>
{isShowUpdateModal && pluginDetail && <PluginMutationModel
onCancel={hideUpdateModal}
plugin={pluginManifestToCardPluginProps({
...pluginDetail.declaration,
icon: icon!,
})}
mutation={mutation}
mutate={install}
confirmButtonText={t('workflow.nodes.agent.installPlugin.install')}
cancelButtonText={t('workflow.nodes.agent.installPlugin.cancel')}
modelTitle={t('workflow.nodes.agent.installPlugin.title')}
description={t('workflow.nodes.agent.installPlugin.desc')}
cardTitleLeft={<>
<Badge2 className='mx-1' size="s" state={BadgeState.Warning}>
{`${pluginDetail.version} -> ${target!.version}`}
</Badge2>
</>}
modalBottomLeft={
<Link
className='flex items-center justify-center gap-1'
href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)}
target='_blank'
>
<span className='system-xs-regular text-xs text-text-accent'>
{t('workflow.nodes.agent.installPlugin.changelog')}
</span>
<RiExternalLinkLine className='size-3 text-text-accent' />
</Link>
}
/>}
{pluginDetail && <PluginVersionPicker
isShow={isShow}
onShowChange={setIsShow}
pluginID={pluginId}
currentVersion={pluginDetail.version}
onSelect={(state) => {
setTarget({
pluginUniqueIden: state.unique_identifier,
version: state.version,
})
showUpdateModal()
}}
trigger={
<Badge
className={cn(
'mx-1 flex hover:bg-state-base-hover',
isShow && 'bg-state-base-hover',
)}
uppercase={true}
text={
<>
<div>{pluginDetail.version}</div>
<RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />
</>
}
hasRedCornerMark={true}
/>
}
/>}
</div>
</Tooltip>
}

View File

@@ -0,0 +1,87 @@
import {
memo,
useCallback,
useState,
} from 'react'
import Textarea from 'react-textarea-autosize'
import { useTranslation } from 'react-i18next'
type TitleInputProps = {
value: string
onBlur: (value: string) => void
}
export const TitleInput = memo(({
value,
onBlur,
}: TitleInputProps) => {
const { t } = useTranslation()
const [localValue, setLocalValue] = useState(value)
const handleBlur = () => {
if (!localValue) {
setLocalValue(value)
onBlur(value)
return
}
onBlur(localValue)
}
return (
<input
value={localValue}
onChange={e => setLocalValue(e.target.value)}
className={`
system-xl-semibold mr-2 h-7 min-w-0 grow appearance-none rounded-md border border-transparent bg-transparent px-1 text-text-primary
outline-none focus:shadow-xs
`}
placeholder={t('workflow.common.addTitle') || ''}
onBlur={handleBlur}
/>
)
})
TitleInput.displayName = 'TitleInput'
type DescriptionInputProps = {
value: string
onChange: (value: string) => void
}
export const DescriptionInput = memo(({
value,
onChange,
}: DescriptionInputProps) => {
const { t } = useTranslation()
const [focus, setFocus] = useState(false)
const handleFocus = useCallback(() => {
setFocus(true)
}, [])
const handleBlur = useCallback(() => {
setFocus(false)
}, [])
return (
<div
className={`
leading-0 group flex max-h-[60px] overflow-y-auto rounded-lg bg-components-panel-bg
px-2 py-[5px]
${focus && '!shadow-xs'}
`}
>
<Textarea
value={value}
onChange={e => onChange(e.target.value)}
minRows={1}
onFocus={handleFocus}
onBlur={handleBlur}
className={`
w-full resize-none appearance-none bg-transparent text-xs
leading-[18px] text-text-primary caret-[#295EFF]
outline-none placeholder:text-text-quaternary
`}
placeholder={t('workflow.common.addDescription') || ''}
/>
</div>
)
})
DescriptionInput.displayName = 'DescriptionInput'

View File

@@ -0,0 +1,30 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import {
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
} from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
type Props = {
isExpand: boolean
onExpandChange: (isExpand: boolean) => void
}
const ExpandBtn: FC<Props> = ({
isExpand,
onExpandChange,
}) => {
const handleToggle = useCallback(() => {
onExpandChange(!isExpand)
}, [isExpand])
const Icon = isExpand ? RiCollapseDiagonalLine : RiExpandDiagonalLine
return (
<ActionButton onClick={handleToggle}>
<Icon className='h-4 w-4' />
</ActionButton>
)
}
export default React.memo(ExpandBtn)

View File

@@ -0,0 +1,90 @@
import { useCallback, useMemo } from 'react'
import { useNodes, useReactFlow, useStoreApi } from 'reactflow'
import { useTranslation } from 'react-i18next'
import type {
CommonNodeType,
Node,
ValueSelector,
VarType,
} from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import {
VariableLabelInSelect,
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
type VariableTagProps = {
valueSelector: ValueSelector
varType: VarType
isShort?: boolean
availableNodes?: Node[]
}
const VariableTag = ({
valueSelector,
varType,
isShort,
availableNodes,
}: VariableTagProps) => {
const nodes = useNodes<CommonNodeType>()
const isRagVar = isRagVariableVar(valueSelector)
const node = useMemo(() => {
if (isSystemVar(valueSelector)) {
const startNode = availableNodes?.find(n => n.data.type === BlockEnum.Start)
if (startNode)
return startNode
}
return getNodeInfoById(availableNodes || nodes, isRagVar ? valueSelector[1] : valueSelector[0])
}, [nodes, valueSelector, availableNodes, isRagVar])
const isEnv = isENV(valueSelector)
const isChatVar = isConversationVar(valueSelector)
const isGlobal = isGlobalVar(valueSelector)
const isValid = Boolean(node) || isEnv || isChatVar || isRagVar || isGlobal
const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.')
const isException = isExceptionVariable(variableName, node?.data.type)
const reactflow = useReactFlow()
const store = useStoreApi()
const handleVariableJump = useCallback(() => {
const workflowContainer = document.getElementById('workflow-container')
const {
clientWidth,
clientHeight,
} = workflowContainer!
const {
setViewport,
} = reactflow
const { transform } = store.getState()
const zoom = transform[2]
const position = node.position
setViewport({
x: (clientWidth - 400 - node.width! * zoom) / 2 - position!.x * zoom,
y: (clientHeight - node.height! * zoom) / 2 - position!.y * zoom,
zoom: transform[2],
})
}, [node, reactflow, store])
const { t } = useTranslation()
return (
<VariableLabelInSelect
variables={valueSelector}
nodeType={node?.data.type}
nodeTitle={node?.data.title}
variableType={!isShort ? varType : undefined}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
e.stopPropagation()
handleVariableJump()
}
}}
errorMsg={!isValid ? t('workflow.errorMsg.invalidVariable') : undefined}
isExceptionVariable={isException}
/>
)
}
export default VariableTag

View File

@@ -0,0 +1,39 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import VarReferenceVars from './var-reference-vars'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import ListEmpty from '@/app/components/base/list-empty'
type Props = {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, varDetail: Var) => void
itemWidth?: number
}
const AssignedVarReferencePopup: FC<Props> = ({
vars,
onChange,
itemWidth,
}) => {
const { t } = useTranslation()
// max-h-[300px] overflow-y-auto todo: use portal to handle long list
return (
<div className='bg-components-panel-bg-bur w-[352px] rounded-lg border-[0.5px] border-components-panel-border p-1 shadow-lg' >
{(!vars || vars.length === 0)
? <ListEmpty
title={t('workflow.nodes.assigner.noAssignedVars') || ''}
description={t('workflow.nodes.assigner.assignedVarsDescription')}
/>
: <VarReferenceVars
searchBoxClassName='mt-1'
vars={vars}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar
/>
}
</div >
)
}
export default React.memo(AssignedVarReferencePopup)

View File

@@ -0,0 +1,71 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import type { Var } from '@/app/components/workflow/types'
import { SimpleSelect } from '@/app/components/base/select'
type Props = {
schema: Partial<CredentialFormSchema>
readonly: boolean
value: string
onChange: (value: string | number, varKindType: VarKindType, varInfo?: Var) => void
onOpenChange?: (open: boolean) => void
isLoading?: boolean
}
const DEFAULT_SCHEMA = {} as CredentialFormSchema
const ConstantField: FC<Props> = ({
schema = DEFAULT_SCHEMA,
readonly,
value,
onChange,
onOpenChange,
isLoading,
}) => {
const language = useLanguage()
const placeholder = (schema as CredentialFormSchemaSelect).placeholder
const handleStaticChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? '' : Number.parseFloat(e.target.value)
onChange(value, VarKindType.constant)
}, [onChange])
const handleSelectChange = useCallback((value: string | number) => {
value = value === null ? '' : value
onChange(value as string, VarKindType.constant)
}, [onChange])
return (
<>
{(schema.type === FormTypeEnum.select || schema.type === FormTypeEnum.dynamicSelect) && (
<SimpleSelect
wrapperClassName='w-full !h-8'
className='flex items-center'
disabled={readonly}
defaultValue={value}
items={(schema as CredentialFormSchemaSelect).options.map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleSelectChange(item.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
onOpenChange={onOpenChange}
isLoading={isLoading}
/>
)}
{schema.type === FormTypeEnum.textNumber && (
<input
type='number'
className='h-8 w-full overflow-hidden rounded-lg bg-workflow-block-parma-bg p-2 text-[13px] font-normal leading-8 text-text-secondary placeholder:text-gray-400 focus:outline-none'
value={value}
onChange={handleStaticChange}
readOnly={readonly}
placeholder={placeholder?.[language] || placeholder?.en_US}
min={(schema as CredentialFormSchemaNumberInput).min}
max={(schema as CredentialFormSchemaNumberInput).max}
/>
)}
</>
)
}
export default React.memo(ConstantField)

View File

@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { RiAddLine } from '@remixicon/react'
type ManageInputFieldProps = {
onManage: () => void
}
const ManageInputField = ({
onManage,
}: ManageInputFieldProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center border-t border-divider-subtle pt-1'>
<div
className='flex h-8 grow cursor-pointer items-center px-3'
onClick={onManage}
>
<RiAddLine className='mr-1 h-4 w-4 text-text-tertiary' />
<div
className='system-xs-medium truncate text-text-tertiary'
title='Create user input field'
>
{t('pipeline.inputField.create')}
</div>
</div>
<div className='mx-1 h-3 w-[1px] shrink-0 bg-divider-regular'></div>
<div
className='system-xs-medium flex h-8 shrink-0 cursor-pointer items-center justify-center px-3 text-text-tertiary'
onClick={onManage}
>
{t('pipeline.inputField.manage')}
</div>
</div>
)
}
export default ManageInputField

View File

@@ -0,0 +1,162 @@
import matchTheSchemaType from './match-schema-type'
describe('match the schema type', () => {
it('should return true for identical primitive types', () => {
expect(matchTheSchemaType({ type: 'string' }, { type: 'string' })).toBe(true)
expect(matchTheSchemaType({ type: 'number' }, { type: 'number' })).toBe(true)
})
it('should return false for different primitive types', () => {
expect(matchTheSchemaType({ type: 'string' }, { type: 'number' })).toBe(false)
})
it('should ignore values and only compare types', () => {
expect(matchTheSchemaType({ type: 'string', value: 'hello' }, { type: 'string', value: 'world' })).toBe(true)
expect(matchTheSchemaType({ type: 'number', value: 42 }, { type: 'number', value: 100 })).toBe(true)
})
it('should return true for structural differences but no types', () => {
expect(matchTheSchemaType({ type: 'string', other: { b: 'xxx' } }, { type: 'string', other: 'xxx' })).toBe(true)
expect(matchTheSchemaType({ type: 'string', other: { b: 'xxx' } }, { type: 'string' })).toBe(true)
})
it('should handle nested objects with same structure and types', () => {
const obj1 = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
address: {
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' },
},
},
},
}
const obj2 = {
type: 'object',
properties: {
name: { type: 'string', value: 'Alice' },
age: { type: 'number', value: 30 },
address: {
type: 'object',
properties: {
street: { type: 'string', value: '123 Main St' },
city: { type: 'string', value: 'Wonderland' },
},
},
},
}
expect(matchTheSchemaType(obj1, obj2)).toBe(true)
})
it('should return false for nested objects with different structures', () => {
const obj1 = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
}
const obj2 = {
type: 'object',
properties: {
name: { type: 'string' },
address: { type: 'string' },
},
}
expect(matchTheSchemaType(obj1, obj2)).toBe(false)
})
it('file struct should match file type', () => {
const fileSchema = {
$id: 'https://dify.ai/schemas/v1/file.json',
$schema: 'http://json-schema.org/draft-07/schema#',
version: '1.0.0',
type: 'object',
title: 'File Schema',
description: 'Schema for file objects (v1)',
properties: {
name: {
type: 'string',
description: 'file name',
},
size: {
type: 'number',
description: 'file size',
},
extension: {
type: 'string',
description: 'file extension',
},
type: {
type: 'string',
description: 'file type',
},
mime_type: {
type: 'string',
description: 'file mime type',
},
transfer_method: {
type: 'string',
description: 'file transfer method',
},
url: {
type: 'string',
description: 'file url',
},
related_id: {
type: 'string',
description: 'file related id',
},
},
required: [
'name',
],
}
const file = {
type: 'object',
title: 'File',
description: 'Schema for file objects (v1)',
properties: {
name: {
type: 'string',
description: 'file name',
},
size: {
type: 'number',
description: 'file size',
},
extension: {
type: 'string',
description: 'file extension',
},
type: {
type: 'string',
description: 'file type',
},
mime_type: {
type: 'string',
description: 'file mime type',
},
transfer_method: {
type: 'string',
description: 'file transfer method',
},
url: {
type: 'string',
description: 'file url',
},
related_id: {
type: 'string',
description: 'file related id',
},
},
required: [
'name',
],
}
expect(matchTheSchemaType(fileSchema, file)).toBe(true)
})
})

View File

@@ -0,0 +1,42 @@
export type AnyObj = Record<string, any> | null
const isObj = (x: any): x is object => x !== null && typeof x === 'object'
// only compare type in object
function matchTheSchemaType(scheme: AnyObj, target: AnyObj): boolean {
const isMatch = (schema: AnyObj, t: AnyObj): boolean => {
const oSchema = isObj(schema)
const oT = isObj(t)
if(!oSchema)
return true
if (!oT) { // ignore the object without type
// deep find oSchema has type
for (const key in schema) {
if (key === 'type')
return false
if (isObj((schema as any)[key]) && !isMatch((schema as any)[key], null))
return false
}
return true
}
// check current `type`
const tx = (schema as any).type
const ty = (t as any).type
const isTypeValueObj = isObj(tx)
if(!isTypeValueObj) // caution: type can be object, so that it would not be compare by value
if (tx !== ty) return false
// recurse into all keys
const keys = new Set([...Object.keys(schema as object), ...Object.keys(t as object)])
for (const k of keys) {
if (k === 'type' && !isTypeValueObj) continue // already checked
if (!isMatch((schema as any)[k], (t as any)[k])) return false
}
return true
}
return isMatch(scheme, target)
}
export default matchTheSchemaType

View File

@@ -0,0 +1,77 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import type { Field as FieldType } from '../../../../../llm/types'
import cn from '@/utils/classnames'
import TreeIndentLine from '../tree-indent-line'
import { RiMoreFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import type { ValueSelector } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
const MAX_DEPTH = 10
type Props = {
valueSelector: ValueSelector
name: string,
payload: FieldType,
depth?: number
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
}
const Field: FC<Props> = ({
valueSelector,
name,
payload,
depth = 1,
readonly,
onSelect,
}) => {
const { t } = useTranslation()
const isLastFieldHighlight = readonly
const hasChildren = payload.type === Type.object && payload.properties
const isHighlight = isLastFieldHighlight && !hasChildren
if (depth > MAX_DEPTH + 1)
return null
return (
<div>
<Tooltip popupContent={t('app.structOutput.moreFillTip')} disabled={depth !== MAX_DEPTH + 1}>
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className='flex grow items-stretch'>
<TreeIndentLine depth={depth} />
{depth === MAX_DEPTH + 1 ? (
<RiMoreFill className='h-3 w-3 text-text-tertiary' />
) : (<div className={cn('system-sm-medium h-6 w-0 grow truncate leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
</div>
{depth < MAX_DEPTH + 1 && (
<div className='system-xs-regular ml-2 shrink-0 text-text-tertiary'>{getFieldType(payload)}</div>
)}
</div>
</Tooltip>
{depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (
<div>
{Object.keys(payload.properties).map(propName => (
<Field
key={propName}
name={propName}
payload={payload.properties?.[propName] as FieldType}
depth={depth + 1}
readonly={readonly}
valueSelector={[...valueSelector, name]}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,81 @@
'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, attrAlias?: string }
payload: StructuredOutput
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
onHovering?: (value: boolean) => void
}
export const PickerPanelMain: FC<Props> = ({
className,
root,
payload,
readonly,
onHovering,
onSelect,
}) => {
const ref = useRef<HTMLDivElement>(null)
useHover(ref, {
onChange: (hovering) => {
if (hovering) {
onHovering?.(true)
}
else {
setTimeout(() => {
onHovering?.(false)
}, 100)
}
},
})
const schema = payload.schema
const fieldNames = Object.keys(schema.properties)
return (
<div className={cn(className)} ref={ref}>
{/* Root info */}
<div className='flex items-center justify-between px-2 py-1'>
<div className='flex'>
{root.nodeName && (
<>
<div className='system-sm-medium max-w-[100px] truncate text-text-tertiary'>{root.nodeName}</div>
<div className='system-sm-medium text-text-tertiary'>.</div>
</>
)}
<div className='system-sm-medium text-text-secondary'>{root.attrName}</div>
</div>
<div className='system-xs-regular ml-2 truncate text-text-tertiary' title={root.attrAlias || 'object'}>{root.attrAlias || 'object'}</div>
</div>
{fieldNames.map(name => (
<Field
key={name}
name={name}
payload={schema.properties[name]}
readonly={readonly}
valueSelector={[root.nodeId!, root.attrName]}
onSelect={onSelect}
/>
))}
</div>
)
}
const PickerPanel: FC<Props> = ({
className,
...props
}) => {
return (
<div className={cn('w-[296px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]', className)}>
<PickerPanelMain {...props} />
</div>
)
}
export default React.memo(PickerPanel)

View File

@@ -0,0 +1,90 @@
'use client'
import cn from '@/utils/classnames'
import { RiArrowDropDownLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { Field as FieldType } from '../../../../../llm/types'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import TreeIndentLine from '../tree-indent-line'
type Props = {
name: string,
payload: FieldType,
required: boolean,
depth?: number,
rootClassName?: string
}
const Field: FC<Props> = ({
name,
payload,
depth = 1,
required,
rootClassName,
}) => {
const { t } = useTranslation()
const isRoot = depth === 1
const hasChildren = payload.type === Type.object && payload.properties
const hasEnum = payload.enum && payload.enum.length > 0
const [fold, {
toggle: toggleFold,
}] = useBoolean(false)
return (
<div>
<div className={cn('flex pr-2')}>
<TreeIndentLine depth={depth} />
<div className='w-0 grow'>
<div className='relative flex select-none'>
{hasChildren && (
<RiArrowDropDownLine
className={cn('absolute left-[-18px] top-[50%] h-4 w-4 translate-y-[-50%] cursor-pointer bg-components-panel-bg text-text-tertiary', fold && 'rotate-[270deg] text-text-accent')}
onClick={toggleFold}
/>
)}
<div className={cn('system-sm-medium ml-[7px] h-6 truncate leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div>
<div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>
{getFieldType(payload)}
{(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)}
</div>
{required && <div className='system-2xs-medium-uppercase ml-3 leading-6 text-text-warning'>{t('app.structOutput.required')}</div>}
</div>
{payload.description && (
<div className='ml-[7px] flex'>
<div className='system-xs-regular w-0 grow truncate text-text-tertiary'>{payload.description}</div>
</div>
)}
{hasEnum && (
<div className='ml-[7px] flex'>
<div className='system-xs-regular w-0 grow text-text-quaternary'>
{payload.enum!.map((value, index) => (
<span key={index}>
{typeof value === 'string' ? `"${value}"` : value}
{index < payload.enum!.length - 1 && ' | '}
</span>
))}
</div>
</div>
)}
</div>
</div>
{hasChildren && !fold && (
<div>
{Object.keys(payload.properties!).map(name => (
<Field
key={name}
name={name}
payload={payload.properties?.[name] as FieldType}
depth={depth + 1}
required={!!payload.required?.includes(name)}
/>
))}
</div>
)}
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,39 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { StructuredOutput } from '../../../../../llm/types'
import Field from './field'
import { useTranslation } from 'react-i18next'
type Props = {
payload: StructuredOutput
rootClassName?: string
}
const ShowPanel: FC<Props> = ({
payload,
rootClassName,
}) => {
const { t } = useTranslation()
const schema = {
...payload,
schema: {
...payload.schema,
description: t('app.structOutput.LLMResponse'),
},
}
return (
<div className='relative left-[-7px]'>
{Object.keys(schema.schema.properties!).map(name => (
<Field
key={name}
name={name}
payload={schema.schema.properties![name]}
required={!!schema.schema.required?.includes(name)}
rootClassName={rootClassName}
/>
))}
</div>
)
}
export default React.memo(ShowPanel)

Some files were not shown because too many files have changed in this diff Show More