Merge branch 'feat/plugins' into dev/plugin-deploy

This commit is contained in:
zxhlyh 2025-01-09 16:14:28 +08:00
commit e1db41a1b3
27 changed files with 619 additions and 756 deletions

View File

@ -10,11 +10,15 @@ type ModelTriggerProps = {
modelName: string
providerName: string
className?: string
showWarnIcon?: boolean
contentClassName?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
modelName,
providerName,
className,
showWarnIcon,
contentClassName,
}) => {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
@ -24,7 +28,7 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
<div
className={cn('group flex flex-grow box-content items-center p-[3px] pl-1 h-8 gap-1 rounded-lg bg-components-input-bg-disabled cursor-pointer', className)}
>
<div className='flex items-center w-full'>
<div className={cn('flex items-center w-full', contentClassName)}>
<div className='flex items-center py-[1px] gap-1 min-w-0 flex-1'>
<ModelIcon
className="w-4 h-4"
@ -36,9 +40,11 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
</div>
</div>
<div className='shrink-0 flex items-center justify-center'>
<Tooltip popupContent={t('common.modelProvider.deprecated')}>
<AlertTriangle className='w-4 h-4 text-text-warning-secondary' />
</Tooltip>
{showWarnIcon && (
<Tooltip popupContent={t('common.modelProvider.deprecated')}>
<AlertTriangle className='w-4 h-4 text-text-warning-secondary' />
</Tooltip>
)}
</div>
</div>
</div>

View File

@ -15,6 +15,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import classNames from '@/utils/classnames'
type ModelSelectorProps = {
defaultModel?: DefaultModel
@ -24,6 +25,8 @@ type ModelSelectorProps = {
onSelect?: (model: DefaultModel) => void
readonly?: boolean
scopeFeatures?: string[]
deprecatedClassName?: string
showDeprecatedWarnIcon?: boolean
}
const ModelSelector: FC<ModelSelectorProps> = ({
defaultModel,
@ -33,6 +36,8 @@ const ModelSelector: FC<ModelSelectorProps> = ({
onSelect,
readonly,
scopeFeatures = [],
deprecatedClassName,
showDeprecatedWarnIcon = false,
}) => {
const [open, setOpen] = useState(false)
const {
@ -64,7 +69,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
placement='bottom-start'
offset={4}
>
<div className='relative'>
<div className={classNames('relative')}>
<PortalToFollowElemTrigger
onClick={handleToggle}
className='block'
@ -86,6 +91,8 @@ const ModelSelector: FC<ModelSelectorProps> = ({
modelName={defaultModel?.model || ''}
providerName={defaultModel?.provider || ''}
className={triggerClassName}
showWarnIcon={showDeprecatedWarnIcon}
contentClassName={deprecatedClassName}
/>
)
}

View File

@ -151,7 +151,7 @@ export const useSearchBoxAutoAnimate = (searchBoxAutoAnimate?: boolean) => {
if (!searchBoxAutoAnimate) {
const clientWidth = document.documentElement.clientWidth
if (clientWidth < 1350)
if (clientWidth < 1400)
setSearchBoxCanAnimate(false)
else
setSearchBoxCanAnimate(true)

View File

@ -37,10 +37,10 @@ const ListWrapper = ({
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections])
return (
<div className='relative flex flex-col grow px-12 py-2 bg-background-default-subtle'>
<div className='relative flex flex-col grow h-0 px-12 py-2 bg-background-default-subtle'>
{
plugins && (
<div className='top-5 flex items-center mb-4 pt-3'>
<div className='flex items-center mb-4 pt-3'>
<div className='title-xl-semi-bold text-text-primary'>{t('plugin.marketplace.pluginsResult', { num: pluginsTotal })}</div>
<div className='mx-3 w-[1px] h-3.5 bg-divider-regular'></div>
<SortDropdown locale={locale} />

View File

@ -99,22 +99,22 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
if (!list) return []
return list.filter(tool => tool.name.toLowerCase().includes(query.toLowerCase()))
}, [query, list])
const { strategyStatus } = useStrategyInfo(
const { strategyStatus, refetch: refetchStrategyInfo } = useStrategyInfo(
value?.agent_strategy_provider_name,
value?.agent_strategy_name,
)
const showPluginNotInstalledWarn = strategyStatus?.plugin?.source === 'external'
&& !strategyStatus.plugin.installed
&& !strategyStatus.plugin.installed && !!value
const showUnsupportedStrategy = strategyStatus?.plugin.source === 'external'
&& !strategyStatus?.isExistInPlugin
&& !strategyStatus?.isExistInPlugin && !!value
const showSwitchVersion = !strategyStatus?.isExistInPlugin
&& strategyStatus?.plugin.source === 'marketplace' && strategyStatus.plugin.installed
&& strategyStatus?.plugin.source === 'marketplace' && strategyStatus.plugin.installed && !!value
const showInstallButton = !strategyStatus?.isExistInPlugin
&& strategyStatus?.plugin.source === 'marketplace' && !strategyStatus.plugin.installed
&& strategyStatus?.plugin.source === 'marketplace' && !strategyStatus.plugin.installed && !!value
const icon = list?.find(
coll => coll.tools?.find(tool => tool.name === value?.agent_strategy_name),
@ -159,8 +159,8 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
>
{value?.agent_strategy_label || t('workflow.nodes.agent.strategy.selectTip')}
</p>
{value && <div className='ml-auto flex items-center gap-1'>
{showInstallButton && <InstallPluginButton
<div className='ml-auto flex items-center gap-1'>
{showInstallButton && value && <InstallPluginButton
onClick={e => e.stopPropagation()}
size={'small'}
uniqueIdentifier={value.plugin_unique_identifier}
@ -178,16 +178,16 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
: <RiArrowDownSLine className='size-4 text-text-tertiary' />
}
{showSwitchVersion && <SwitchPluginVersion
uniqueIdentifier={'langgenius/openai:12'}
uniqueIdentifier={value.plugin_unique_identifier}
tooltip={<ToolTipContent
title={t('workflow.nodes.agent.unsupportedStrategy')}>
{t('workflow.nodes.agent.strategyNotFoundDescAndSwitchVersion')}
</ToolTipContent>}
onChange={() => {
// TODO: refresh all strategies
refetchStrategyInfo()
}}
/>}
</div>}
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>

View File

@ -82,6 +82,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
availableNodes={availableNodes}
nodesOutputVars={nodeOutputVars}
isSupportJinja={def.template?.enabled}
required={def.required}
varList={[]}
modelConfig={
defaultModel.data
@ -107,7 +108,13 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
const onChange = (value: number) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
return <Field title={renderI18nObject(def.label)} tooltip={def.tooltip && renderI18nObject(def.tooltip)} inline>
return <Field
title={<>
{renderI18nObject(def.label)} {def.required && <span className='text-red-500'>*</span>}
</>}
tooltip={def.tooltip && renderI18nObject(def.tooltip)}
inline
>
<div className='flex w-[200px] items-center gap-3'>
<Slider
value={value}
@ -140,7 +147,12 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
return (
<Field title={renderI18nObject(schema.label)} tooltip={schema.tooltip && renderI18nObject(schema.tooltip)}>
<Field
title={<>
{renderI18nObject(schema.label)} {schema.required && <span className='text-red-500'>*</span>}
</>}
tooltip={schema.tooltip && renderI18nObject(schema.tooltip)}
>
<ToolSelector
scope={schema.scope}
value={value}
@ -163,6 +175,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
tooltip={schema.tooltip && renderI18nObject(schema.tooltip)}
onChange={onChange}
supportCollapse
required={schema.required}
/>
)
}

View File

@ -1,18 +1,17 @@
'use client'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import React from 'react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import type { DefaultTFuncReturn } from 'i18next'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
className?: string
title: JSX.Element | string | DefaultTFuncReturn
tooltip?: React.ReactNode
title: ReactNode
tooltip?: ReactNode
isSubTitle?: boolean
supportFold?: boolean
children?: JSX.Element | string | null

View File

@ -75,6 +75,7 @@ type Props = {
editorContainerClassName?: string
placeholderClassName?: string
titleClassName?: string
required?: boolean
}
const Editor: FC<Props> = ({
@ -110,6 +111,7 @@ const Editor: FC<Props> = ({
placeholderClassName,
titleClassName,
editorContainerClassName,
required,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
@ -147,7 +149,7 @@ const Editor: FC<Props> = ({
<div className={cn(isFocus ? 'bg-gray-50' : 'bg-gray-100', isExpand && 'h-full flex flex-col', 'rounded-lg', containerClassName)}>
<div className={cn('pt-1 pl-3 pr-2 flex justify-between items-center', headerClassName)}>
<div className='flex gap-2'>
<div className={cn('leading-4 text-xs font-semibold text-gray-700 uppercase', titleClassName)}>{title}</div>
<div className={cn('leading-4 text-xs font-semibold text-gray-700 uppercase', titleClassName)}>{title} {required && <span className='text-red-500'>*</span>}</div>
{titleTooltip && <Tooltip popupContent={titleTooltip} />}
</div>
<div className='flex items-center'>

View File

@ -0,0 +1,67 @@
import Tooltip from '@/app/components/base/tooltip'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import Indicator from '@/app/components/header/indicator'
import { type FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type ModelBarProps = {
provider: string
model: string
} | {}
const useAllModel = () => {
const { data: textGeneration } = useModelList(ModelTypeEnum.textGeneration)
const { data: moderation } = useModelList(ModelTypeEnum.moderation)
const { data: rerank } = useModelList(ModelTypeEnum.rerank)
const { data: speech2text } = useModelList(ModelTypeEnum.speech2text)
const { data: textEmbedding } = useModelList(ModelTypeEnum.textEmbedding)
const { data: tts } = useModelList(ModelTypeEnum.tts)
const models = useMemo(() => {
return textGeneration
.concat(moderation)
.concat(rerank)
.concat(speech2text)
.concat(textEmbedding)
.concat(tts)
}, [textGeneration, moderation, rerank, speech2text, textEmbedding, tts])
if (!textGeneration || !moderation || !rerank || !speech2text || !textEmbedding || !tts)
return undefined
return models
}
export const ModelBar: FC<ModelBarProps> = (props) => {
const { t } = useTranslation()
const modelList = useAllModel()
if (!('provider' in props)) {
return <ModelSelector
modelList={[]}
triggerClassName='bg-workflow-block-parma-bg !h-6 !rounded-md'
defaultModel={undefined}
showDeprecatedWarnIcon={false}
readonly
deprecatedClassName='opacity-50'
/>
}
const modelInstalled = modelList?.some(
provider => provider.provider === props.provider && provider.models.some(model => model.model === props.model))
const showWarn = modelList && !modelInstalled
return modelList && <Tooltip
popupContent={t('workflow.nodes.agent.modelNotInstallTooltip')}
triggerMethod='hover'
disabled={!modelList || modelInstalled}
>
<div className='relative'>
<ModelSelector
modelList={modelList}
triggerClassName='bg-workflow-block-parma-bg !h-6 !rounded-md'
defaultModel={props}
showDeprecatedWarnIcon={false}
readonly
deprecatedClassName='opacity-50'
/>
{showWarn && <Indicator color={'red'} className='absolute -right-0.5 -top-0.5' />}
</div>
</Tooltip>
}

View File

@ -1,10 +1,11 @@
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import classNames from '@/utils/classnames'
import { memo, useMemo, useRef } from 'react'
import { memo, useMemo, useRef, useState } from 'react'
import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import { useTranslation } from 'react-i18next'
import { Group } from '@/app/components/base/icons/src/vender/other'
type Status = 'not-installed' | 'not-authorized' | undefined
@ -45,21 +46,31 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => {
if (status === 'not-authorized') return t('workflow.nodes.agent.toolNotAuthorizedTooltip', { tool: name })
throw new Error('Unknown status')
}, [name, notSuccess, status, t])
return <Tooltip triggerMethod='hover' popupContent={tooltip} disabled={!notSuccess}>
<div className={classNames(
'size-5 border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge relative flex items-center justify-center rounded-[6px]',
)}
ref={containerRef}
const [iconFetchError, setIconFetchError] = useState(false)
return <Tooltip
triggerMethod='hover'
popupContent={tooltip}
disabled={!notSuccess}
>
<div
className={classNames(
'size-5 border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge relative flex items-center justify-center rounded-[6px]',
)}
ref={containerRef}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={icon}
alt='tool icon'
className={classNames(
'w-full h-full size-3.5 object-cover',
notSuccess && 'opacity-50',
)}
/>
{!iconFetchError
// eslint-disable-next-line @next/next/no-img-element
? <img
src={icon}
alt='tool icon'
className={classNames(
'w-full h-full size-3.5 object-cover',
notSuccess && 'opacity-50',
)}
onError={() => setIconFetchError(true)}
/>
: <Group className="w-3 h-3 opacity-35" />
}
{indicator && <Indicator color={indicator} className="absolute right-[-1px] top-[-1px]" />}
</div>
</Tooltip>

View File

@ -2,41 +2,19 @@ import { type FC, memo, useMemo } from 'react'
import type { NodeProps } from '../../types'
import type { AgentNodeType } from './types'
import { SettingItem } from '../_base/components/setting-item'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { Group, GroupLabel } from '../_base/components/group'
import type { ToolIconProps } from './components/tool-icon'
import { ToolIcon } from './components/tool-icon'
import useConfig from './use-config'
import { useTranslation } from 'react-i18next'
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useRenderI18nObject } from '@/hooks/use-i18n'
const useAllModel = () => {
const { data: textGeneration } = useModelList(ModelTypeEnum.textGeneration)
const { data: moderation } = useModelList(ModelTypeEnum.moderation)
const { data: rerank } = useModelList(ModelTypeEnum.rerank)
const { data: speech2text } = useModelList(ModelTypeEnum.speech2text)
const { data: textEmbedding } = useModelList(ModelTypeEnum.textEmbedding)
const { data: tts } = useModelList(ModelTypeEnum.tts)
const models = useMemo(() => {
return textGeneration
.concat(moderation)
.concat(rerank)
.concat(speech2text)
.concat(textEmbedding)
.concat(tts)
}, [textGeneration, moderation, rerank, speech2text, textEmbedding, tts])
if (!textGeneration || !moderation || !rerank || !speech2text || !textEmbedding || !tts)
return undefined
return models
}
import { ModelBar } from './components/model-bar'
const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => {
const { inputs, currentStrategy, currentStrategyStatus, pluginDetail } = useConfig(props.id, props.data)
const renderI18nObject = useRenderI18nObject()
const { t } = useTranslation()
const modelList = useAllModel()
const models = useMemo(() => {
if (!inputs) return []
// if selected, show in node
@ -46,6 +24,7 @@ const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => {
.filter(param => param.type === FormTypeEnum.modelSelector)
.reduce((acc, param) => {
const item = inputs.agent_parameters?.[param.name]?.value
console.log({ item })
if (!item) {
if (param.required) {
acc.push({ param: param.name })
@ -102,27 +81,18 @@ const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => {
{inputs.agent_strategy_label}
</SettingItem>
: <SettingItem label={t('workflow.nodes.agent.strategyNotSet')} />}
{models.length > 0 && modelList && <Group
<Group
label={<GroupLabel className='mt-1'>
{t('workflow.nodes.agent.model')}
</GroupLabel>}
>
{models.map((model) => {
return <ModelSelector
return <ModelBar
{...model}
key={model.param}
modelList={modelList}
triggerClassName='bg-workflow-block-parma-bg !h-6 !rounded-md'
defaultModel={
'provider' in model
? {
provider: model.provider,
model: model.model,
}
: undefined}
readonly
/>
})}
</Group>}
</Group>
{tools.length > 0 && <Group label={<GroupLabel className='mt-1'>
{t('workflow.nodes.agent.toolbox')}
</GroupLabel>}>

View File

@ -73,7 +73,7 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => {
})()
return <div className='my-2'>
<Field title={t('workflow.nodes.agent.strategy.label')} className='px-4 py-2' >
<Field title={t('workflow.nodes.agent.strategy.label')} className='px-4 py-2' tooltip={t('workflow.nodes.agent.strategy.tooltip')} >
<AgentStrategy
strategy={inputs.agent_strategy_name ? {
agent_strategy_provider_name: inputs.agent_strategy_provider_name!,

View File

@ -49,10 +49,15 @@ export const useStrategyInfo = (
isExistInPlugin: strategyExist,
}
}, [strategy, marketplace, strategyProvider.isError, strategyProvider.isLoading])
const refetch = useCallback(() => {
strategyProvider.refetch()
marketplace.refetch()
}, [marketplace, strategyProvider])
return {
strategyProvider,
strategy,
strategyStatus,
refetch,
}
}

View File

@ -23,6 +23,7 @@ const AgentLogItem = ({
status,
children,
data,
metadata,
} = item
const [expanded, setExpanded] = useState(false)
@ -41,8 +42,17 @@ const AgentLogItem = ({
: <RiArrowRightSLine className='shrink-0 w-4 h-4 text-text-quaternary' />
}
<div className='shrink-0 mr-1.5 w-5 h-5'></div>
<div className='grow system-sm-semibold-uppercase text-text-secondary truncate'>{label}</div>
{/* <div className='shrink-0 mr-2 system-xs-regular text-text-tertiary'>0.02s</div> */}
<div
className='grow system-sm-semibold-uppercase text-text-secondary truncate'
title={label}
>
{label}
</div>
{
metadata?.elapsed_time && (
<div className='shrink-0 mr-2 system-xs-regular text-text-tertiary'>{metadata?.elapsed_time?.toFixed(3)}s</div>
)
}
<NodeStatusIcon status={status} />
</div>
{

View File

@ -62,7 +62,7 @@ const AgentLogNav = ({
)
}
{
!!end && agentOrToolLogItemStackLength > 2 && (
!!end && agentOrToolLogItemStackLength > 1 && (
<>
<div className='shrink-0 mx-0.5 system-xs-regular text-divider-deep'>/</div>
<div className='flex items-center px-[5px] system-xs-medium-uppercase text-text-tertiary'>

View File

@ -1,128 +0,0 @@
import { parseDSL } from './graph-to-log-struct-2'
describe('parseDSL', () => {
it('should parse plain nodes correctly', () => {
const dsl = 'plainNode1 -> plainNode2'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: {}, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: {}, status: 'succeeded' },
])
})
it('should parse retry nodes correctly', () => {
const dsl = '(retry, retryNode, 3)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'succeeded' },
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'retry' },
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'retry' },
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'retry' },
])
})
it('should parse iteration nodes correctly', () => {
const dsl = '(iteration, iterationNode, plainNode1 -> plainNode2)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'iterationNode', node_id: 'iterationNode', title: 'iterationNode', node_type: 'iteration', execution_metadata: {}, status: 'succeeded' },
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0 }, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0 }, status: 'succeeded' },
])
})
it('should parse parallel nodes correctly', () => {
const dsl = '(parallel, parallelNode, nodeA, nodeB -> nodeC)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'parallelNode', node_id: 'parallelNode', title: 'parallelNode', execution_metadata: { parallel_id: 'parallelNode' }, status: 'succeeded' },
{ id: 'nodeA', node_id: 'nodeA', title: 'nodeA', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'nodeA' }, status: 'succeeded' },
{ id: 'nodeB', node_id: 'nodeB', title: 'nodeB', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'nodeB' }, status: 'succeeded' },
{ id: 'nodeC', node_id: 'nodeC', title: 'nodeC', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'nodeB' }, status: 'succeeded' },
])
})
// TODO
it('should handle nested parallel nodes', () => {
const dsl = '(parallel, outerParallel, (parallel, innerParallel, plainNode1 -> plainNode2) -> plainNode3)'
const result = parseDSL(dsl)
expect(result).toEqual([
{
id: 'outerParallel',
node_id: 'outerParallel',
title: 'outerParallel',
execution_metadata: { parallel_id: 'outerParallel' },
status: 'succeeded',
},
{
id: 'innerParallel',
node_id: 'innerParallel',
title: 'innerParallel',
execution_metadata: { parallel_id: 'outerParallel', parallel_start_node_id: 'innerParallel' },
status: 'succeeded',
},
{
id: 'plainNode1',
node_id: 'plainNode1',
title: 'plainNode1',
execution_metadata: {
parallel_id: 'innerParallel',
parallel_start_node_id: 'plainNode1',
parent_parallel_id: 'outerParallel',
parent_parallel_start_node_id: 'innerParallel',
},
status: 'succeeded',
},
{
id: 'plainNode2',
node_id: 'plainNode2',
title: 'plainNode2',
execution_metadata: {
parallel_id: 'innerParallel',
parallel_start_node_id: 'plainNode1',
parent_parallel_id: 'outerParallel',
parent_parallel_start_node_id: 'innerParallel',
},
status: 'succeeded',
},
{
id: 'plainNode3',
node_id: 'plainNode3',
title: 'plainNode3',
execution_metadata: {
parallel_id: 'outerParallel',
parallel_start_node_id: 'plainNode3',
},
status: 'succeeded',
},
])
})
// iterations not support nested iterations
// it('should handle nested iterations', () => {
// const dsl = '(iteration, outerIteration, (iteration, innerIteration -> plainNode1 -> plainNode2))'
// const result = parseDSL(dsl)
// expect(result).toEqual([
// { id: 'outerIteration', node_id: 'outerIteration', title: 'outerIteration', node_type: 'iteration', execution_metadata: {}, status: 'succeeded' },
// { id: 'innerIteration', node_id: 'innerIteration', title: 'innerIteration', node_type: 'iteration', execution_metadata: { iteration_id: 'outerIteration', iteration_index: 0 }, status: 'succeeded' },
// { id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { iteration_id: 'innerIteration', iteration_index: 0 }, status: 'succeeded' },
// { id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { iteration_id: 'innerIteration', iteration_index: 0 }, status: 'succeeded' },
// ])
// })
it('should handle nested iterations within parallel nodes', () => {
const dsl = '(parallel, parallelNode, (iteration, iterationNode, plainNode1, plainNode2))'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'parallelNode', node_id: 'parallelNode', title: 'parallelNode', execution_metadata: { parallel_id: 'parallelNode' }, status: 'succeeded' },
{ id: 'iterationNode', node_id: 'iterationNode', title: 'iterationNode', node_type: 'iteration', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'iterationNode' }, status: 'succeeded' },
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0, parallel_id: 'parallelNode', parallel_start_node_id: 'iterationNode' }, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0, parallel_id: 'parallelNode', parallel_start_node_id: 'iterationNode' }, status: 'succeeded' },
])
})
it('should throw an error for unknown node types', () => {
const dsl = '(unknown, nodeId)'
expect(() => parseDSL(dsl)).toThrowError('Unknown nodeType: unknown')
})
})

View File

@ -1,304 +0,0 @@
type IterationInfo = { iterationId: string; iterationIndex: number }
type NodePlain = { nodeType: 'plain'; nodeId: string; } & Partial<IterationInfo>
type NodeComplex = { nodeType: string; nodeId: string; params: (NodePlain | (NodeComplex & Partial<IterationInfo>) | Node[] | number)[] } & Partial<IterationInfo>
type Node = NodePlain | NodeComplex
/**
* Parses a DSL string into an array of node objects.
* @param dsl - The input DSL string.
* @returns An array of parsed nodes.
*/
function parseDSL(dsl: string): NodeData[] {
return convertToNodeData(parseTopLevelFlow(dsl).map(nodeStr => parseNode(nodeStr)))
}
/**
* Splits a top-level flow string by "->", respecting nested structures.
* @param dsl - The DSL string to split.
* @returns An array of top-level segments.
*/
function parseTopLevelFlow(dsl: string): string[] {
const segments: string[] = []
let buffer = ''
let nested = 0
for (let i = 0; i < dsl.length; i++) {
const char = dsl[i]
if (char === '(') nested++
if (char === ')') nested--
if (char === '-' && dsl[i + 1] === '>' && nested === 0) {
segments.push(buffer.trim())
buffer = ''
i++ // Skip the ">" character
}
else {
buffer += char
}
}
if (buffer.trim())
segments.push(buffer.trim())
return segments
}
/**
* Parses a single node string.
* If the node is complex (e.g., has parentheses), it extracts the node type, node ID, and parameters.
* @param nodeStr - The node string to parse.
* @param parentIterationId - The ID of the parent iteration node (if applicable).
* @returns A parsed node object.
*/
function parseNode(nodeStr: string, parentIterationId?: string): Node {
// Check if the node is a complex node
if (nodeStr.startsWith('(') && nodeStr.endsWith(')')) {
const innerContent = nodeStr.slice(1, -1).trim() // Remove outer parentheses
let nested = 0
let buffer = ''
const parts: string[] = []
// Split the inner content by commas, respecting nested parentheses
for (let i = 0; i < innerContent.length; i++) {
const char = innerContent[i]
if (char === '(') nested++
if (char === ')') nested--
if (char === ',' && nested === 0) {
parts.push(buffer.trim())
buffer = ''
}
else {
buffer += char
}
}
parts.push(buffer.trim())
// Extract nodeType, nodeId, and params
const [nodeType, nodeId, ...paramsRaw] = parts
const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId)
const complexNode = {
nodeType: nodeType.trim(),
nodeId: nodeId.trim(),
params,
}
if (parentIterationId) {
(complexNode as any).iterationId = parentIterationId;
(complexNode as any).iterationIndex = 0 // Fixed as 0
}
return complexNode
}
// If it's not a complex node, treat it as a plain node
const plainNode: NodePlain = { nodeType: 'plain', nodeId: nodeStr.trim() }
if (parentIterationId) {
plainNode.iterationId = parentIterationId
plainNode.iterationIndex = 0 // Fixed as 0
}
return plainNode
}
/**
* Parses parameters of a complex node.
* Supports nested flows and complex sub-nodes.
* Adds iteration-specific metadata recursively.
* @param paramParts - The parameters string split by commas.
* @param iterationId - The ID of the iteration node, if applicable.
* @returns An array of parsed parameters (plain nodes, nested nodes, or flows).
*/
function parseParams(paramParts: string[], iterationId?: string): (Node | Node[] | number)[] {
return paramParts.map((part) => {
if (part.includes('->')) {
// Parse as a flow and return an array of nodes
return parseTopLevelFlow(part).map(node => parseNode(node, iterationId))
}
else if (part.startsWith('(')) {
// Parse as a nested complex node
return parseNode(part, iterationId)
}
else if (!Number.isNaN(Number(part.trim()))) {
// Parse as a numeric parameter
return Number(part.trim())
}
else {
// Parse as a plain node
return parseNode(part, iterationId)
}
})
}
type NodeData = {
id: string;
node_id: string;
title: string;
node_type?: string;
execution_metadata: Record<string, any>;
status: string;
}
/**
* Converts a plain node to node data.
*/
function convertPlainNode(node: Node): NodeData[] {
return [
{
id: node.nodeId,
node_id: node.nodeId,
title: node.nodeId,
execution_metadata: {},
status: 'succeeded',
},
]
}
/**
* Converts a retry node to node data.
*/
function convertRetryNode(node: Node): NodeData[] {
const { nodeId, iterationId, iterationIndex, params } = node as NodeComplex
const retryCount = params ? Number.parseInt(params[0] as unknown as string, 10) : 0
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
execution_metadata: {},
status: 'succeeded',
},
]
for (let i = 0; i < retryCount; i++) {
result.push({
id: nodeId,
node_id: nodeId,
title: nodeId,
execution_metadata: iterationId ? {
iteration_id: iterationId,
iteration_index: iterationIndex || 0,
} : {},
status: 'retry',
})
}
return result
}
/**
* Converts an iteration node to node data.
*/
function convertIterationNode(node: Node): NodeData[] {
const { nodeId, params } = node as NodeComplex
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
node_type: 'iteration',
status: 'succeeded',
execution_metadata: {},
},
]
params?.forEach((param: any) => {
if (Array.isArray(param)) {
param.forEach((childNode: Node) => {
const childData = convertToNodeData([childNode])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
iteration_id: nodeId,
iteration_index: 0,
}
})
result.push(...childData)
})
}
})
return result
}
/**
* Converts a parallel node to node data.
*/
function convertParallelNode(node: Node, parentParallelId?: string, parentStartNodeId?: string): NodeData[] {
const { nodeId, params } = node as NodeComplex
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
execution_metadata: {
parallel_id: nodeId,
},
status: 'succeeded',
},
]
params?.forEach((param) => {
if (Array.isArray(param)) {
const startNodeId = param[0]?.nodeId
param.forEach((childNode: Node) => {
const childData = convertToNodeData([childNode])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
parallel_id: nodeId,
parallel_start_node_id: startNodeId,
...(parentParallelId && {
parent_parallel_id: parentParallelId,
parent_parallel_start_node_id: parentStartNodeId,
}),
}
})
result.push(...childData)
})
}
else if (param && typeof param === 'object') {
const startNodeId = param.nodeId
const childData = convertToNodeData([param])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
parallel_id: nodeId,
parallel_start_node_id: startNodeId,
...(parentParallelId && {
parent_parallel_id: parentParallelId,
parent_parallel_start_node_id: parentStartNodeId,
}),
}
})
result.push(...childData)
}
})
return result
}
/**
* Main function to convert nodes to node data.
*/
function convertToNodeData(nodes: Node[], parentParallelId?: string, parentStartNodeId?: string): NodeData[] {
const result: NodeData[] = []
nodes.forEach((node) => {
switch (node.nodeType) {
case 'plain':
result.push(...convertPlainNode(node))
break
case 'retry':
result.push(...convertRetryNode(node))
break
case 'iteration':
result.push(...convertIterationNode(node))
break
case 'parallel':
result.push(...convertParallelNode(node, parentParallelId, parentStartNodeId))
break
default:
throw new Error(`Unknown nodeType: ${node.nodeType}`)
}
})
return result
}
export { parseDSL }

