feat: add sub-graph config panel with variable selection and null

handling
This commit is contained in:
zhsama
2026-01-14 03:22:42 +08:00
parent b7025ad9d6
commit b9052bc244
12 changed files with 351 additions and 97 deletions

View File

@ -1,85 +1,178 @@
'use client'
import type { FC } from 'react'
import type { WhenOutputNoneOption } from '../types'
import { memo, useCallback, useState } from 'react'
import type { Item } from '@/app/components/base/select'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import { RiCheckLine } from '@remixicon/react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import Tab, { TabType } from '@/app/components/workflow/nodes/_base/components/workflow-panel/tab'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { cn } from '@/utils/classnames'
type ConfigPanelProps = {
toolNodeId: string
paramKey: string
activeTab: 'settings' | 'lastRun'
onTabChange: (tab: 'settings' | 'lastRun') => void
agentName: string
extractorNodeId: string
mentionConfig: MentionConfig
availableNodes: Node[]
availableVars: NodeOutPutVar[]
onMentionConfigChange: (config: MentionConfig) => void
}
const outputVariables = [
{ name: 'text', type: 'string' },
{ name: 'structured_output', type: 'object' },
]
const ConfigPanel: FC<ConfigPanelProps> = ({
toolNodeId: _toolNodeId,
paramKey: _paramKey,
activeTab,
agentName,
extractorNodeId,
mentionConfig,
availableNodes,
availableVars,
onMentionConfigChange,
}) => {
const { t } = useTranslation()
const [whenOutputNone, setWhenOutputNone] = useState<WhenOutputNoneOption>('default')
const [tabType, setTabType] = useState<TabType>(TabType.settings)
const handleWhenOutputNoneChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setWhenOutputNone(e.target.value as WhenOutputNoneOption)
}, [])
const resolvedExtractorId = mentionConfig.extractor_node_id || extractorNodeId
if (activeTab === 'lastRun') {
return (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center">
const selectedOutput = useMemo<ValueSelector>(() => {
if (!resolvedExtractorId || !mentionConfig.output_selector?.length)
return []
return [resolvedExtractorId, ...(mentionConfig.output_selector || [])]
}, [mentionConfig.output_selector, resolvedExtractorId])
const handleOutputVarChange = useCallback((value: ValueSelector | string) => {
const selector = Array.isArray(value) ? value : []
const nextExtractorId = selector[0] || resolvedExtractorId
const nextOutputSelector = selector.length > 1 ? selector.slice(1) : []
onMentionConfigChange({
...mentionConfig,
extractor_node_id: nextExtractorId,
output_selector: nextOutputSelector,
})
}, [mentionConfig, onMentionConfigChange, resolvedExtractorId])
const whenOutputNoneOptions = useMemo(() => ([
{
value: 'raise_error',
name: t('subGraphModal.whenOutputNone.error', { ns: 'workflow' }),
description: t('subGraphModal.whenOutputNone.errorDesc', { ns: 'workflow' }),
},
{
value: 'use_default',
name: t('subGraphModal.whenOutputNone.default', { ns: 'workflow' }),
description: t('subGraphModal.whenOutputNone.defaultDesc', { ns: 'workflow' }),
},
]), [t])
const handleNullStrategyChange = useCallback((item: Item) => {
if (typeof item.value !== 'string')
return
onMentionConfigChange({
...mentionConfig,
null_strategy: item.value as MentionConfig['null_strategy'],
})
}, [mentionConfig, onMentionConfigChange])
const handleDefaultValueChange = useCallback((value: string) => {
const trimmed = value.trim()
let nextValue: unknown = value
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
nextValue = JSON.parse(trimmed)
}
catch {
nextValue = value
}
}
onMentionConfigChange({
...mentionConfig,
default_value: nextValue,
})
}, [mentionConfig, onMentionConfigChange])
return (
<div className="flex h-full flex-col">
<div className="px-4 pb-2 pt-4">
<div className="system-lg-semibold text-text-primary">
{t('subGraphModal.internalStructure', { ns: 'workflow' })}
</div>
<div className="system-sm-regular text-text-tertiary">
{t('subGraphModal.internalStructureDesc', { ns: 'workflow', name: agentName })}
</div>
</div>
<div className="px-4 pb-2">
<Tab value={tabType} onChange={setTabType} />
</div>
{tabType === TabType.lastRun && (
<div className="flex flex-1 items-center justify-center p-4">
<p className="system-sm-regular text-text-tertiary">
{t('subGraphModal.noRunHistory', { ns: 'workflow' })}
</p>
</div>
</div>
)
}
return (
<div className="space-y-4 p-4">
<Field
title={t('subGraphModal.outputVariables', { ns: 'workflow' })}
>
<div className="space-y-2">
{outputVariables.map(variable => (
<div
key={variable.name}
className="flex items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-2"
>
<span className="system-sm-medium text-text-secondary">{variable.name}</span>
<span className="system-xs-regular text-text-tertiary">{variable.type}</span>
</div>
))}
)}
{tabType === TabType.settings && (
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 px-4 py-4">
<Field title={t('subGraphModal.outputVariables', { ns: 'workflow' })}>
<VarReferencePicker
nodeId={extractorNodeId}
readonly={false}
isShowNodeName
value={selectedOutput}
onChange={handleOutputVarChange}
availableNodes={availableNodes}
availableVars={availableVars}
/>
</Field>
</div>
<div className="space-y-4 px-4 py-4">
<Field title={t('subGraphModal.whenOutputIsNone', { ns: 'workflow' })}>
<SimpleSelect
items={whenOutputNoneOptions}
defaultValue={mentionConfig.null_strategy}
allowSearch={false}
notClearable
onSelect={handleNullStrategyChange}
renderOption={({ item, selected }) => (
<div className="flex items-start gap-2">
<div className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
{selected && (
<RiCheckLine className="h-4 w-4 text-[14px] text-text-accent" />
)}
</div>
<div className="min-w-0">
<div className="system-sm-medium text-text-secondary">{item.name}</div>
<div className="system-xs-regular mt-0.5 text-text-tertiary">{item.description}</div>
</div>
</div>
)}
/>
</Field>
{mentionConfig.null_strategy === 'use_default' && (
<div>
<div className="system-xs-regular text-text-tertiary">
{t('subGraphModal.defaultValueHint', { ns: 'workflow' })}
</div>
<div className={cn('mt-2 overflow-hidden rounded-lg border border-components-input-border-active bg-components-input-bg-normal p-1')}>
<CodeEditor
noWrapper
language={CodeLanguage.json}
value={mentionConfig.default_value ?? ''}
onChange={handleDefaultValueChange}
isJSONStringifyBeauty
className="min-h-[160px]"
/>
</div>
</div>
)}
</div>
</div>
</Field>
<Field
title={t('subGraphModal.whenOutputIsNone', { ns: 'workflow' })}
>
<select
className={cn(
'w-full rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2',
'system-sm-regular text-text-secondary',
'focus:border-primary-600 focus:outline-none',
)}
value={whenOutputNone}
onChange={handleWhenOutputNoneChange}
>
<option value="default">
{t('subGraphModal.whenOutputNone.default', { ns: 'workflow' })}
</option>
<option value="error">
{t('subGraphModal.whenOutputNone.error', { ns: 'workflow' })}
</option>
</select>
</Field>
)}
</div>
)
}

