feat: Add mutual exclusion between structured output and tools in LLM

node
This commit is contained in:
zhsama
2026-02-04 22:28:26 +08:00
parent e0082dbf18
commit 9bd714623e
22 changed files with 314 additions and 105 deletions

View File

@ -85,6 +85,7 @@ type Props = {
required?: boolean
onBlur?: () => void
onFocus?: () => void
disableToolBlocks?: boolean
}
const Editor: FC<Props> = ({
@ -129,6 +130,7 @@ const Editor: FC<Props> = ({
required,
onBlur,
onFocus,
disableToolBlocks,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
@ -312,6 +314,7 @@ const Editor: FC<Props> = ({
editable={!readOnly}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
disableToolBlocks={disableToolBlocks}
toolMetadata={promptMetadata}
onToolMetadataChange={onPromptMetadataChange}
/>

View File

@ -13,6 +13,9 @@ const i18nPrefix = 'nodes.llm.computerUse'
type Props = {
readonly: boolean
isDisabledByStructuredOutput?: boolean
disabled?: boolean
disabledTip?: string
enabled: boolean
onChange: (enabled: boolean) => void
nodeId: string
@ -22,6 +25,9 @@ type Props = {
const ComputerUseConfig: FC<Props> = ({
readonly,
isDisabledByStructuredOutput,
disabled,
disabledTip,
enabled,
onChange,
nodeId,
@ -29,6 +35,8 @@ const ComputerUseConfig: FC<Props> = ({
promptTemplateKey,
}) => {
const { t } = useTranslation()
const disabledByStructuredOutput = isDisabledByStructuredOutput ?? disabled ?? false
const isDisabled = readonly || disabledByStructuredOutput
return (
<div>
@ -46,12 +54,17 @@ const ComputerUseConfig: FC<Props> = ({
noXSpacing
operations={(
<div>
<Switch
size="md"
disabled={readonly}
defaultValue={enabled}
onChange={onChange}
/>
<Tooltip
disabled={!disabledTip}
popupContent={disabledTip}
>
<Switch
size="md"
disabled={isDisabled}
defaultValue={enabled}
onChange={onChange}
/>
</Tooltip>
</div>
)}
>
@ -63,7 +76,8 @@ const ComputerUseConfig: FC<Props> = ({
</div>
<ReferenceToolConfig
readonly={readonly}
enabled={enabled}
isDisabledByStructuredOutput={disabledByStructuredOutput}
isComputerUseEnabled={enabled}
nodeId={nodeId}
toolSettings={toolSettings}
promptTemplateKey={promptTemplateKey}

View File

@ -43,6 +43,7 @@ type Props = {
modelConfig?: ModelConfig
isSupportSandbox?: boolean
onPromptEditorBlur?: () => void
disableToolBlocks?: boolean
}
const roleOptions = [
@ -88,6 +89,7 @@ const ConfigPromptItem: FC<Props> = ({
modelConfig,
isSupportSandbox,
onPromptEditorBlur,
disableToolBlocks,
}) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
@ -158,6 +160,7 @@ const ConfigPromptItem: FC<Props> = ({
handleAddVariable={handleAddVariable}
isSupportFileVar
isSupportSandbox={isSupportSandbox}
disableToolBlocks={disableToolBlocks}
onBlur={onPromptEditorBlur}
/>
)

View File

@ -66,6 +66,7 @@ type Props = {
handleAddVariable: (payload: any) => void
modelConfig: ModelConfig
onPromptEditorBlur?: () => void
disableToolBlocks?: boolean
}
const ConfigPrompt: FC<Props> = ({
@ -82,6 +83,7 @@ const ConfigPrompt: FC<Props> = ({
handleAddVariable,
modelConfig,
onPromptEditorBlur,
disableToolBlocks,
}) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
@ -361,6 +363,7 @@ const ConfigPrompt: FC<Props> = ({
modelConfig={modelConfig}
isSupportSandbox={isSupportSandbox}
onPromptEditorBlur={onPromptEditorBlur}
disableToolBlocks={disableToolBlocks}
/>
</div>
)
@ -438,6 +441,7 @@ const ConfigPrompt: FC<Props> = ({
modelConfig={modelConfig}
isSupportSandbox={isSupportSandbox}
onBlur={onPromptEditorBlur}
disableToolBlocks={disableToolBlocks}
/>
</div>
)}

View File

@ -1,40 +1,36 @@
'use client'
import type { FC } from 'react'
import type { LLMNodeType, ToolSetting } from '../types'
import type { ToolDependency } from '@/app/components/workflow/nodes/llm/use-node-skills'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { Locale } from '@/i18n-config/language'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Switch from '@/app/components/base/switch'
import { useNodeCurdKit } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useNodeSkills } from '@/app/components/workflow/nodes/llm/use-node-skills'
import useTheme from '@/hooks/use-theme'
import { getLanguage } from '@/i18n-config/language'
import { consoleClient, consoleQuery } from '@/service/client'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { getIconFromMarketPlace } from '@/utils/get-icon'
type ReferenceToolConfigProps = {
readonly: boolean
enabled: boolean
isDisabledByStructuredOutput?: boolean
isComputerUseEnabled?: boolean
disabledByStructuredOutput?: boolean
computerUseEnabled?: boolean
nodeId: string
toolSettings?: ToolSetting[]
promptTemplateKey: string
}
type ToolDependency = {
type: string
provider: string
tool_name: string
}
type ToolProviderGroup = {
id: string
actions: ToolDependency[]
@ -42,14 +38,18 @@ type ToolProviderGroup = {
const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
readonly,
enabled,
isDisabledByStructuredOutput,
isComputerUseEnabled,
disabledByStructuredOutput,
computerUseEnabled,
nodeId,
toolSettings,
promptTemplateKey,
}) => {
const isDisabled = readonly || !enabled
const resolvedIsComputerUseEnabled = isComputerUseEnabled ?? computerUseEnabled ?? false
const resolvedIsDisabledByStructuredOutput = isDisabledByStructuredOutput ?? disabledByStructuredOutput ?? false
const isReferenceToolsDisabled = readonly || !resolvedIsComputerUseEnabled || resolvedIsDisabledByStructuredOutput
const { i18n, t } = useTranslation()
const appId = useAppStore(s => s.appDetail?.id)
const { handleNodeDataUpdate } = useNodeCurdKit<LLMNodeType>(nodeId)
const { theme } = useTheme()
const { data: buildInTools } = useAllBuiltInTools()
@ -58,34 +58,11 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
const { data: mcpTools } = useAllMCPTools()
const locale = useMemo(() => getLanguage(i18n.language as Locale), [i18n.language])
const queryKey = useMemo(() => {
return [
...consoleQuery.workflowDraft.nodeSkills.queryKey({
input: {
params: {
appId: appId ?? '',
nodeId,
},
},
}),
promptTemplateKey,
]
}, [appId, nodeId, promptTemplateKey])
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => consoleClient.workflowDraft.nodeSkills({
params: {
appId: appId ?? '',
nodeId,
},
}),
enabled: !!appId && !!nodeId,
placeholderData: previous => previous,
const { toolDependencies, isLoading, isQueryEnabled, hasData } = useNodeSkills({
nodeId,
promptTemplateKey,
})
const toolDependencies = useMemo<ToolDependency[]>(() => data?.tool_dependencies ?? [], [data?.tool_dependencies])
const providers = useMemo<ToolProviderGroup[]>(() => {
const map = new Map<string, ToolDependency[]>()
toolDependencies.forEach((tool) => {
@ -214,8 +191,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
}))
}, [])
const isQueryEnabled = !!appId && !!nodeId
const isInitialLoading = isQueryEnabled && isLoading && !data
const isInitialLoading = isQueryEnabled && isLoading && !hasData
const showNoData = !isInitialLoading && providers.length === 0
const renderProviderIcon = useCallback((providerId: string) => {
@ -244,7 +220,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
}, [iconErrorMap, providerIcons])
return (
<div className={cn('flex flex-col gap-2', isDisabled && 'opacity-50')}>
<div className={cn('flex flex-col gap-2', isReferenceToolsDisabled && 'opacity-50')}>
{isInitialLoading && [0, 1].map(index => (
<div
key={`loading-provider-${index}`}
@ -296,7 +272,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
key={`${action.type}-${action.provider}-${action.tool_name}`}
className={cn(
'relative flex h-7 items-center justify-between rounded-md pl-9 pr-2',
!isDisabled && 'hover:bg-state-base-hover',
!isReferenceToolsDisabled && 'hover:bg-state-base-hover',
)}
>
<div className="absolute left-[15px] top-0 h-full w-[2px] bg-divider-subtle" />
@ -307,7 +283,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
</div>
<Switch
size="md"
disabled={isDisabled}
disabled={isReferenceToolsDisabled}
defaultValue={resolveToolEnabled(action)}
onChange={value => handleToolEnabledChange(action, value)}
/>

View File

@ -1,8 +1,10 @@
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
import { BoxGroup } from '@/app/components/workflow/nodes/_base/components/layout'
import { cn } from '@/utils/classnames'
import MaxIterations from './max-iterations'
import { useNodeTools } from './use-node-tools'
@ -11,14 +13,19 @@ type ToolsProps = {
tools?: ToolValue[]
maxIterations?: number
hideMaxIterations?: boolean
disabled?: boolean
disabledTip?: string
}
const Tools = ({
nodeId,
tools = [],
maxIterations = 10,
hideMaxIterations = false,
disabled,
disabledTip,
}: ToolsProps) => {
const { t } = useTranslation()
const isDisabled = !!disabled
const {
handleToolsChange,
handleMaxIterationsChange,
@ -34,22 +41,32 @@ const Tools = ({
className: 'px-0',
}}
>
<MultipleToolSelector
nodeId={nodeId}
nodeOutputVars={[]}
availableNodes={[]}
value={tools}
label={t(`nodes.llm.tools.title`, { ns: 'workflow' })}
tooltip={t(`nodes.llm.tools.title`, { ns: 'workflow' })}
onChange={handleToolsChange}
supportCollapse
/>
{!hideMaxIterations && (
<MaxIterations
value={maxIterations}
onChange={handleMaxIterationsChange}
/>
)}
<Tooltip
disabled={!disabledTip}
popupContent={disabledTip}
>
<div className={cn(isDisabled && 'opacity-50')}>
<div className={cn(isDisabled && 'pointer-events-none')}>
<MultipleToolSelector
nodeId={nodeId}
nodeOutputVars={[]}
availableNodes={[]}
value={tools}
label={t('nodes.llm.tools.title', { ns: 'workflow' })}
tooltip={t('nodes.llm.tools.title', { ns: 'workflow' })}
onChange={handleToolsChange}
supportCollapse
disabled={isDisabled}
/>
{!hideMaxIterations && (
<MaxIterations
value={maxIterations}
onChange={handleMaxIterationsChange}
/>
)}
</div>
</div>
</Tooltip>
</BoxGroup>
)
}

View File

@ -29,6 +29,8 @@ import Tools from './components/tools'
import MaxIterations from './components/tools/max-iterations'
import { useNodeTools } from './components/tools/use-node-tools'
import useConfig from './use-config'
import { useNodeSkills } from './use-node-skills'
import { useStructuredOutputMutualExclusion } from './use-structured-output-mutual-exclusion'
const i18nPrefix = 'nodes.llm'
@ -91,6 +93,27 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
scheduleSkillsRefresh(promptTemplateKey)
}, [promptTemplateKey, scheduleSkillsRefresh])
const { toolDependencies } = useNodeSkills({
nodeId: id,
promptTemplateKey: skillsRefreshKey,
enabled: isSupportSandbox,
})
const {
isStructuredOutputBlocked,
isComputerUseBlocked,
isToolsBlocked,
disableToolBlocks,
structuredOutputDisabledTip,
computerUseDisabledTip,
toolsDisabledTip,
} = useStructuredOutputMutualExclusion({
inputs,
readOnly,
isSupportSandbox,
toolDependencies,
})
const {
handleMaxIterationsChange,
} = useNodeTools(id)
@ -162,6 +185,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
handleAddVariable={handleAddVariable}
modelConfig={model}
onPromptEditorBlur={handlePromptEditorBlur}
disableToolBlocks={disableToolBlocks}
/>
)}
@ -248,6 +272,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
<>
<ComputerUseConfig
readonly={readOnly}
isDisabledByStructuredOutput={isComputerUseBlocked}
disabledTip={computerUseDisabledTip}
enabled={!!inputs.computer_use}
onChange={handleComputerUseChange}
nodeId={id}
@ -262,6 +288,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
tools={inputs.tools}
maxIterations={inputs.max_iterations}
hideMaxIterations
disabled={isToolsBlocked}
disabledTip={toolsDisabledTip}
/>
)}
</div>
@ -364,13 +392,19 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
<RiQuestionLine className="size-3.5 text-text-quaternary" />
</div>
</Tooltip>
<Switch
className="ml-2"
defaultValue={!!inputs.structured_output_enabled}
onChange={handleStructureOutputEnableChange}
size="md"
disabled={readOnly}
/>
<Tooltip
disabled={!structuredOutputDisabledTip}
popupContent={structuredOutputDisabledTip}
>
<div className="ml-2">
<Switch
defaultValue={!!inputs.structured_output_enabled}
onChange={handleStructureOutputEnableChange}
size="md"
disabled={isStructuredOutputBlocked}
/>
</div>
</Tooltip>
</div>
)}
>

View File

@ -0,0 +1,63 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { consoleClient, consoleQuery } from '@/service/client'
export type ToolDependency = {
type: string
provider: string
tool_name: string
}
type UseNodeSkillsParams = {
nodeId: string
promptTemplateKey: string
enabled?: boolean
}
export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: UseNodeSkillsParams) {
const appId = useAppStore(s => s.appDetail?.id)
const isQueryEnabled = enabled && !!appId && !!nodeId
const queryKey = useMemo(() => {
return [
...consoleQuery.workflowDraft.nodeSkills.queryKey({
input: {
params: {
appId: appId ?? '',
nodeId,
},
},
}),
promptTemplateKey,
]
}, [appId, nodeId, promptTemplateKey])
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => consoleClient.workflowDraft.nodeSkills({
params: {
appId: appId ?? '',
nodeId,
},
}),
enabled: isQueryEnabled,
placeholderData: previous => previous,
})
const toolDependencies = useMemo<ToolDependency[]>(
() => data?.tool_dependencies ?? [],
[data?.tool_dependencies],
)
const hasData = !!data
return {
toolDependencies,
isLoading,
isQueryEnabled,
hasData,
}
}

View File

@ -0,0 +1,59 @@
import type { LLMNodeType } from './types'
import type { ToolDependency } from './use-node-skills'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
type Params = {
inputs: LLMNodeType
readOnly: boolean
isSupportSandbox: boolean
toolDependencies: ToolDependency[]
}
export const useStructuredOutputMutualExclusion = ({
inputs,
readOnly,
isSupportSandbox,
toolDependencies,
}: Params) => {
const { t } = useTranslation()
const isStructuredOutputEnabled = !!inputs.structured_output_enabled
const hasToolDependencies = isSupportSandbox && toolDependencies.length > 0
const hasEnabledTools = (inputs.tools?.length ?? 0) > 0
const hasToolConflict = !!inputs.computer_use || hasToolDependencies || hasEnabledTools
const isStructuredOutputBlocked = readOnly || (hasToolConflict && !isStructuredOutputEnabled)
const isComputerUseBlocked = readOnly || (isStructuredOutputEnabled && !inputs.computer_use)
const isToolsBlocked = readOnly || isStructuredOutputEnabled
const disableToolBlocks = isStructuredOutputEnabled
const structuredOutputDisabledTip = useMemo(() => {
if (readOnly || !isStructuredOutputBlocked)
return ''
return inputs.computer_use
? t('structOutput.disabledByComputerUse', { ns: 'app' })
: t('structOutput.disabledByTools', { ns: 'app' })
}, [inputs.computer_use, isStructuredOutputBlocked, readOnly, t])
const computerUseDisabledTip = useMemo(() => {
if (readOnly || !isComputerUseBlocked)
return ''
return t('nodes.llm.computerUse.disabledByStructuredOutput', { ns: 'workflow' })
}, [isComputerUseBlocked, readOnly, t])
const toolsDisabledTip = useMemo(() => {
if (readOnly || !isToolsBlocked)
return ''
return t('nodes.llm.tools.disabledByStructuredOutput', { ns: 'workflow' })
}, [isToolsBlocked, readOnly, t])
return {
isStructuredOutputBlocked,
isComputerUseBlocked,
isToolsBlocked,
disableToolBlocks,
structuredOutputDisabledTip,
computerUseDisabledTip,
toolsDisabledTip,
}
}