View File

@ -1,97 +1,128 @@
import graphToLogStruct, { parseNodeString } from './graph-to-log-struct'
import parseDSL from './graph-to-log-struct'
describe('graphToLogStruct', () => {
test('parseNodeString', () => {
expect(parseNodeString('(node1, param1, (node2, param2, (node3, param1)), param4)')).toEqual({
node: 'node1',
params: [
'param1',
{
node: 'node2',
params: [
'param2',
{
node: 'node3',
params: [
'param1',
],
},
],
describe('parseDSL', () => {
it('should parse plain nodes correctly', () => {
const dsl = 'plainNode1 -> plainNode2'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: {}, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: {}, status: 'succeeded' },
])
})
it('should parse retry nodes correctly', () => {
const dsl = '(retry, retryNode, 3)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'succeeded' },
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'retry' },
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'retry' },
{ id: 'retryNode', node_id: 'retryNode', title: 'retryNode', execution_metadata: {}, status: 'retry' },
])
})
it('should parse iteration nodes correctly', () => {
const dsl = '(iteration, iterationNode, plainNode1 -> plainNode2)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'iterationNode', node_id: 'iterationNode', title: 'iterationNode', node_type: 'iteration', execution_metadata: {}, status: 'succeeded' },
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0 }, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0 }, status: 'succeeded' },
])
})
it('should parse parallel nodes correctly', () => {
const dsl = '(parallel, parallelNode, nodeA, nodeB -> nodeC)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'parallelNode', node_id: 'parallelNode', title: 'parallelNode', execution_metadata: { parallel_id: 'parallelNode' }, status: 'succeeded' },
{ id: 'nodeA', node_id: 'nodeA', title: 'nodeA', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'nodeA' }, status: 'succeeded' },
{ id: 'nodeB', node_id: 'nodeB', title: 'nodeB', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'nodeB' }, status: 'succeeded' },
{ id: 'nodeC', node_id: 'nodeC', title: 'nodeC', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'nodeB' }, status: 'succeeded' },
])
})
// TODO
it('should handle nested parallel nodes', () => {
const dsl = '(parallel, outerParallel, (parallel, innerParallel, plainNode1 -> plainNode2) -> plainNode3)'
const result = parseDSL(dsl)
expect(result).toEqual([
{
id: 'outerParallel',
node_id: 'outerParallel',
title: 'outerParallel',
execution_metadata: { parallel_id: 'outerParallel' },
status: 'succeeded',
},
{
id: 'innerParallel',
node_id: 'innerParallel',
title: 'innerParallel',
execution_metadata: { parallel_id: 'outerParallel', parallel_start_node_id: 'innerParallel' },
status: 'succeeded',
},
{
id: 'plainNode1',
node_id: 'plainNode1',
title: 'plainNode1',
execution_metadata: {
parallel_id: 'innerParallel',
parallel_start_node_id: 'plainNode1',
parent_parallel_id: 'outerParallel',
parent_parallel_start_node_id: 'innerParallel',
},
'param4',
],
})
})
test('iteration nodes', () => {
expect(graphToLogStruct('start -> (iteration, 1, [2, 3])')).toEqual([
{
id: 'start',
node_id: 'start',
title: 'start',
execution_metadata: {},
status: 'succeeded',
},
{
id: '1',
node_id: '1',
title: '1',
execution_metadata: {},
status: 'succeeded',
node_type: 'iteration',
},
{
id: '2',
node_id: '2',
title: '2',
execution_metadata: { iteration_id: '1', iteration_index: 0 },
id: 'plainNode2',
node_id: 'plainNode2',
title: 'plainNode2',
execution_metadata: {
parallel_id: 'innerParallel',
parallel_start_node_id: 'plainNode1',
parent_parallel_id: 'outerParallel',
parent_parallel_start_node_id: 'innerParallel',
},
status: 'succeeded',
},
{
id: '3',
node_id: '3',
title: '3',
execution_metadata: { iteration_id: '1', iteration_index: 1 },
id: 'plainNode3',
node_id: 'plainNode3',
title: 'plainNode3',
execution_metadata: {
parallel_id: 'outerParallel',
parallel_start_node_id: 'plainNode3',
},
status: 'succeeded',
},
])
})
test('retry nodes', () => {
expect(graphToLogStruct('start -> (retry, 1, 3)')).toEqual([
{
id: 'start',
node_id: 'start',
title: 'start',
execution_metadata: {},
status: 'succeeded',
},
{
id: '1',
node_id: '1',
title: '1',
execution_metadata: {},
status: 'succeeded',
},
{
id: '1',
node_id: '1',
title: '1',
execution_metadata: {},
status: 'retry',
},
{
id: '1',
node_id: '1',
title: '1',
execution_metadata: {},
status: 'retry',
},
{
id: '1',
node_id: '1',
title: '1',
execution_metadata: {},
status: 'retry',
},
// iterations not support nested iterations
// it('should handle nested iterations', () => {
// const dsl = '(iteration, outerIteration, (iteration, innerIteration -> plainNode1 -> plainNode2))'
// const result = parseDSL(dsl)
// expect(result).toEqual([
// { id: 'outerIteration', node_id: 'outerIteration', title: 'outerIteration', node_type: 'iteration', execution_metadata: {}, status: 'succeeded' },
// { id: 'innerIteration', node_id: 'innerIteration', title: 'innerIteration', node_type: 'iteration', execution_metadata: { iteration_id: 'outerIteration', iteration_index: 0 }, status: 'succeeded' },
// { id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { iteration_id: 'innerIteration', iteration_index: 0 }, status: 'succeeded' },
// { id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { iteration_id: 'innerIteration', iteration_index: 0 }, status: 'succeeded' },
// ])
// })
it('should handle nested iterations within parallel nodes', () => {
const dsl = '(parallel, parallelNode, (iteration, iterationNode, plainNode1, plainNode2))'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'parallelNode', node_id: 'parallelNode', title: 'parallelNode', execution_metadata: { parallel_id: 'parallelNode' }, status: 'succeeded' },
{ id: 'iterationNode', node_id: 'iterationNode', title: 'iterationNode', node_type: 'iteration', execution_metadata: { parallel_id: 'parallelNode', parallel_start_node_id: 'iterationNode' }, status: 'succeeded' },
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0, parallel_id: 'parallelNode', parallel_start_node_id: 'iterationNode' }, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { iteration_id: 'iterationNode', iteration_index: 0, parallel_id: 'parallelNode', parallel_start_node_id: 'iterationNode' }, status: 'succeeded' },
])
})
it('should throw an error for unknown node types', () => {
const dsl = '(unknown, nodeId)'
expect(() => parseDSL(dsl)).toThrowError('Unknown nodeType: unknown')
})
})