View File

@ -1,36 +1,72 @@
import type { FC } from 'react'
import type { SubGraphConfig } from '../types'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { memo, useMemo } from 'react'
import { useStore as useReactFlowStore } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks'
import { Panel as NodePanel } from '@/app/components/workflow/nodes'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import ConfigPanel from './config-panel'
type SubGraphChildrenProps = {
toolNodeId: string
paramKey: string
onConfigChange: (config: Partial<SubGraphConfig>) => void
agentName: string
extractorNodeId: string
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
}
const SubGraphChildren: FC<SubGraphChildrenProps> = ({
toolNodeId: _toolNodeId,
paramKey: _paramKey,
onConfigChange: _onConfigChange,
agentName,
extractorNodeId,
mentionConfig,
onMentionConfigChange,
}) => {
const selectedNode = useReactFlowStore(useShallow((s) => {
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()
const nodePanelWidth = useStore(s => s.nodePanelWidth)
const { selectedNode, nodes } = useReactFlowStore(useShallow((s) => {
const nodes = s.getNodes()
const currentNode = nodes.find(node => node.data.selected)
if (currentNode?.data.type === BlockEnum.LLM) {
return {
id: currentNode.id,
type: currentNode.type,
data: currentNode.data,
selectedNode: {
id: currentNode.id,
type: currentNode.type,
data: currentNode.data,
},
nodes,
}
}
return null
return {
selectedNode: null,
nodes,
}
}))
const extractorNode = useMemo(() => {
return nodes.find(node => node.data.type === BlockEnum.LLM)
}, [nodes])
const availableNodes = useMemo(() => {
return extractorNode ? [extractorNode] : []
}, [extractorNode])
const availableVars = useMemo<NodeOutPutVar[]>(() => {
if (!extractorNode)
return []
const vars = getNodeAvailableVars({
beforeNodes: [extractorNode],
isChatMode,
filterVar: () => true,
})
return vars.filter(item => item.nodeId === extractorNode.id)
}, [extractorNode, getNodeAvailableVars, isChatMode])
const nodePanel = useMemo(() => {
if (!selectedNode)
return null
@ -46,11 +82,25 @@ const SubGraphChildren: FC<SubGraphChildrenProps> = ({
return (
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 flex">
{nodePanel && (
<div className="pointer-events-auto">
{nodePanel}
</div>
)}
<div className="pointer-events-auto">
{nodePanel || (
<div className="relative mr-1 h-full">
<div
className="flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
style={{ width: `${nodePanelWidth}px` }}
>
<ConfigPanel
agentName={agentName}
extractorNodeId={extractorNodeId}
mentionConfig={mentionConfig}
availableNodes={availableNodes}
availableVars={availableVars}
onMentionConfigChange={onMentionConfigChange}
/>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -1,18 +1,21 @@
import type { FC } from 'react'
import type { Viewport } from 'reactflow'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { Edge, Node } from '@/app/components/workflow/types'
import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import { useAvailableNodesMetaData, useSubGraphPersistence } from '../hooks'
import { useAvailableNodesMetaData } from '../hooks'
import SubGraphChildren from './sub-graph-children'
type SubGraphMainProps = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
toolNodeId: string
paramKey: string
agentName: string
extractorNodeId: string
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
onSave?: (nodes: Node[], edges: Edge[]) => void
}
@ -20,13 +23,14 @@ const SubGraphMain: FC<SubGraphMainProps> = ({
nodes,
edges,
viewport,
toolNodeId,
paramKey,
agentName,
extractorNodeId,
mentionConfig,
onMentionConfigChange,
onSave,
}) => {
const reactFlowStore = useStoreApi()
const availableNodesMetaData = useAvailableNodesMetaData()
const { updateSubGraphConfig } = useSubGraphPersistence({ toolNodeId, paramKey })
const handleSyncSubGraphDraft = useCallback(() => {
const { getNodes, edges } = reactFlowStore.getState()
@ -53,9 +57,10 @@ const SubGraphMain: FC<SubGraphMainProps> = ({
interactionMode="subgraph"
>
<SubGraphChildren
toolNodeId={toolNodeId}
paramKey={paramKey}
onConfigChange={updateSubGraphConfig}
agentName={agentName}
extractorNodeId={extractorNodeId}
mentionConfig={mentionConfig}
onMentionConfigChange={onMentionConfigChange}
/>
</WorkflowWithInnerContext>
)

View File

@ -5,16 +5,27 @@ import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store
import type { PromptItem } from '@/app/components/workflow/types'
import { memo, useMemo } from 'react'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types'
import SubGraphMain from './components/sub-graph-main'
import { useSubGraphNodes } from './hooks'
import { createSubGraphSlice } from './store'
const SUB_GRAPH_EDGE_GAP = 180
const SUB_GRAPH_ENTRY_POSITION = {
x: START_INITIAL_POSITION.x,
y: 150,
}
const SUB_GRAPH_LLM_POSITION = {
x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP,
y: SUB_GRAPH_ENTRY_POSITION.y,
}
const defaultViewport: Viewport = {
x: 50,
x: SUB_GRAPH_EDGE_GAP,
y: 50,
zoom: 1,
zoom: 1.3,
}
const SubGraph: FC<SubGraphProps> = (props) => {
@ -23,6 +34,8 @@ const SubGraph: FC<SubGraphProps> = (props) => {
paramKey,
agentName,
agentNodeId,
mentionConfig,
onMentionConfigChange,
extractorNode,
toolParamValue,
onSave,
@ -41,7 +54,7 @@ const SubGraph: FC<SubGraphProps> = (props) => {
return {
id: 'subgraph-source',
type: 'custom',
position: { x: 100, y: 150 },
position: SUB_GRAPH_ENTRY_POSITION,
data: {
type: BlockEnum.Start,
title: agentName,
@ -50,8 +63,10 @@ const SubGraph: FC<SubGraphProps> = (props) => {
_connectedTargetHandleIds: [],
_subGraphEntry: true,
_iconTypeOverride: BlockEnum.Agent,
selected: false,
variables: [],
},
selected: false,
selectable: false,
draggable: false,
connectable: false,
@ -110,9 +125,11 @@ const SubGraph: FC<SubGraphProps> = (props) => {
return {
...extractorNode,
hidden: false,
position: { x: 320, y: 150 },
selected: false,
position: SUB_GRAPH_LLM_POSITION,
data: {
...extractorNode.data,
selected: false,
prompt_template: nextPromptTemplate,
},
}
@ -159,8 +176,10 @@ const SubGraph: FC<SubGraphProps> = (props) => {
nodes={nodes}
edges={edges}
viewport={defaultViewport}
toolNodeId={toolNodeId}
paramKey={paramKey}
agentName={agentName}
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
mentionConfig={mentionConfig}
onMentionConfigChange={onMentionConfigChange}
onSave={onSave}
/>
</WorkflowWithDefaultContext>

View File

@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types'
@ -26,6 +27,8 @@ export type SubGraphProps = {
sourceVariable: ValueSelector
agentNodeId: string
agentName: string
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
extractorNode?: Node<LLMNodeType>
toolParamValue?: string
onSave?: (nodes: Node[], edges: Edge[]) => void