View File

@ -1,174 +1,304 @@
const STEP_SPLIT = '->'
type IterationInfo = { iterationId: string; iterationIndex: number }
type NodePlain = { nodeType: 'plain'; nodeId: string; } & Partial<IterationInfo>
type NodeComplex = { nodeType: string; nodeId: string; params: (NodePlain | (NodeComplex & Partial<IterationInfo>) | Node[] | number)[] } & Partial<IterationInfo>
type Node = NodePlain | NodeComplex
const toNodeData = (step: string, info: Record<string, any> = {}): any => {
const [nodeId, title] = step.split('@')
const data: Record<string, any> = {
id: nodeId,
node_id: nodeId,
title: title || nodeId,
execution_metadata: {},
status: 'succeeded',
}
const executionMetadata = data.execution_metadata
const { isRetry, isIteration, inIterationInfo } = info
if (isRetry)
data.status = 'retry'
if (isIteration)
data.node_type = 'iteration'
if (inIterationInfo) {
executionMetadata.iteration_id = inIterationInfo.iterationId
executionMetadata.iteration_index = inIterationInfo.iterationIndex
}
return data
/**
* Parses a DSL string into an array of node objects.
* @param dsl - The input DSL string.
* @returns An array of parsed nodes.
*/
function parseDSL(dsl: string): NodeData[] {
return convertToNodeData(parseTopLevelFlow(dsl).map(nodeStr => parseNode(nodeStr)))
}
const toRetryNodeData = ({
nodeId,
repeatTimes,
}: {
nodeId: string,
repeatTimes: number,
}): any => {
const res = [toNodeData(nodeId)]
for (let i = 0; i < repeatTimes; i++)
res.push(toNodeData(nodeId, { isRetry: true }))
return res
}
/**
* Splits a top-level flow string by "->", respecting nested structures.
* @param dsl - The DSL string to split.
* @returns An array of top-level segments.
*/
function parseTopLevelFlow(dsl: string): string[] {
const segments: string[] = []
let buffer = ''
let nested = 0
const toIterationNodeData = ({
nodeId,
children,
}: {
nodeId: string,
children: number[],
}) => {
const res = [toNodeData(nodeId, { isIteration: true })]
// TODO: handle inner node structure
for (let i = 0; i < children.length; i++) {
const step = `${children[i]}`
res.push(toNodeData(step, { inIterationInfo: { iterationId: nodeId, iterationIndex: i } }))
}
return res
}
type NodeStructure = {
node: string;
params: Array<string | NodeStructure>;
}
export function parseNodeString(input: string): NodeStructure {
input = input.trim()
if (input.startsWith('(') && input.endsWith(')'))
input = input.slice(1, -1)
const parts: Array<string | NodeStructure> = []
let current = ''
let depth = 0
let inArrayDepth = 0
for (let i = 0; i < input.length; i++) {
const char = input[i]
if (char === '(')
depth++
else if (char === ')')
depth--
if (char === '[')
inArrayDepth++
else if (char === ']')
inArrayDepth--
const isInArray = inArrayDepth > 0
if (char === ',' && depth === 0 && !isInArray) {
parts.push(current.trim())
current = ''
for (let i = 0; i < dsl.length; i++) {
const char = dsl[i]
if (char === '(') nested++
if (char === ')') nested--
if (char === '-' && dsl[i + 1] === '>' && nested === 0) {
segments.push(buffer.trim())
buffer = ''
i++ // Skip the ">" character
}
else {
current += char
buffer += char
}
}
if (buffer.trim())
segments.push(buffer.trim())
if (current)
parts.push(current.trim())
return segments
}
const result: NodeStructure = {
node: '',
params: [],
}
/**
* Parses a single node string.
* If the node is complex (e.g., has parentheses), it extracts the node type, node ID, and parameters.
* @param nodeStr - The node string to parse.
* @param parentIterationId - The ID of the parent iteration node (if applicable).
* @returns A parsed node object.
*/
function parseNode(nodeStr: string, parentIterationId?: string): Node {
// Check if the node is a complex node
if (nodeStr.startsWith('(') && nodeStr.endsWith(')')) {
const innerContent = nodeStr.slice(1, -1).trim() // Remove outer parentheses
let nested = 0
let buffer = ''
const parts: string[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
// Split the inner content by commas, respecting nested parentheses
for (let i = 0; i < innerContent.length; i++) {
const char = innerContent[i]
if (char === '(') nested++
if (char === ')') nested--
if (typeof part === 'string') {
if (part.startsWith('('))
result.params.push(parseNodeString(part))
if (part.startsWith('[')) {
const content = part.slice(1, -1)
result.params.push(parseNodeString(content))
if (char === ',' && nested === 0) {
parts.push(buffer.trim())
buffer = ''
}
else {
buffer += char
}
}
else if (i === 0) {
result.node = part as unknown as string
parts.push(buffer.trim())
// Extract nodeType, nodeId, and params
const [nodeType, nodeId, ...paramsRaw] = parts
const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId)
const complexNode = {
nodeType: nodeType.trim(),
nodeId: nodeId.trim(),
params,
}
if (parentIterationId) {
(complexNode as any).iterationId = parentIterationId;
(complexNode as any).iterationIndex = 0 // Fixed as 0
}
return complexNode
}
// If it's not a complex node, treat it as a plain node
const plainNode: NodePlain = { nodeType: 'plain', nodeId: nodeStr.trim() }
if (parentIterationId) {
plainNode.iterationId = parentIterationId
plainNode.iterationIndex = 0 // Fixed as 0
}
return plainNode
}
/**
* Parses parameters of a complex node.
* Supports nested flows and complex sub-nodes.
* Adds iteration-specific metadata recursively.
* @param paramParts - The parameters string split by commas.
* @param iterationId - The ID of the iteration node, if applicable.
* @returns An array of parsed parameters (plain nodes, nested nodes, or flows).
*/
function parseParams(paramParts: string[], iterationId?: string): (Node | Node[] | number)[] {
return paramParts.map((part) => {
if (part.includes('->')) {
// Parse as a flow and return an array of nodes
return parseTopLevelFlow(part).map(node => parseNode(node, iterationId))
}
else if (part.startsWith('(')) {
// Parse as a nested complex node
return parseNode(part, iterationId)
}
else if (!Number.isNaN(Number(part.trim()))) {
// Parse as a numeric parameter
return Number(part.trim())
}
else {
result.params.push(part as unknown as string)
// Parse as a plain node
return parseNode(part, iterationId)
}
})
}
type NodeData = {
id: string;
node_id: string;
title: string;
node_type?: string;
execution_metadata: Record<string, any>;
status: string;
}
/**
* Converts a plain node to node data.
*/
function convertPlainNode(node: Node): NodeData[] {
return [
{
id: node.nodeId,
node_id: node.nodeId,
title: node.nodeId,
execution_metadata: {},
status: 'succeeded',
},
]
}
/**
* Converts a retry node to node data.
*/
function convertRetryNode(node: Node): NodeData[] {
const { nodeId, iterationId, iterationIndex, params } = node as NodeComplex
const retryCount = params ? Number.parseInt(params[0] as unknown as string, 10) : 0
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
execution_metadata: {},
status: 'succeeded',
},
]
for (let i = 0; i < retryCount; i++) {
result.push({
id: nodeId,
node_id: nodeId,
title: nodeId,
execution_metadata: iterationId ? {
iteration_id: iterationId,
iteration_index: iterationIndex || 0,
} : {},
status: 'retry',
})
}
return result
}
const toNodes = (input: string): any[] => {
const list = input.split(STEP_SPLIT)
.map(step => step.trim())
/**
* Converts an iteration node to node data.
*/
function convertIterationNode(node: Node): NodeData[] {
const { nodeId, params } = node as NodeComplex
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
node_type: 'iteration',
status: 'succeeded',
execution_metadata: {},
},
]
const res: any[] = []
list.forEach((step) => {
const isPlainStep = !step.includes('(')
if (isPlainStep) {
res.push(toNodeData(step))
return
}
const { node, params } = parseNodeString(step)
switch (node) {
case 'iteration':
console.log(params)
break
res.push(...toIterationNodeData({
nodeId: params[0] as string,
children: JSON.parse(params[1] as string) as number[],
}))
break
case 'retry':
res.push(...toRetryNodeData({
nodeId: params[0] as string,
repeatTimes: Number.parseInt(params[1] as string),
}))
break
params?.forEach((param: any) => {
if (Array.isArray(param)) {
param.forEach((childNode: Node) => {
const childData = convertToNodeData([childNode])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
iteration_id: nodeId,
iteration_index: 0,
}
})
result.push(...childData)
})
}
})
return res
return result
}
/*
* : 1 -> 2 -> 3
* iteration: (iteration, 1, [2, 3]) -> 4. (1, [2, 3]) means 1 is parent, [2, 3] is children
* parallel: 1 -> (parallel, [1,2,3], [4, (parallel: (6,7))]).
* retry: (retry, 1, 3). 1 is parent, 3 is retry times
*/
const graphToLogStruct = (input: string): any[] => {
const list = toNodes(input)
return list
/**
* Converts a parallel node to node data.
*/
function convertParallelNode(node: Node, parentParallelId?: string, parentStartNodeId?: string): NodeData[] {
const { nodeId, params } = node as NodeComplex
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
execution_metadata: {
parallel_id: nodeId,
},
status: 'succeeded',
},
]
params?.forEach((param) => {
if (Array.isArray(param)) {
const startNodeId = param[0]?.nodeId
param.forEach((childNode: Node) => {
const childData = convertToNodeData([childNode])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
parallel_id: nodeId,
parallel_start_node_id: startNodeId,
...(parentParallelId && {
parent_parallel_id: parentParallelId,
parent_parallel_start_node_id: parentStartNodeId,
}),
}
})
result.push(...childData)
})
}
else if (param && typeof param === 'object') {
const startNodeId = param.nodeId
const childData = convertToNodeData([param])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
parallel_id: nodeId,
parallel_start_node_id: startNodeId,
...(parentParallelId && {
parent_parallel_id: parentParallelId,
parent_parallel_start_node_id: parentStartNodeId,
}),
}
})
result.push(...childData)
}
})
return result
}
export default graphToLogStruct
/**
* Main function to convert nodes to node data.
*/
function convertToNodeData(nodes: Node[], parentParallelId?: string, parentStartNodeId?: string): NodeData[] {
const result: NodeData[] = []
nodes.forEach((node) => {
switch (node.nodeType) {
case 'plain':
result.push(...convertPlainNode(node))
break
case 'retry':
result.push(...convertRetryNode(node))
break
case 'iteration':
result.push(...convertIterationNode(node))
break
case 'parallel':
result.push(...convertParallelNode(node, parentParallelId, parentStartNodeId))
break
default:
throw new Error(`Unknown nodeType: ${node.nodeType}`)
}
})
return result
}
export default parseDSL

View File

@ -2,8 +2,8 @@ import format from '.'
import graphToLogStruct from '../graph-to-log-struct'
describe('iteration', () => {
const list = graphToLogStruct('start -> (iteration, 1, [2, 3])')
const [startNode, iterationNode, ...iterations] = graphToLogStruct('start -> (iteration, 1, [2, 3])')
const list = graphToLogStruct('start -> (iteration, iterationNode, plainNode1 -> plainNode2)')
const [startNode, iterationNode, ...iterations] = list
const result = format(list as any, () => { })
test('result should have no nodes in iteration node', () => {
expect((result as any).find((item: any) => !!item.execution_metadata?.iteration_id)).toBeUndefined()
@ -14,8 +14,7 @@ describe('iteration', () => {
{
...iterationNode,
details: [
[iterations[0]],
[iterations[1]],
[iterations[0], iterations[1]],
],
},
])

View File

@ -0,0 +1,39 @@
import { cloneDeep } from 'lodash-es'
import format from '.'
import graphToLogStruct from '../graph-to-log-struct'
describe('parallel', () => {
const list = graphToLogStruct('(parallel, parallelNode, nodeA, nodeB -> nodeC)')
const [parallelNode, ...parallelDetail] = list
const parallelI18n = 'PARALLEL'
// format will change the list...
const result = format(cloneDeep(list) as any, () => parallelI18n)
test('parallel should put nodes in details', () => {
expect(result as any).toEqual([
{
...parallelNode,
parallelDetail: {
isParallelStartNode: true,
parallelTitle: `${parallelI18n}-1`,
children: [
parallelNode,
{
...parallelDetail[0],
parallelDetail: {
branchTitle: `${parallelI18n}-1-A`,
},
},
{
...parallelDetail[1],
parallelDetail: {
branchTitle: `${parallelI18n}-1-B`,
},
},
parallelDetail[2],
],
},
},
])
})
})

View File

@ -3,9 +3,9 @@ import graphToLogStruct from '../graph-to-log-struct'
describe('retry', () => {
// retry nodeId:1 3 times.
const steps = graphToLogStruct('start -> (retry, 1, 3)')
const steps = graphToLogStruct('start -> (retry, retryNode, 3)')
const [startNode, retryNode, ...retryDetail] = steps
const result = format(steps)
const result = format(steps as any)
test('should have no retry status nodes', () => {
expect(result.find(item => (item as any).status === 'retry')).toBeUndefined()
})

View File

@ -162,12 +162,12 @@ const translation = {
selectPackagePlaceholder: 'Please select a package',
},
upgrade: {
title: 'Upgrade Plugin',
successfulTitle: 'Upgrade successful',
description: 'About to upgrade the following plugin',
title: 'Install Plugin',
successfulTitle: 'Install successful',
description: 'About to install the following plugin',
usedInApps: 'Used in {{num}} apps',
upgrade: 'Upgrade',
upgrading: 'Upgrading...',
upgrade: 'Install',
upgrading: 'Installing...',
close: 'Close',
},
error: {

View File

@ -703,6 +703,7 @@ const translation = {
agent: {
strategy: {
label: 'Agentic Strategy',
tooltip: 'Different Agentic strategies determine how the system plans and executes multi-step tool calls',
shortLabel: 'Strategy',
configureTip: 'Please configure agentic strategy.',
configureTipDesc: 'After configuring the agentic strategy, this node will automatically load the remaining configurations. The strategy will affect the mechanism of multi-step tool reasoning. ',

View File

@ -162,12 +162,12 @@ const translation = {
selectPackagePlaceholder: '请选择一个包',
},
upgrade: {
title: '升级插件',
successfulTitle: '升级成功',
description: '即将升级以下插件',
title: '安装插件',
successfulTitle: '安装成功',
description: '即将安装以下插件',
usedInApps: '在 {{num}} 个应用中使用',
upgrade: '升级',
upgrading: '升级中...',
upgrade: '安装',
upgrading: '安装中...',
close: '关闭',
},
error: {

View File

@ -703,6 +703,7 @@ const translation = {
agent: {
strategy: {
label: 'Agent 策略',
tooltip: '不同的 Agent 策略决定了系统如何规划和执行多步工具调用',
shortLabel: '策略',
configureTip: '请配置 Agent 策略。',
configureTipDesc: '配置完成后,此节点将自动加载剩余配置。策略将影响多步工具推理的机制。',

View File

@ -11,6 +11,10 @@ export type AgentLogItem = {
data: object, // debug data
error?: string,
status: string,
metadata?: {
elapsed_time?: number
provider?: string
},
}
export type AgentLogItemWithChildren = AgentLogItem & {