mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
feat: enhance model plugin workflow checks and model provider management UX (#33289)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: statxc <tyleradams93226@gmail.com>
This commit is contained in:
@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Field from './field'
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
||||
}))
|
||||
|
||||
describe('Field', () => {
|
||||
it('should render subtitle styling, tooltip, operations, warning dot and required marker', () => {
|
||||
const { container } = render(
|
||||
<Field
|
||||
title="Knowledge"
|
||||
tooltip="tooltip text"
|
||||
operations={<button type="button">operation</button>}
|
||||
required
|
||||
warningDot
|
||||
isSubTitle
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('tooltip text')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument()
|
||||
expect(screen.getByText('*')).toBeInTheDocument()
|
||||
expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull()
|
||||
expect(container.querySelector('.bg-text-warning-secondary')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should toggle folded children when supportFold is enabled', () => {
|
||||
const { container } = render(
|
||||
<Field title="Foldable" supportFold>
|
||||
<div>folded content</div>
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('folded content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!)
|
||||
expect(screen.getByText('folded content')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toHaveStyle({ transform: 'rotate(0deg)' })
|
||||
|
||||
fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!)
|
||||
expect(screen.queryByText('folded content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render inline children without folding support', () => {
|
||||
const { container } = render(
|
||||
<Field title="Inline" inline>
|
||||
<div>always visible</div>
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('always visible')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('flex')
|
||||
})
|
||||
})
|
||||
@ -18,6 +18,7 @@ type Props = {
|
||||
operations?: React.JSX.Element
|
||||
inline?: boolean
|
||||
required?: boolean
|
||||
warningDot?: boolean
|
||||
}
|
||||
|
||||
const Field: FC<Props> = ({
|
||||
@ -30,6 +31,7 @@ const Field: FC<Props> = ({
|
||||
inline,
|
||||
supportFold,
|
||||
required,
|
||||
warningDot,
|
||||
}) => {
|
||||
const [fold, {
|
||||
toggle: toggleFold,
|
||||
@ -41,7 +43,10 @@ const Field: FC<Props> = ({
|
||||
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')}>
|
||||
<div className={cn('relative', isSubTitle ? 'text-text-tertiary system-xs-medium-uppercase' : 'text-text-secondary system-sm-semibold-uppercase')}>
|
||||
{warningDot && (
|
||||
<span className="absolute -left-[9px] top-1/2 size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" />
|
||||
)}
|
||||
{title}
|
||||
{' '}
|
||||
{required && <span className="text-text-destructive">*</span>}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FieldTitle } from './field-title'
|
||||
|
||||
vi.mock('@/app/components/base/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('FieldTitle', () => {
|
||||
it('should render title, subtitle, operation, tooltip and warning dot', () => {
|
||||
render(
|
||||
<FieldTitle
|
||||
title="Embedding"
|
||||
subTitle={<div>subtitle</div>}
|
||||
operation={<button type="button">action</button>}
|
||||
tooltip="Tooltip copy"
|
||||
warningDot
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Embedding')).toBeInTheDocument()
|
||||
expect(screen.getByText('subtitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tooltip copy')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-text-warning-secondary')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should toggle local collapsed state and notify onCollapse when enabled', () => {
|
||||
const onCollapse = vi.fn()
|
||||
const { container } = render(
|
||||
<FieldTitle
|
||||
title="Models"
|
||||
showArrow
|
||||
onCollapse={onCollapse}
|
||||
/>,
|
||||
)
|
||||
|
||||
const header = screen.getByText('Models').closest('.group\\/collapse')
|
||||
const arrow = container.querySelector('[aria-hidden="true"]')
|
||||
|
||||
expect(arrow).toHaveClass('rotate-[270deg]')
|
||||
|
||||
fireEvent.click(header!)
|
||||
|
||||
expect(onCollapse).toHaveBeenCalledWith(false)
|
||||
expect(arrow).not.toHaveClass('rotate-[270deg]')
|
||||
})
|
||||
|
||||
it('should respect controlled collapsed state and ignore clicks when disabled', () => {
|
||||
const onCollapse = vi.fn()
|
||||
const { container } = render(
|
||||
<FieldTitle
|
||||
title="Controlled"
|
||||
showArrow
|
||||
collapsed={false}
|
||||
disabled
|
||||
onCollapse={onCollapse}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Controlled').closest('.group\\/collapse')!)
|
||||
|
||||
expect(onCollapse).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).not.toHaveClass('rotate-[270deg]')
|
||||
})
|
||||
})
|
||||
@ -3,8 +3,7 @@ import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type FieldTitleProps = {
|
||||
@ -12,6 +11,7 @@ export type FieldTitleProps = {
|
||||
operation?: ReactNode
|
||||
subTitle?: string | ReactNode
|
||||
tooltip?: string
|
||||
warningDot?: boolean
|
||||
showArrow?: boolean
|
||||
disabled?: boolean
|
||||
collapsed?: boolean
|
||||
@ -22,6 +22,7 @@ export const FieldTitle = memo(({
|
||||
operation,
|
||||
subTitle,
|
||||
tooltip,
|
||||
warningDot,
|
||||
showArrow,
|
||||
disabled,
|
||||
collapsed,
|
||||
@ -41,13 +42,19 @@ export const FieldTitle = memo(({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
|
||||
{title}
|
||||
<div className="flex items-center text-text-secondary system-sm-semibold-uppercase">
|
||||
<span className="relative">
|
||||
{warningDot && (
|
||||
<span className="absolute -left-[9px] top-1/2 size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" />
|
||||
)}
|
||||
{title}
|
||||
</span>
|
||||
{
|
||||
showArrow && (
|
||||
<ArrowDownRoundFill
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
|
||||
collapsedMerged && 'rotate-[270deg]',
|
||||
)}
|
||||
/>
|
||||
@ -55,10 +62,19 @@ export const FieldTitle = memo(({
|
||||
}
|
||||
{
|
||||
tooltip && (
|
||||
<Tooltip
|
||||
popupContent={tooltip}
|
||||
triggerClassName="w-4 h-4 ml-1"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
render={(
|
||||
<span className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
import type { CommonNodeType } from '../../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../types'
|
||||
import NodeControl from './node-control'
|
||||
|
||||
const {
|
||||
mockHandleNodeSelect,
|
||||
mockSetInitShowLastRunTab,
|
||||
mockSetPendingSingleRun,
|
||||
mockCanRunBySingle,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleNodeSelect: vi.fn(),
|
||||
mockSetInitShowLastRunTab: vi.fn(),
|
||||
mockSetPendingSingleRun: vi.fn(),
|
||||
mockCanRunBySingle: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
Stop: ({ className }: { className?: string }) => <div data-testid="stop-icon" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setInitShowLastRunTab: mockSetInitShowLastRunTab,
|
||||
setPendingSingleRun: mockSetPendingSingleRun,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils', () => ({
|
||||
canRunBySingle: mockCanRunBySingle,
|
||||
}))
|
||||
|
||||
vi.mock('./panel-operator', () => ({
|
||||
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
|
||||
<>
|
||||
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>close panel</button>
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
const makeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
type: BlockEnum.Code,
|
||||
title: 'Node',
|
||||
desc: '',
|
||||
selected: false,
|
||||
_singleRunningStatus: undefined,
|
||||
isInIteration: false,
|
||||
isInLoop: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('NodeControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanRunBySingle.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('should trigger a single run and show the hover control when plugins are not locked', () => {
|
||||
const { container } = render(
|
||||
<NodeControl
|
||||
id="node-1"
|
||||
data={makeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('group-hover:flex')
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep')
|
||||
|
||||
fireEvent.click(screen.getByTestId('tooltip').parentElement!)
|
||||
|
||||
expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
|
||||
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' })
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
})
|
||||
|
||||
it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => {
|
||||
const { container } = render(
|
||||
<NodeControl
|
||||
id="node-2"
|
||||
pluginInstallLocked
|
||||
data={makeData({
|
||||
selected: true,
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
isInIteration: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).not.toContain('group-hover:flex')
|
||||
expect(wrapper.className).toContain('!flex')
|
||||
expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('stop-icon').parentElement!)
|
||||
|
||||
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open panel' }))
|
||||
expect(wrapper.className).toContain('!flex')
|
||||
})
|
||||
|
||||
it('should hide the run control when single-node execution is not supported', () => {
|
||||
mockCanRunBySingle.mockReturnValue(false)
|
||||
|
||||
render(
|
||||
<NodeControl
|
||||
id="node-3"
|
||||
data={makeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -21,10 +21,13 @@ import { NodeRunningStatus } from '../../../types'
|
||||
import { canRunBySingle } from '../../../utils'
|
||||
import PanelOperator from './panel-operator'
|
||||
|
||||
type NodeControlProps = Pick<Node, 'id' | 'data'>
|
||||
type NodeControlProps = Pick<Node, 'id' | 'data'> & {
|
||||
pluginInstallLocked?: boolean
|
||||
}
|
||||
const NodeControl: FC<NodeControlProps> = ({
|
||||
id,
|
||||
data,
|
||||
pluginInstallLocked,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -40,7 +43,7 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
<div
|
||||
className={`
|
||||
absolute -top-7 right-0 hidden h-7 pb-1
|
||||
${!data._pluginInstallLocked && 'group-hover:flex'}
|
||||
${!pluginInstallLocked && 'group-hover:flex'}
|
||||
${data.selected && '!flex'}
|
||||
${open && '!flex'}
|
||||
`}
|
||||
|
||||
@ -240,7 +240,7 @@ const Editor: FC<Props> = ({
|
||||
<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)}>
|
||||
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
|
||||
<PromptEditor
|
||||
key={controlPromptEditorRerenderKey}
|
||||
placeholder={placeholder}
|
||||
@ -278,6 +278,9 @@ const Editor: FC<Props> = ({
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
position: node.position,
|
||||
...(node.data.type === BlockEnum.LLM && {
|
||||
modelProvider: (node.data as { model?: ModelConfig }).model?.provider,
|
||||
}),
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
@ -301,7 +304,7 @@ const Editor: FC<Props> = ({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
|
||||
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
|
||||
<CodeEditor
|
||||
availableVars={nodesOutputVars || []}
|
||||
varList={varList}
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import type { VariablePayload } from '../types'
|
||||
import {
|
||||
RiErrorWarningFill,
|
||||
RiMoreLine,
|
||||
} from '@remixicon/react'
|
||||
import { capitalize } from 'es-toolkit/string'
|
||||
import { memo } from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar } from '../../utils'
|
||||
import { useVarColor } from '../hooks'
|
||||
@ -28,7 +25,8 @@ const VariableLabel = ({
|
||||
}: VariablePayload) => {
|
||||
const varColorClassName = useVarColor(variables, isExceptionVariable)
|
||||
const isShowNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables))
|
||||
return (
|
||||
|
||||
const badge = (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-6 max-w-full items-center space-x-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 shadow-xs',
|
||||
@ -47,7 +45,7 @@ const VariableLabel = ({
|
||||
{
|
||||
notShowFullPath && (
|
||||
<>
|
||||
<RiMoreLine className="h-3 w-3 shrink-0 text-text-secondary" />
|
||||
<span className="i-ri-more-line h-3 w-3 shrink-0 text-text-secondary" />
|
||||
<div className="shrink-0 text-divider-deep system-xs-regular">/</div>
|
||||
</>
|
||||
)
|
||||
@ -70,12 +68,7 @@ const VariableLabel = ({
|
||||
}
|
||||
{
|
||||
!!errorMsg && (
|
||||
<Tooltip
|
||||
popupContent={errorMsg}
|
||||
asChild
|
||||
>
|
||||
<RiErrorWarningFill className="h-3 w-3 shrink-0 text-text-destructive" />
|
||||
</Tooltip>
|
||||
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
|
||||
)
|
||||
}
|
||||
{
|
||||
@ -83,6 +76,16 @@ const VariableLabel = ({
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!errorMsg)
|
||||
return badge
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={badge} />
|
||||
<TooltipContent>{errorMsg}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(VariableLabel)
|
||||
|
||||
@ -28,8 +28,8 @@ import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/
|
||||
import useStartSingleRunFormParams from '@/app/components/workflow/nodes/start/use-single-run-form-params'
|
||||
import useTemplateTransformSingleRunFormParams from '@/app/components/workflow/nodes/template-transform/use-single-run-form-params'
|
||||
|
||||
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
|
||||
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/use-single-run-form-params'
|
||||
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more'
|
||||
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/hooks/use-single-run-form-params'
|
||||
import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params'
|
||||
import useVariableAggregatorSingleRunFormParams from '@/app/components/workflow/nodes/variable-assigner/use-single-run-form-params'
|
||||
|
||||
@ -159,10 +159,10 @@ const useLastRun = <T>({
|
||||
if (!warningForNode)
|
||||
return false
|
||||
|
||||
if (warningForNode.unConnected && !warningForNode.errorMessage)
|
||||
if (warningForNode.unConnected && warningForNode.errorMessages.length === 0)
|
||||
return false
|
||||
|
||||
const message = warningForNode.errorMessage || 'This node has unresolved checklist issues'
|
||||
const message = warningForNode.errorMessages[0] || 'This node has unresolved checklist issues'
|
||||
Toast.notify({ type: 'error', message })
|
||||
return true
|
||||
}, [warningNodes, id])
|
||||
|
||||
@ -17,6 +17,7 @@ import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
|
||||
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
|
||||
@ -61,6 +62,8 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
|
||||
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
|
||||
const toolIcon = useToolIcon(data)
|
||||
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
|
||||
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && data.selected && data.isInIteration) {
|
||||
@ -138,7 +141,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
'relative flex rounded-2xl border',
|
||||
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
|
||||
data._waitingRun && 'opacity-70',
|
||||
data._pluginInstallLocked && 'cursor-not-allowed',
|
||||
pluginInstallLocked && 'cursor-not-allowed',
|
||||
)}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
@ -146,14 +149,15 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
|
||||
}}
|
||||
>
|
||||
{(data._dimmed || data._pluginInstallLocked) && (
|
||||
{(data._dimmed || pluginDimmed || pluginInstallLocked) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-2xl transition-opacity',
|
||||
data._pluginInstallLocked
|
||||
pluginInstallLocked
|
||||
? 'pointer-events-auto z-30 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]'
|
||||
: 'pointer-events-none z-20 bg-workflow-block-parma-bg opacity-50',
|
||||
)}
|
||||
onClick={pluginInstallLocked ? e => e.stopPropagation() : undefined}
|
||||
data-testid="workflow-node-install-overlay"
|
||||
/>
|
||||
)}
|
||||
@ -229,6 +233,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
<NodeControl
|
||||
id={id}
|
||||
data={data}
|
||||
pluginInstallLocked={pluginInstallLocked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { memo, useEffect } from 'react'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { memo } from 'react'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
|
||||
const Node: FC<NodeProps<DataSourceNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
@ -16,22 +14,7 @@ const Node: FC<NodeProps<DataSourceNodeType>> = ({
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../types'
|
||||
import ChunkStructure from './index'
|
||||
|
||||
const mockUseChunkStructure = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
|
||||
Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { title: string, warningDot?: boolean, operation?: ReactNode } }) => (
|
||||
<div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}>
|
||||
<div>{fieldTitleProps.title}</div>
|
||||
{fieldTitleProps.operation}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useChunkStructure: mockUseChunkStructure,
|
||||
}))
|
||||
|
||||
vi.mock('../option-card', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="option-card">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./selector', () => ({
|
||||
default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => (
|
||||
<div data-testid="selector">
|
||||
{value ?? 'no-value'}
|
||||
{trigger}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./instruction', () => ({
|
||||
default: ({ className }: { className?: string }) => <div data-testid="instruction" className={className}>Instruction</div>,
|
||||
}))
|
||||
|
||||
describe('ChunkStructure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseChunkStructure.mockReturnValue({
|
||||
options: [{ value: ChunkStructureEnum.general, label: 'General' }],
|
||||
optionMap: {
|
||||
[ChunkStructureEnum.general]: {
|
||||
title: 'General Chunk Structure',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the selected option and warning dot metadata when a chunk structure is chosen', () => {
|
||||
render(
|
||||
<ChunkStructure
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
warningDot
|
||||
onChunkStructureChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('field')).toHaveAttribute('data-warning-dot', 'true')
|
||||
expect(screen.getByTestId('selector')).toHaveTextContent(ChunkStructureEnum.general)
|
||||
expect(screen.getByTestId('option-card')).toHaveTextContent('General Chunk Structure')
|
||||
expect(screen.queryByTestId('instruction')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the add trigger and instruction when no chunk structure is selected', () => {
|
||||
render(
|
||||
<ChunkStructure
|
||||
onChunkStructureChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /chooseChunkStructure/i })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('instruction')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ChunkStructureEnum } from '../../types'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -12,11 +11,13 @@ import Selector from './selector'
|
||||
type ChunkStructureProps = {
|
||||
chunkStructure?: ChunkStructureEnum
|
||||
onChunkStructureChange: (value: ChunkStructureEnum) => void
|
||||
warningDot?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
const ChunkStructure = ({
|
||||
chunkStructure,
|
||||
onChunkStructureChange,
|
||||
warningDot = false,
|
||||
readonly = false,
|
||||
}: ChunkStructureProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -30,6 +31,7 @@ const ChunkStructure = ({
|
||||
fieldTitleProps={{
|
||||
title: t('nodes.knowledgeBase.chunkStructure', { ns: 'workflow' }),
|
||||
tooltip: t('nodes.knowledgeBase.chunkStructureTip.message', { ns: 'workflow' }),
|
||||
warningDot,
|
||||
operation: chunkStructure && (
|
||||
<Selector
|
||||
options={options}
|
||||
@ -62,7 +64,7 @@ const ChunkStructure = ({
|
||||
className="w-full"
|
||||
variant="secondary-accent"
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
<span className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('nodes.knowledgeBase.chooseChunkStructure', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import EmbeddingModel from './embedding-model'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockModelSelector = vi.hoisted(() => vi.fn(() => <div data-testid="model-selector">selector</div>))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
|
||||
Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { warningDot?: boolean } }) => (
|
||||
<div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: mockUseModelList,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: mockModelSelector,
|
||||
}))
|
||||
|
||||
describe('EmbeddingModel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelList.mockReturnValue({ data: [{ provider: 'openai', model: 'text-embedding-3-large' }] })
|
||||
})
|
||||
|
||||
it('should pass the selected model configuration and warning state to the selector field', () => {
|
||||
const onEmbeddingModelChange = vi.fn()
|
||||
|
||||
render(
|
||||
<EmbeddingModel
|
||||
embeddingModel="text-embedding-3-large"
|
||||
embeddingModelProvider="openai"
|
||||
warningDot
|
||||
onEmbeddingModelChange={onEmbeddingModelChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding)
|
||||
expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({
|
||||
defaultModel: {
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-3-large',
|
||||
},
|
||||
modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }],
|
||||
readonly: false,
|
||||
showDeprecatedWarnIcon: true,
|
||||
}), undefined)
|
||||
})
|
||||
|
||||
it('should pass an undefined default model when the embedding model is incomplete', () => {
|
||||
render(<EmbeddingModel embeddingModel="text-embedding-3-large" />)
|
||||
|
||||
expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({
|
||||
defaultModel: undefined,
|
||||
}), undefined)
|
||||
})
|
||||
})
|
||||
@ -17,12 +17,14 @@ type EmbeddingModelProps = {
|
||||
embeddingModel: string
|
||||
embeddingModelProvider: string
|
||||
}) => void
|
||||
warningDot?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
const EmbeddingModel = ({
|
||||
embeddingModel,
|
||||
embeddingModelProvider,
|
||||
onEmbeddingModelChange,
|
||||
warningDot = false,
|
||||
readonly = false,
|
||||
}: EmbeddingModelProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -50,6 +52,7 @@ const EmbeddingModel = ({
|
||||
<Field
|
||||
fieldTitleProps={{
|
||||
title: t('form.embeddingModel', { ns: 'datasetSettings' }),
|
||||
warningDot,
|
||||
}}
|
||||
>
|
||||
<ModelSelector
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
import type {
|
||||
DefaultModel,
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import RerankingModelSelector from './reranking-model-selector'
|
||||
|
||||
type MockModelSelectorProps = {
|
||||
defaultModel?: DefaultModel
|
||||
modelList: Model[]
|
||||
onSelect?: (model: DefaultModel) => void
|
||||
}
|
||||
|
||||
const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModel: mockUseModelListAndDefaultModel,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, modelList, onSelect }: MockModelSelectorProps) => (
|
||||
<div>
|
||||
<div data-testid="default-model">
|
||||
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-default-model'}
|
||||
</div>
|
||||
<div data-testid="model-list-count">{modelList.length}</div>
|
||||
<button type="button" onClick={() => onSelect?.({ provider: 'cohere', model: 'rerank-v3' })}>
|
||||
select-model
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'rerank-v3',
|
||||
label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' },
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'cohere',
|
||||
icon_small: {
|
||||
en_US: 'https://example.com/cohere.png',
|
||||
zh_Hans: 'https://example.com/cohere.png',
|
||||
},
|
||||
icon_small_dark: {
|
||||
en_US: 'https://example.com/cohere-dark.png',
|
||||
zh_Hans: 'https://example.com/cohere-dark.png',
|
||||
},
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RerankingModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({
|
||||
modelList: [createModel()],
|
||||
defaultModel: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// Rendering behavior for mapped rerank model state.
|
||||
describe('Rendering', () => {
|
||||
it('should not pass a default model when reranking model fields are empty strings', () => {
|
||||
render(
|
||||
<RerankingModelSelector
|
||||
rerankingModel={{
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('default-model')).toHaveTextContent('no-default-model')
|
||||
expect(screen.getByTestId('model-list-count')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should map reranking model to default model when both fields exist', () => {
|
||||
render(
|
||||
<RerankingModelSelector
|
||||
rerankingModel={{
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-v3',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('default-model')).toHaveTextContent('cohere/rerank-v3')
|
||||
})
|
||||
})
|
||||
|
||||
// Selection behavior should convert back to workflow reranking model shape.
|
||||
describe('Interactions', () => {
|
||||
it('should map selected model back to reranking model fields', () => {
|
||||
const onRerankingModelChange = vi.fn()
|
||||
|
||||
render(<RerankingModelSelector onRerankingModelChange={onRerankingModelChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
|
||||
|
||||
expect(onRerankingModelChange).toHaveBeenCalledWith({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-v3',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -22,12 +22,12 @@ const RerankingModelSelector = ({
|
||||
modelList: rerankModelList,
|
||||
} = useModelListAndDefaultModel(ModelTypeEnum.rerank)
|
||||
const rerankModel = useMemo(() => {
|
||||
if (!rerankingModel)
|
||||
if (!rerankingModel?.reranking_provider_name || !rerankingModel?.reranking_model_name)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
providerName: rerankingModel.reranking_provider_name,
|
||||
modelName: rerankingModel.reranking_model_name,
|
||||
provider: rerankingModel.reranking_provider_name,
|
||||
model: rerankingModel.reranking_model_name,
|
||||
}
|
||||
}, [rerankingModel])
|
||||
|
||||
@ -40,7 +40,7 @@ const RerankingModelSelector = ({
|
||||
|
||||
return (
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.providerName, model: rerankModel.modelName }}
|
||||
defaultModel={rerankModel}
|
||||
modelList={rerankModelList}
|
||||
onSelect={handleRerankingModelChange}
|
||||
readonly={readonly}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import nodeDefault from './default'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [{
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [{
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
}],
|
||||
status,
|
||||
}]
|
||||
|
||||
const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => [{
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
}]
|
||||
|
||||
const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({
|
||||
...nodeDefault.defaultValue,
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model: {
|
||||
...nodeDefault.defaultValue.retrieval_model,
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
},
|
||||
_embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active),
|
||||
_embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active),
|
||||
_rerankModelList: [],
|
||||
...overrides,
|
||||
}) as KnowledgeBaseNodeType
|
||||
|
||||
describe('knowledge-base default node validation', () => {
|
||||
it('should return an invalid result when the payload has a validation issue', () => {
|
||||
const result = nodeDefault.checkValid(createPayload({ chunk_structure: undefined }), t)
|
||||
|
||||
expect(result).toEqual({
|
||||
isValid: false,
|
||||
errorMessage: 'nodes.knowledgeBase.chunkIsRequired',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a valid result when the payload is complete', () => {
|
||||
const result = nodeDefault.checkValid(createPayload(), t)
|
||||
|
||||
expect(result).toEqual({
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,11 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import {
|
||||
getKnowledgeBaseValidationIssue,
|
||||
getKnowledgeBaseValidationMessage,
|
||||
} from './utils'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 3.1,
|
||||
@ -24,86 +27,9 @@ const nodeDefault: NodeDefault<KnowledgeBaseNodeType> = {
|
||||
},
|
||||
},
|
||||
checkValid(payload, t) {
|
||||
const {
|
||||
chunk_structure,
|
||||
indexing_technique,
|
||||
retrieval_model,
|
||||
embedding_model,
|
||||
embedding_model_provider,
|
||||
index_chunk_variable_selector,
|
||||
_embeddingModelList,
|
||||
_rerankModelList,
|
||||
} = payload
|
||||
|
||||
const {
|
||||
search_method,
|
||||
reranking_enable,
|
||||
reranking_model,
|
||||
} = retrieval_model || {}
|
||||
|
||||
const currentEmbeddingModelProvider = _embeddingModelList?.find(provider => provider.provider === embedding_model_provider)
|
||||
const currentEmbeddingModel = currentEmbeddingModelProvider?.models.find(model => model.model === embedding_model)
|
||||
|
||||
const currentRerankingModelProvider = _rerankModelList?.find(provider => provider.provider === reranking_model?.reranking_provider_name)
|
||||
const currentRerankingModel = currentRerankingModelProvider?.models.find(model => model.model === reranking_model?.reranking_model_name)
|
||||
|
||||
if (!chunk_structure) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.chunkIsRequired', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (index_chunk_variable_selector.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.chunksVariableIsRequired', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (!indexing_technique) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.indexMethodIsRequired', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (indexing_technique === IndexingType.QUALIFIED) {
|
||||
if (!embedding_model || !embedding_model_provider) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.embeddingModelIsRequired', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
else if (!currentEmbeddingModel) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.embeddingModelIsInvalid', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!retrieval_model || !search_method) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.retrievalSettingIsRequired', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (reranking_enable) {
|
||||
if (!reranking_model || !reranking_model.reranking_provider_name || !reranking_model.reranking_model_name) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.rerankingModelIsRequired', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
else if (!currentRerankingModel) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('nodes.knowledgeBase.rerankingModelIsInvalid', { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
}
|
||||
const issue = getKnowledgeBaseValidationIssue(payload)
|
||||
if (issue)
|
||||
return { isValid: false, errorMessage: getKnowledgeBaseValidationMessage(issue, t) }
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useMemo } from 'react'
|
||||
import { deriveModelStatus } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
|
||||
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
|
||||
type UseEmbeddingModelStatusProps = {
|
||||
embeddingModel?: string
|
||||
embeddingModelProvider?: string
|
||||
embeddingModelList: Model[]
|
||||
}
|
||||
|
||||
type UseEmbeddingModelStatusResult = {
|
||||
providerMeta: ModelProvider | undefined
|
||||
modelProvider: Model | undefined
|
||||
currentModel: ModelItem | undefined
|
||||
status: ReturnType<typeof deriveModelStatus>
|
||||
}
|
||||
|
||||
export const useEmbeddingModelStatus = ({
|
||||
embeddingModel,
|
||||
embeddingModelProvider,
|
||||
embeddingModelList,
|
||||
}: UseEmbeddingModelStatusProps): UseEmbeddingModelStatusResult => {
|
||||
const { modelProviders } = useProviderContext()
|
||||
|
||||
const providerMeta = useMemo(() => {
|
||||
return modelProviders.find(provider => provider.provider === embeddingModelProvider)
|
||||
}, [embeddingModelProvider, modelProviders])
|
||||
|
||||
const modelProvider = useMemo(() => {
|
||||
return embeddingModelList.find(provider => provider.provider === embeddingModelProvider)
|
||||
}, [embeddingModelList, embeddingModelProvider])
|
||||
|
||||
const currentModel = useMemo(() => {
|
||||
return modelProvider?.models.find(model => model.model === embeddingModel)
|
||||
}, [embeddingModel, modelProvider])
|
||||
|
||||
const credentialState = useCredentialPanelState(providerMeta)
|
||||
|
||||
const status = useMemo(() => {
|
||||
return deriveModelStatus(
|
||||
embeddingModel,
|
||||
embeddingModelProvider,
|
||||
providerMeta,
|
||||
currentModel,
|
||||
credentialState,
|
||||
)
|
||||
}, [credentialState, currentModel, embeddingModel, embeddingModelProvider, providerMeta])
|
||||
|
||||
return {
|
||||
providerMeta,
|
||||
modelProvider,
|
||||
currentModel,
|
||||
status,
|
||||
}
|
||||
}
|
||||
233
web/app/components/workflow/nodes/knowledge-base/node.spec.tsx
Normal file
233
web/app/components/workflow/nodes/knowledge-base/node.spec.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from './node'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUseSettingsDisplay = vi.hoisted(() => vi.fn())
|
||||
const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({ data: undefined }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => 'en_US',
|
||||
useModelList: mockUseModelList,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./hooks/use-settings-display', () => ({
|
||||
useSettingsDisplay: mockUseSettingsDisplay,
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-embedding-model-status', () => ({
|
||||
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
|
||||
}))
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createNodeData = (overrides: Partial<CommonNodeType<KnowledgeBaseNodeType>> = {}): CommonNodeType<KnowledgeBaseNodeType> => ({
|
||||
title: 'Knowledge Base',
|
||||
desc: '',
|
||||
type: BlockEnum.KnowledgeBase,
|
||||
index_chunk_variable_selector: ['result'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 10,
|
||||
retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('KnowledgeBaseNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelList.mockReturnValue({ data: [] })
|
||||
mockUseSettingsDisplay.mockReturnValue({
|
||||
[IndexMethodEnum.QUALIFIED]: 'High Quality',
|
||||
[RetrievalSearchMethodEnum.semantic]: 'Vector Search',
|
||||
})
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({
|
||||
providerMeta: undefined,
|
||||
modelProvider: undefined,
|
||||
currentModel: createModelItem(),
|
||||
status: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
// Embedding model row should mirror the selector status labels.
|
||||
describe('Embedding Model Status', () => {
|
||||
it('should render active embedding model label when the model is available', () => {
|
||||
render(<Node id="knowledge-base-1" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByText('Text Embedding 3 Large')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure required when embedding model status requires configuration', () => {
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({
|
||||
providerMeta: undefined,
|
||||
modelProvider: undefined,
|
||||
currentModel: createModelItem({ status: ModelStatusEnum.noConfigure }),
|
||||
status: 'configure-required',
|
||||
})
|
||||
|
||||
render(<Node id="knowledge-base-1" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled when embedding model status is disabled', () => {
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({
|
||||
providerMeta: undefined,
|
||||
modelProvider: undefined,
|
||||
currentModel: createModelItem({ status: ModelStatusEnum.disabled }),
|
||||
status: 'disabled',
|
||||
})
|
||||
|
||||
render(<Node id="knowledge-base-1" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible when embedding model status is incompatible', () => {
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({
|
||||
providerMeta: undefined,
|
||||
modelProvider: undefined,
|
||||
currentModel: undefined,
|
||||
status: 'incompatible',
|
||||
})
|
||||
|
||||
render(<Node id="knowledge-base-1" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure model prompt when no embedding model is selected', () => {
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({
|
||||
providerMeta: undefined,
|
||||
modelProvider: undefined,
|
||||
currentModel: undefined,
|
||||
status: 'empty',
|
||||
})
|
||||
|
||||
render(
|
||||
<Node
|
||||
id="knowledge-base-1"
|
||||
data={createNodeData({
|
||||
embedding_model: undefined,
|
||||
embedding_model_provider: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation warnings', () => {
|
||||
it('should render a warning banner when chunk structure is missing', () => {
|
||||
render(
|
||||
<Node
|
||||
id="knowledge-base-1"
|
||||
data={createNodeData({
|
||||
chunk_structure: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/chunkIsRequired/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a warning value for the chunks input row when no chunk variable is selected', () => {
|
||||
render(
|
||||
<Node
|
||||
id="knowledge-base-1"
|
||||
data={createNodeData({
|
||||
index_chunk_variable_selector: [],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/chunksVariableIsRequired/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a warning value for retrieval settings when reranking is incomplete', () => {
|
||||
mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => {
|
||||
if (modelType === ModelTypeEnum.textEmbedding) {
|
||||
return {
|
||||
data: [{
|
||||
provider: 'openai',
|
||||
models: [createModelItem()],
|
||||
}],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
})
|
||||
|
||||
render(
|
||||
<Node
|
||||
id="knowledge-base-1"
|
||||
data={createNodeData({
|
||||
retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: true,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/rerankingModelIsRequired/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the embedding model row when the index method is not qualified', () => {
|
||||
render(
|
||||
<Node
|
||||
id="knowledge-base-1"
|
||||
data={createNodeData({
|
||||
indexing_technique: IndexMethodEnum.ECONOMICAL,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,38 +1,210 @@
|
||||
import type { FC } from 'react'
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { memo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { DERIVED_MODEL_STATUS_BADGE_I18N } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
|
||||
import {
|
||||
useLanguage,
|
||||
useModelList,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
|
||||
import { useSettingsDisplay } from './hooks/use-settings-display'
|
||||
import {
|
||||
IndexMethodEnum,
|
||||
} from './types'
|
||||
import {
|
||||
getKnowledgeBaseValidationIssue,
|
||||
getKnowledgeBaseValidationMessage,
|
||||
KnowledgeBaseValidationIssueCode,
|
||||
} from './utils'
|
||||
|
||||
type SettingRowProps = {
|
||||
label: string
|
||||
value: string
|
||||
warning?: boolean
|
||||
}
|
||||
|
||||
const SettingRow = memo(({
|
||||
label,
|
||||
value,
|
||||
warning = false,
|
||||
}: SettingRowProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 items-center rounded-md px-1.5',
|
||||
warning
|
||||
? 'border-[0.5px] border-state-warning-active bg-state-warning-hover'
|
||||
: 'bg-workflow-block-parma-bg',
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 shrink-0 text-text-tertiary system-xs-medium-uppercase">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn('grow truncate text-right system-xs-medium', warning ? 'text-text-warning' : 'text-text-secondary')}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const RETRIEVAL_WARNING_CODES = new Set<KnowledgeBaseValidationIssueCode>([
|
||||
KnowledgeBaseValidationIssueCode.retrievalSettingRequired,
|
||||
KnowledgeBaseValidationIssueCode.rerankingModelRequired,
|
||||
KnowledgeBaseValidationIssueCode.rerankingModelInvalid,
|
||||
])
|
||||
|
||||
const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const settingsDisplay = useSettingsDisplay()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const chunkStructure = data.chunk_structure
|
||||
const indexChunkVariableSelector = data.index_chunk_variable_selector
|
||||
const indexingTechnique = data.indexing_technique
|
||||
const embeddingModel = data.embedding_model
|
||||
const retrievalModel = data.retrieval_model
|
||||
const retrievalSearchMethod = retrievalModel?.search_method
|
||||
const retrievalRerankingEnable = retrievalModel?.reranking_enable
|
||||
const embeddingModelProvider = data.embedding_model_provider
|
||||
const { data: embeddingProviderModelList } = useQuery(
|
||||
consoleQuery.modelProviders.models.queryOptions({
|
||||
input: { params: { provider: embeddingModelProvider || '' } },
|
||||
enabled: indexingTechnique === IndexMethodEnum.QUALIFIED && !!embeddingModelProvider,
|
||||
refetchOnWindowFocus: false,
|
||||
select: response => response.data,
|
||||
}),
|
||||
)
|
||||
|
||||
const validationPayload = useMemo(() => {
|
||||
return {
|
||||
chunk_structure: chunkStructure,
|
||||
index_chunk_variable_selector: indexChunkVariableSelector,
|
||||
indexing_technique: indexingTechnique,
|
||||
embedding_model: embeddingModel,
|
||||
embedding_model_provider: embeddingModelProvider,
|
||||
retrieval_model: {
|
||||
search_method: retrievalSearchMethod,
|
||||
reranking_enable: retrievalRerankingEnable,
|
||||
reranking_model: retrievalModel?.reranking_model,
|
||||
},
|
||||
_embeddingModelList: embeddingModelList,
|
||||
_embeddingProviderModelList: embeddingProviderModelList,
|
||||
_rerankModelList: rerankModelList,
|
||||
}
|
||||
}, [
|
||||
chunkStructure,
|
||||
indexChunkVariableSelector,
|
||||
indexingTechnique,
|
||||
embeddingModel,
|
||||
embeddingModelProvider,
|
||||
retrievalSearchMethod,
|
||||
retrievalRerankingEnable,
|
||||
retrievalModel?.reranking_model,
|
||||
embeddingModelList,
|
||||
embeddingProviderModelList,
|
||||
rerankModelList,
|
||||
])
|
||||
|
||||
const validationIssue = useMemo(() => {
|
||||
return getKnowledgeBaseValidationIssue({
|
||||
...validationPayload,
|
||||
})
|
||||
}, [validationPayload])
|
||||
|
||||
const validationIssueMessage = useMemo(() => {
|
||||
return getKnowledgeBaseValidationMessage(validationIssue, t)
|
||||
}, [validationIssue, t])
|
||||
const { currentModel: currentEmbeddingModel, status: embeddingModelStatus } = useEmbeddingModelStatus({
|
||||
embeddingModel: data.embedding_model,
|
||||
embeddingModelProvider: data.embedding_model_provider,
|
||||
embeddingModelList,
|
||||
})
|
||||
|
||||
const chunksDisplayValue = useMemo(() => {
|
||||
if (!data.index_chunk_variable_selector?.length)
|
||||
return '-'
|
||||
|
||||
const chunkVar = data.index_chunk_variable_selector.at(-1)
|
||||
return chunkVar || '-'
|
||||
}, [data.index_chunk_variable_selector])
|
||||
|
||||
const embeddingModelDisplay = useMemo(() => {
|
||||
if (data.indexing_technique !== IndexMethodEnum.QUALIFIED)
|
||||
return '-'
|
||||
|
||||
if (embeddingModelStatus === 'empty')
|
||||
return t('detailPanel.configureModel', { ns: 'plugin' })
|
||||
|
||||
if (embeddingModelStatus !== 'active') {
|
||||
const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[embeddingModelStatus as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N]
|
||||
if (statusI18nKey)
|
||||
return t(statusI18nKey as 'modelProvider.selector.incompatible', { ns: 'common' })
|
||||
}
|
||||
|
||||
return currentEmbeddingModel?.label[language] || currentEmbeddingModel?.label.en_US || data.embedding_model || '-'
|
||||
}, [currentEmbeddingModel, data.embedding_model, data.indexing_technique, embeddingModelStatus, language, t])
|
||||
|
||||
const indexMethodDisplay = settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay] || '-'
|
||||
const retrievalMethodDisplay = settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay] || '-'
|
||||
|
||||
const chunksWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired
|
||||
const indexMethodWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.indexMethodRequired
|
||||
const embeddingWarning = data.indexing_technique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active'
|
||||
const showEmbeddingModelRow = data.indexing_technique === IndexMethodEnum.QUALIFIED
|
||||
const retrievalWarning = !!(validationIssue && RETRIEVAL_WARNING_CODES.has(validationIssue.code))
|
||||
|
||||
if (!data.chunk_structure) {
|
||||
return (
|
||||
<div className="mb-1 space-y-0.5 px-3 py-1">
|
||||
<div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5">
|
||||
<span className="mr-1 size-[4px] shrink-0 rounded-[2px] bg-text-warning-secondary" />
|
||||
<div className="grow truncate text-text-warning system-xs-medium" title={validationIssueMessage}>
|
||||
{validationIssueMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-1 space-y-0.5 px-3 py-1">
|
||||
<div className="flex h-6 items-center rounded-md bg-workflow-block-parma-bg px-1.5">
|
||||
<div className="system-xs-medium-uppercase mr-2 shrink-0 text-text-tertiary">
|
||||
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
<div
|
||||
className="system-xs-medium grow truncate text-right text-text-secondary"
|
||||
title={data.indexing_technique}
|
||||
>
|
||||
{settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay]}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-6 items-center rounded-md bg-workflow-block-parma-bg px-1.5">
|
||||
<div className="system-xs-medium-uppercase mr-2 shrink-0 text-text-tertiary">
|
||||
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div
|
||||
className="system-xs-medium grow truncate text-right text-text-secondary"
|
||||
title={data.retrieval_model?.search_method}
|
||||
>
|
||||
{settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay]}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('nodes.knowledgeBase.chunksInput', { ns: 'workflow' })}
|
||||
value={chunksWarning ? validationIssueMessage : chunksDisplayValue}
|
||||
warning={chunksWarning}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
value={indexMethodWarning ? validationIssueMessage : indexMethodDisplay}
|
||||
warning={indexMethodWarning}
|
||||
/>
|
||||
{showEmbeddingModelRow && (
|
||||
<SettingRow
|
||||
label={t('form.embeddingModel', { ns: 'datasetSettings' })}
|
||||
value={embeddingModelDisplay}
|
||||
warning={embeddingWarning}
|
||||
/>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t('form.retrievalSetting.method', { ns: 'datasetSettings' })}
|
||||
value={retrievalWarning ? validationIssueMessage : retrievalMethodDisplay}
|
||||
warning={retrievalWarning}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
198
web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx
Normal file
198
web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import Panel from './panel'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||
const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn())
|
||||
const mockChunkStructure = vi.hoisted(() => vi.fn(() => <div data-testid="chunk-structure" />))
|
||||
const mockEmbeddingModel = vi.hoisted(() => vi.fn(() => <div data-testid="embedding-model" />))
|
||||
const mockSummaryIndexSetting = vi.hoisted(() => vi.fn(() => <div data-testid="summary-index-setting" />))
|
||||
const mockQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: mockUseQuery,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
modelProviders: {
|
||||
models: {
|
||||
queryOptions: mockQueryOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: mockUseModelList,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false }),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-config', () => ({
|
||||
useConfig: () => ({
|
||||
handleChunkStructureChange: vi.fn(),
|
||||
handleIndexMethodChange: vi.fn(),
|
||||
handleKeywordNumberChange: vi.fn(),
|
||||
handleEmbeddingModelChange: vi.fn(),
|
||||
handleRetrievalSearchMethodChange: vi.fn(),
|
||||
handleHybridSearchModeChange: vi.fn(),
|
||||
handleRerankingModelEnabledChange: vi.fn(),
|
||||
handleWeighedScoreChange: vi.fn(),
|
||||
handleRerankingModelChange: vi.fn(),
|
||||
handleTopKChange: vi.fn(),
|
||||
handleScoreThresholdChange: vi.fn(),
|
||||
handleScoreThresholdEnabledChange: vi.fn(),
|
||||
handleInputVariableChange: vi.fn(),
|
||||
handleSummaryIndexSettingChange: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-embedding-model-status', () => ({
|
||||
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/settings/utils', () => ({
|
||||
checkShowMultiModalTip: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CE_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
|
||||
Group: ({ children }: { children: ReactNode }) => <div data-testid="group">{children}</div>,
|
||||
BoxGroup: ({ children }: { children: ReactNode }) => <div data-testid="box-group">{children}</div>,
|
||||
BoxGroupField: ({ children, fieldProps }: { children: ReactNode, fieldProps: { fieldTitleProps: { warningDot?: boolean } } }) => (
|
||||
<div data-testid="box-group-field" data-warning-dot={String(!!fieldProps.fieldTitleProps.warningDot)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: () => <div data-testid="var-reference-picker" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: () => <div data-testid="split" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
|
||||
default: mockSummaryIndexSetting,
|
||||
}))
|
||||
|
||||
vi.mock('./components/chunk-structure', () => ({
|
||||
default: mockChunkStructure,
|
||||
}))
|
||||
|
||||
vi.mock('./components/index-method', () => ({
|
||||
default: () => <div data-testid="index-method" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/embedding-model', () => ({
|
||||
default: mockEmbeddingModel,
|
||||
}))
|
||||
|
||||
vi.mock('./components/retrieval-setting', () => ({
|
||||
default: () => <div data-testid="retrieval-setting" />,
|
||||
}))
|
||||
|
||||
const createData = (overrides: Record<string, unknown> = {}) => ({
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 10,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps: PanelProps = {
|
||||
getInputVars: () => [],
|
||||
toVarInputs: () => [],
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: undefined,
|
||||
}
|
||||
|
||||
describe('KnowledgeBasePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseQuery.mockReturnValue({ data: undefined })
|
||||
mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => {
|
||||
if (modelType === ModelTypeEnum.textEmbedding) {
|
||||
return {
|
||||
data: [{
|
||||
provider: 'openai',
|
||||
models: [{ model: 'text-embedding-3-large' }],
|
||||
}],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
})
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({ status: 'active' })
|
||||
})
|
||||
|
||||
it('should show a warning dot on chunk structure and skip nested sections when chunk structure is missing', () => {
|
||||
render(<Panel id="knowledge-base-1" data={createData({ chunk_structure: undefined }) as never} panelProps={panelProps} />)
|
||||
|
||||
expect(mockChunkStructure).toHaveBeenCalledWith(expect.objectContaining({
|
||||
warningDot: true,
|
||||
}), undefined)
|
||||
expect(screen.queryByTestId('box-group-field')).not.toBeInTheDocument()
|
||||
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enabled: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should pass warning dots and render summary settings when the qualified configuration needs attention', () => {
|
||||
mockUseEmbeddingModelStatus.mockReturnValue({ status: 'disabled' })
|
||||
|
||||
render(<Panel id="knowledge-base-1" data={createData({ index_chunk_variable_selector: [] }) as never} panelProps={panelProps} />)
|
||||
|
||||
expect(screen.getByTestId('box-group-field')).toHaveAttribute('data-warning-dot', 'true')
|
||||
expect(mockEmbeddingModel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
warningDot: true,
|
||||
}), undefined)
|
||||
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: { params: { provider: 'openai' } },
|
||||
enabled: true,
|
||||
}))
|
||||
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide embedding and summary settings for non-qualified index methods', () => {
|
||||
render(
|
||||
<Panel
|
||||
id="knowledge-base-1"
|
||||
data={createData({ indexing_technique: IndexMethodEnum.ECONOMICAL }) as never}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('embedding-model')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
|
||||
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enabled: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { NodePanelProps, Var } from '@/app/components/workflow/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -19,16 +20,22 @@ import {
|
||||
} from '@/app/components/workflow/nodes/_base/components/layout'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import Split from '../_base/components/split'
|
||||
import ChunkStructure from './components/chunk-structure'
|
||||
import EmbeddingModel from './components/embedding-model'
|
||||
import IndexMethod from './components/index-method'
|
||||
import RetrievalSetting from './components/retrieval-setting'
|
||||
import { useConfig } from './hooks/use-config'
|
||||
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
} from './types'
|
||||
import {
|
||||
getKnowledgeBaseValidationIssue,
|
||||
KnowledgeBaseValidationIssueCode,
|
||||
} from './utils'
|
||||
|
||||
const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
id,
|
||||
@ -38,6 +45,22 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const chunkStructure = data.chunk_structure
|
||||
const indexChunkVariableSelector = data.index_chunk_variable_selector
|
||||
const indexingTechnique = data.indexing_technique
|
||||
const embeddingModel = data.embedding_model
|
||||
const retrievalModel = data.retrieval_model
|
||||
const retrievalSearchMethod = retrievalModel?.search_method
|
||||
const retrievalRerankingEnable = retrievalModel?.reranking_enable
|
||||
const embeddingModelProvider = data.embedding_model_provider
|
||||
const { data: embeddingProviderModelList } = useQuery(
|
||||
consoleQuery.modelProviders.models.queryOptions({
|
||||
input: { params: { provider: embeddingModelProvider || '' } },
|
||||
enabled: indexingTechnique === IndexMethodEnum.QUALIFIED && !!embeddingModelProvider,
|
||||
refetchOnWindowFocus: false,
|
||||
select: response => response.data,
|
||||
}),
|
||||
)
|
||||
|
||||
const {
|
||||
handleChunkStructureChange,
|
||||
@ -108,6 +131,49 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
})
|
||||
}, [data.embedding_model_provider, data.embedding_model, data.retrieval_model?.reranking_enable, data.retrieval_model?.reranking_model, data.indexing_technique, embeddingModelList, rerankModelList])
|
||||
|
||||
const validationPayload = useMemo(() => {
|
||||
return {
|
||||
chunk_structure: chunkStructure,
|
||||
index_chunk_variable_selector: indexChunkVariableSelector,
|
||||
indexing_technique: indexingTechnique,
|
||||
embedding_model: embeddingModel,
|
||||
embedding_model_provider: embeddingModelProvider,
|
||||
retrieval_model: {
|
||||
search_method: retrievalSearchMethod,
|
||||
reranking_enable: retrievalRerankingEnable,
|
||||
reranking_model: retrievalModel?.reranking_model,
|
||||
},
|
||||
_embeddingModelList: embeddingModelList,
|
||||
_embeddingProviderModelList: embeddingProviderModelList,
|
||||
_rerankModelList: rerankModelList,
|
||||
}
|
||||
}, [
|
||||
chunkStructure,
|
||||
indexChunkVariableSelector,
|
||||
indexingTechnique,
|
||||
embeddingModel,
|
||||
embeddingModelProvider,
|
||||
retrievalSearchMethod,
|
||||
retrievalRerankingEnable,
|
||||
retrievalModel?.reranking_model,
|
||||
embeddingModelList,
|
||||
embeddingProviderModelList,
|
||||
rerankModelList,
|
||||
])
|
||||
|
||||
const validationIssue = useMemo(() => {
|
||||
return getKnowledgeBaseValidationIssue(validationPayload)
|
||||
}, [validationPayload])
|
||||
const { status: embeddingModelStatus } = useEmbeddingModelStatus({
|
||||
embeddingModel,
|
||||
embeddingModelProvider,
|
||||
embeddingModelList,
|
||||
})
|
||||
|
||||
const chunkStructureWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunkStructureRequired
|
||||
const chunksInputWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired
|
||||
const embeddingModelWarning = indexingTechnique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Group
|
||||
@ -117,6 +183,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
<ChunkStructure
|
||||
chunkStructure={data.chunk_structure}
|
||||
onChunkStructureChange={handleChunkStructureChange}
|
||||
warningDot={chunkStructureWarning}
|
||||
readonly={nodesReadOnly}
|
||||
/>
|
||||
</Group>
|
||||
@ -131,6 +198,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
fieldTitleProps: {
|
||||
title: t('nodes.knowledgeBase.chunksInput', { ns: 'workflow' }),
|
||||
tooltip: t('nodes.knowledgeBase.chunksInputTip', { ns: 'workflow' }),
|
||||
warningDot: chunksInputWarning,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@ -163,6 +231,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
embeddingModel={data.embedding_model}
|
||||
embeddingModelProvider={data.embedding_model_provider}
|
||||
onEmbeddingModelChange={handleEmbeddingModelChange}
|
||||
warningDot={embeddingModelWarning}
|
||||
readonly={nodesReadOnly}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import type { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
@ -57,6 +57,7 @@ export type KnowledgeBaseNodeType = CommonNodeType & {
|
||||
keyword_number: number
|
||||
retrieval_model: RetrievalSetting
|
||||
_embeddingModelList?: Model[]
|
||||
_embeddingProviderModelList?: ModelItem[]
|
||||
_rerankModelList?: Model[]
|
||||
summary_index_setting?: SummaryIndexSetting
|
||||
}
|
||||
|
||||
226
web/app/components/workflow/nodes/knowledge-base/utils.spec.ts
Normal file
226
web/app/components/workflow/nodes/knowledge-base/utils.spec.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
import {
|
||||
getKnowledgeBaseValidationIssue,
|
||||
getKnowledgeBaseValidationMessage,
|
||||
isHighQualitySearchMethod,
|
||||
isKnowledgeBaseEmbeddingIssue,
|
||||
KnowledgeBaseValidationIssueCode,
|
||||
} from './utils'
|
||||
|
||||
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => {
|
||||
return [
|
||||
{
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [{
|
||||
model: 'gpt-4o',
|
||||
label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
}],
|
||||
status,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => {
|
||||
return [{
|
||||
model: 'gpt-4o',
|
||||
label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
}]
|
||||
}
|
||||
|
||||
const makePayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => {
|
||||
return {
|
||||
index_chunk_variable_selector: ['general_chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'gpt-4o',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 10,
|
||||
retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
},
|
||||
_embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active),
|
||||
_embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active),
|
||||
_rerankModelList: [],
|
||||
...overrides,
|
||||
} as KnowledgeBaseNodeType
|
||||
}
|
||||
|
||||
describe('knowledge-base validation issue', () => {
|
||||
it('identifies high quality retrieval methods', () => {
|
||||
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.semantic)).toBe(true)
|
||||
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.hybrid)).toBe(true)
|
||||
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.fullText)).toBe(true)
|
||||
expect(isHighQualitySearchMethod('unknown-method' as RetrievalSearchMethodEnum)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns chunk structure issue when chunk structure is missing', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(makePayload({ chunk_structure: undefined }))
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunkStructureRequired)
|
||||
})
|
||||
|
||||
it('returns chunks variable issue when chunks selector is empty', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(makePayload({ index_chunk_variable_selector: [] }))
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunksVariableRequired)
|
||||
})
|
||||
|
||||
it('maps no-configure to configure required', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noConfigure) }),
|
||||
)
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired)
|
||||
})
|
||||
|
||||
it('maps credential-removed to API key unavailable', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.credentialRemoved) }),
|
||||
)
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable)
|
||||
})
|
||||
|
||||
it('maps quota-exceeded to credits exhausted', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.quotaExceeded) }),
|
||||
)
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted)
|
||||
})
|
||||
|
||||
it('maps disabled to disabled', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.disabled) }),
|
||||
)
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelDisabled)
|
||||
})
|
||||
|
||||
it('maps missing provider plugin to incompatible when embedding model is already configured', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({
|
||||
embedding_model_provider: 'missing-provider',
|
||||
_embeddingProviderModelList: undefined,
|
||||
}),
|
||||
)
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
|
||||
})
|
||||
|
||||
it('falls back to provider model list when provider scoped model list is empty', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ _embeddingProviderModelList: [] }),
|
||||
)
|
||||
expect(issue).toBeNull()
|
||||
})
|
||||
|
||||
it('returns embedding-model-not-configured when the qualified index is missing provider details', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ embedding_model: undefined }),
|
||||
)
|
||||
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
|
||||
})
|
||||
|
||||
it('maps no-permission embedding models to incompatible', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noPermission) }),
|
||||
)
|
||||
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
|
||||
})
|
||||
|
||||
it('returns retrieval-setting-required when retrieval search method is missing', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({ retrieval_model: undefined as never }),
|
||||
)
|
||||
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.retrievalSettingRequired)
|
||||
})
|
||||
|
||||
it('returns reranking-model-required when reranking is enabled without a model', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({
|
||||
retrieval_model: {
|
||||
...makePayload().retrieval_model,
|
||||
reranking_enable: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelRequired)
|
||||
})
|
||||
|
||||
it('returns reranking-model-invalid when the configured reranking model is unavailable', () => {
|
||||
const issue = getKnowledgeBaseValidationIssue(
|
||||
makePayload({
|
||||
retrieval_model: {
|
||||
...makePayload().retrieval_model,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'missing-provider',
|
||||
reranking_model_name: 'missing-model',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelInvalid)
|
||||
})
|
||||
})
|
||||
|
||||
describe('knowledge-base validation messaging', () => {
|
||||
const t = (key: string) => key
|
||||
|
||||
it.each([
|
||||
[KnowledgeBaseValidationIssueCode.chunkStructureRequired, 'nodes.knowledgeBase.chunkIsRequired'],
|
||||
[KnowledgeBaseValidationIssueCode.chunksVariableRequired, 'nodes.knowledgeBase.chunksVariableIsRequired'],
|
||||
[KnowledgeBaseValidationIssueCode.indexMethodRequired, 'nodes.knowledgeBase.indexMethodIsRequired'],
|
||||
[KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, 'nodes.knowledgeBase.embeddingModelNotConfigured'],
|
||||
[KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired, 'modelProvider.selector.configureRequired'],
|
||||
[KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable, 'modelProvider.selector.apiKeyUnavailable'],
|
||||
[KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted, 'modelProvider.selector.creditsExhausted'],
|
||||
[KnowledgeBaseValidationIssueCode.embeddingModelDisabled, 'modelProvider.selector.disabled'],
|
||||
[KnowledgeBaseValidationIssueCode.embeddingModelIncompatible, 'modelProvider.selector.incompatible'],
|
||||
[KnowledgeBaseValidationIssueCode.retrievalSettingRequired, 'nodes.knowledgeBase.retrievalSettingIsRequired'],
|
||||
[KnowledgeBaseValidationIssueCode.rerankingModelRequired, 'nodes.knowledgeBase.rerankingModelIsRequired'],
|
||||
[KnowledgeBaseValidationIssueCode.rerankingModelInvalid, 'nodes.knowledgeBase.rerankingModelIsInvalid'],
|
||||
] as const)('maps %s to the expected translation key', (code, expectedKey) => {
|
||||
expect(getKnowledgeBaseValidationMessage({ code }, t as never)).toBe(expectedKey)
|
||||
})
|
||||
|
||||
it('returns an empty string when there is no issue', () => {
|
||||
expect(getKnowledgeBaseValidationMessage(undefined, t as never)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isKnowledgeBaseEmbeddingIssue', () => {
|
||||
it('returns true for embedding-related issues', () => {
|
||||
expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.embeddingModelDisabled })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-embedding issues and missing values', () => {
|
||||
expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.rerankingModelInvalid })).toBe(false)
|
||||
expect(isKnowledgeBaseEmbeddingIssue(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -1,3 +1,11 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import {
|
||||
IndexingType,
|
||||
} from '@/app/components/datasets/create/step-two'
|
||||
import {
|
||||
ModelStatusEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
@ -7,3 +15,174 @@ export const isHighQualitySearchMethod = (searchMethod: RetrievalSearchMethodEnu
|
||||
|| searchMethod === RetrievalSearchMethodEnum.hybrid
|
||||
|| searchMethod === RetrievalSearchMethodEnum.fullText
|
||||
}
|
||||
|
||||
export enum KnowledgeBaseValidationIssueCode {
|
||||
chunkStructureRequired = 'chunk-structure-required',
|
||||
chunksVariableRequired = 'chunks-variable-required',
|
||||
indexMethodRequired = 'index-method-required',
|
||||
embeddingModelNotConfigured = 'embedding-model-not-configured',
|
||||
embeddingModelConfigureRequired = 'embedding-model-configure-required',
|
||||
embeddingModelApiKeyUnavailable = 'embedding-model-api-key-unavailable',
|
||||
embeddingModelCreditsExhausted = 'embedding-model-credits-exhausted',
|
||||
embeddingModelDisabled = 'embedding-model-disabled',
|
||||
embeddingModelIncompatible = 'embedding-model-incompatible',
|
||||
retrievalSettingRequired = 'retrieval-setting-required',
|
||||
rerankingModelRequired = 'reranking-model-required',
|
||||
rerankingModelInvalid = 'reranking-model-invalid',
|
||||
}
|
||||
|
||||
type KnowledgeBaseValidationIssue = {
|
||||
code: KnowledgeBaseValidationIssueCode
|
||||
}
|
||||
|
||||
type KnowledgeBaseValidationPayload = Pick<KnowledgeBaseNodeType, 'chunk_structure' | 'index_chunk_variable_selector' | 'indexing_technique' | 'embedding_model' | 'embedding_model_provider' | '_embeddingModelList' | '_embeddingProviderModelList' | '_rerankModelList'> & {
|
||||
retrieval_model?: Pick<KnowledgeBaseNodeType['retrieval_model'], 'search_method' | 'reranking_enable' | 'reranking_model'>
|
||||
}
|
||||
|
||||
const EMBEDDING_ISSUE_CODES = new Set<KnowledgeBaseValidationIssueCode>([
|
||||
KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured,
|
||||
KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired,
|
||||
KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable,
|
||||
KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted,
|
||||
KnowledgeBaseValidationIssueCode.embeddingModelDisabled,
|
||||
KnowledgeBaseValidationIssueCode.embeddingModelIncompatible,
|
||||
])
|
||||
|
||||
const resolveIssue = (code: KnowledgeBaseValidationIssueCode): KnowledgeBaseValidationIssue => ({
|
||||
code,
|
||||
})
|
||||
|
||||
const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => {
|
||||
const {
|
||||
embedding_model,
|
||||
embedding_model_provider,
|
||||
_embeddingModelList,
|
||||
_embeddingProviderModelList,
|
||||
} = payload
|
||||
|
||||
if (!embedding_model || !embedding_model_provider)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
|
||||
|
||||
const currentEmbeddingModelProvider = _embeddingModelList?.find(provider => provider.provider === embedding_model_provider)
|
||||
const hasProviderScopedModelList = !!_embeddingProviderModelList && _embeddingProviderModelList.length > 0
|
||||
const embeddingModelCandidates = hasProviderScopedModelList
|
||||
? _embeddingProviderModelList
|
||||
: currentEmbeddingModelProvider?.models
|
||||
const currentEmbeddingModel = embeddingModelCandidates?.find(model => model.model === embedding_model)
|
||||
|
||||
if (!currentEmbeddingModel) {
|
||||
if (!currentEmbeddingModelProvider)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
|
||||
|
||||
const providerExists = hasProviderScopedModelList || currentEmbeddingModelProvider
|
||||
return resolveIssue(providerExists
|
||||
? KnowledgeBaseValidationIssueCode.embeddingModelIncompatible
|
||||
: KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
|
||||
}
|
||||
|
||||
switch (currentEmbeddingModel.status) {
|
||||
case ModelStatusEnum.active:
|
||||
return null
|
||||
case ModelStatusEnum.noConfigure:
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired)
|
||||
case ModelStatusEnum.credentialRemoved:
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable)
|
||||
case ModelStatusEnum.quotaExceeded:
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted)
|
||||
case ModelStatusEnum.disabled:
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelDisabled)
|
||||
case ModelStatusEnum.noPermission:
|
||||
default:
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
|
||||
}
|
||||
}
|
||||
|
||||
export const getKnowledgeBaseValidationIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => {
|
||||
const {
|
||||
chunk_structure,
|
||||
indexing_technique,
|
||||
retrieval_model,
|
||||
index_chunk_variable_selector,
|
||||
_rerankModelList,
|
||||
} = payload
|
||||
|
||||
const {
|
||||
search_method,
|
||||
reranking_enable,
|
||||
reranking_model,
|
||||
} = retrieval_model || {}
|
||||
|
||||
if (!chunk_structure)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.chunkStructureRequired)
|
||||
|
||||
if (index_chunk_variable_selector.length === 0)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.chunksVariableRequired)
|
||||
|
||||
if (!indexing_technique)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.indexMethodRequired)
|
||||
|
||||
if (indexing_technique === IndexingType.QUALIFIED) {
|
||||
const embeddingIssue = resolveEmbeddingIssue(payload)
|
||||
if (embeddingIssue)
|
||||
return embeddingIssue
|
||||
}
|
||||
|
||||
if (!retrieval_model || !search_method)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.retrievalSettingRequired)
|
||||
|
||||
if (reranking_enable) {
|
||||
if (!reranking_model || !reranking_model.reranking_provider_name || !reranking_model.reranking_model_name)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.rerankingModelRequired)
|
||||
|
||||
const currentRerankingModelProvider = _rerankModelList?.find(provider => provider.provider === reranking_model.reranking_provider_name)
|
||||
const currentRerankingModel = currentRerankingModelProvider?.models.find(model => model.model === reranking_model.reranking_model_name)
|
||||
if (!currentRerankingModel)
|
||||
return resolveIssue(KnowledgeBaseValidationIssueCode.rerankingModelInvalid)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const getKnowledgeBaseValidationMessage = (
|
||||
issue: KnowledgeBaseValidationIssue | null | undefined,
|
||||
t: TFunction,
|
||||
) => {
|
||||
if (!issue)
|
||||
return ''
|
||||
|
||||
switch (issue.code) {
|
||||
case KnowledgeBaseValidationIssueCode.chunkStructureRequired:
|
||||
return t('nodes.knowledgeBase.chunkIsRequired', { ns: 'workflow' })
|
||||
case KnowledgeBaseValidationIssueCode.chunksVariableRequired:
|
||||
return t('nodes.knowledgeBase.chunksVariableIsRequired', { ns: 'workflow' })
|
||||
case KnowledgeBaseValidationIssueCode.indexMethodRequired:
|
||||
return t('nodes.knowledgeBase.indexMethodIsRequired', { ns: 'workflow' })
|
||||
case KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured:
|
||||
return t('nodes.knowledgeBase.embeddingModelNotConfigured', { ns: 'workflow' })
|
||||
case KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired:
|
||||
return t('modelProvider.selector.configureRequired', { ns: 'common' })
|
||||
case KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable:
|
||||
return t('modelProvider.selector.apiKeyUnavailable', { ns: 'common' })
|
||||
case KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted:
|
||||
return t('modelProvider.selector.creditsExhausted', { ns: 'common' })
|
||||
case KnowledgeBaseValidationIssueCode.embeddingModelDisabled:
|
||||
return t('modelProvider.selector.disabled', { ns: 'common' })
|
||||
case KnowledgeBaseValidationIssueCode.embeddingModelIncompatible:
|
||||
return t('modelProvider.selector.incompatible', { ns: 'common' })
|
||||
case KnowledgeBaseValidationIssueCode.retrievalSettingRequired:
|
||||
return t('nodes.knowledgeBase.retrievalSettingIsRequired', { ns: 'workflow' })
|
||||
case KnowledgeBaseValidationIssueCode.rerankingModelRequired:
|
||||
return t('nodes.knowledgeBase.rerankingModelIsRequired', { ns: 'workflow' })
|
||||
case KnowledgeBaseValidationIssueCode.rerankingModelInvalid:
|
||||
return t('nodes.knowledgeBase.rerankingModelIsInvalid', { ns: 'workflow' })
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const isKnowledgeBaseEmbeddingIssue = (issue: KnowledgeBaseValidationIssue | null | undefined) => {
|
||||
if (!issue)
|
||||
return false
|
||||
|
||||
return EMBEDDING_ISSUE_CODES.has(issue.code)
|
||||
}
|
||||
|
||||
47
web/app/components/workflow/nodes/llm/default.spec.ts
Normal file
47
web/app/components/workflow/nodes/llm/default.spec.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { EditionType, PromptRole } from '../../types'
|
||||
import nodeDefault from './default'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({
|
||||
...nodeDefault.defaultValue,
|
||||
model: {
|
||||
...nodeDefault.defaultValue.model,
|
||||
provider: 'langgenius/openai/gpt-4.1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
},
|
||||
prompt_template: [{
|
||||
role: PromptRole.system,
|
||||
text: 'You are helpful.',
|
||||
edition_type: EditionType.basic,
|
||||
}],
|
||||
...overrides,
|
||||
}) as LLMNodeType
|
||||
|
||||
describe('llm default node validation', () => {
|
||||
it('should require a model provider before validating the prompt', () => {
|
||||
const result = nodeDefault.checkValid(createPayload({
|
||||
model: {
|
||||
...nodeDefault.defaultValue.model,
|
||||
provider: '',
|
||||
name: 'gpt-4.1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
}), t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
|
||||
})
|
||||
|
||||
it('should return a valid result when the provider and prompt are configured', () => {
|
||||
const result = nodeDefault.checkValid(createPayload(), t)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
})
|
||||
@ -4,6 +4,7 @@ import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
// import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum, EditionType, PromptRole } from '../../types'
|
||||
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
|
||||
|
||||
const RETRIEVAL_OUTPUT_STRUCT = `{
|
||||
"content": "",
|
||||
@ -60,7 +61,8 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
|
||||
},
|
||||
checkValid(payload: LLMNodeType, t: any) {
|
||||
let errorMessages = ''
|
||||
if (!errorMessages && !payload.model.provider)
|
||||
const modelIssue = getLLMModelIssue({ modelProvider: payload.model.provider })
|
||||
if (!errorMessages && modelIssue === LLMModelIssueCode.providerRequired)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { ns: 'workflow', field: t(`${i18nPrefix}.fields.model`, { ns: 'workflow' }) })
|
||||
|
||||
if (!errorMessages && !payload.memory) {
|
||||
|
||||
248
web/app/components/workflow/nodes/llm/panel.spec.tsx
Normal file
248
web/app/components/workflow/nodes/llm/panel.spec.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Panel from './panel'
|
||||
|
||||
const mockUseConfig = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-config', () => ({
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/config-prompt', () => ({
|
||||
default: () => <div data-testid="config-prompt" />,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/config-vision', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/memory-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/variable/var-reference-picker', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/reasoning-format-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/structure-output', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
VarItem: () => null,
|
||||
}))
|
||||
|
||||
type MockUseConfigReturn = ReturnType<typeof mockUseConfig>
|
||||
|
||||
const modelProviderSelector = vi.mocked(useProviderContextSelector)
|
||||
|
||||
const createProviderContextState = (modelProviders: ModelProvider[]): ProviderContextState => ({
|
||||
modelProviders,
|
||||
refreshModelProviders: vi.fn(),
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: true,
|
||||
enableBilling: false,
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
datasetOperatorEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
isEducationWorkspace: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
educationAccountExpireAt: null,
|
||||
isLoadingEducationAccountInfo: false,
|
||||
isFetchingEducationAccountInfo: false,
|
||||
webappCopyrightEnabled: false,
|
||||
licenseLimit: {
|
||||
workspace_members: {
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
refreshLicenseLimit: vi.fn(),
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
})
|
||||
|
||||
const createMockModelProvider = (provider: string): ModelProvider => ({
|
||||
provider,
|
||||
label: { en_US: provider, zh_Hans: provider },
|
||||
help: {
|
||||
title: { en_US: provider, zh_Hans: provider },
|
||||
url: { en_US: '', zh_Hans: '' },
|
||||
},
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: '', zh_Hans: '' },
|
||||
placeholder: { en_US: '', zh_Hans: '' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
|
||||
const baseNodeData: LLMNodeType = {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
model: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {},
|
||||
},
|
||||
prompt_template: [],
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const panelProps = {} as PanelProps
|
||||
|
||||
const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
|
||||
readOnly: false,
|
||||
inputs: baseNodeData,
|
||||
isChatModel: true,
|
||||
isChatMode: true,
|
||||
isCompletionModel: false,
|
||||
shouldShowContextTip: false,
|
||||
isVisionModel: false,
|
||||
handleModelChanged: vi.fn(),
|
||||
hasSetBlockStatus: false,
|
||||
handleCompletionParamsChange: vi.fn(),
|
||||
handleContextVarChange: vi.fn(),
|
||||
filterInputVar: vi.fn(),
|
||||
filterVar: vi.fn(),
|
||||
availableVars: [],
|
||||
availableNodesWithParent: [],
|
||||
isShowVars: false,
|
||||
handlePromptChange: vi.fn(),
|
||||
handleAddEmptyVariable: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleVarNameChange: vi.fn(),
|
||||
handleSyeQueryChange: vi.fn(),
|
||||
handleMemoryChange: vi.fn(),
|
||||
handleVisionResolutionEnabledChange: vi.fn(),
|
||||
handleVisionResolutionChange: vi.fn(),
|
||||
isModelSupportStructuredOutput: false,
|
||||
structuredOutputCollapsed: false,
|
||||
setStructuredOutputCollapsed: vi.fn(),
|
||||
handleStructureOutputEnableChange: vi.fn(),
|
||||
handleStructureOutputChange: vi.fn(),
|
||||
filterJinja2InputVar: vi.fn(),
|
||||
handleReasoningFormatChange: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderPanel = (data?: Partial<LLMNodeType>) => {
|
||||
return render(
|
||||
<Panel
|
||||
id="llm-node"
|
||||
data={{ ...baseNodeData, ...data }}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LLM Panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelProviderSelector.mockImplementation(selector => selector(
|
||||
createProviderContextState([createMockModelProvider('openai')]),
|
||||
))
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult())
|
||||
})
|
||||
|
||||
describe('Model Warning Dot', () => {
|
||||
it('should not show the model warning dot when the node only has a connection checklist issue', () => {
|
||||
renderPanel()
|
||||
|
||||
const modelField = screen.getByText('workflow.nodes.llm.model').parentElement
|
||||
expect(modelField?.querySelector('.bg-text-warning-secondary')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the model warning dot when the model is not configured', () => {
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult({
|
||||
inputs: {
|
||||
...baseNodeData,
|
||||
model: {
|
||||
...baseNodeData.model,
|
||||
provider: '',
|
||||
name: '',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
renderPanel({
|
||||
model: {
|
||||
...baseNodeData.model,
|
||||
provider: '',
|
||||
name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const modelField = screen.getByText('workflow.nodes.llm.model').parentElement
|
||||
expect(modelField?.querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -15,7 +15,9 @@ import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/compo
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
|
||||
import { extractPluginId } from '../../utils/plugin'
|
||||
import ConfigVision from '../_base/components/config-vision'
|
||||
import MemoryConfig from '../_base/components/memory-config'
|
||||
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
|
||||
@ -23,6 +25,7 @@ import ConfigPrompt from './components/config-prompt'
|
||||
import ReasoningFormatConfig from './components/reasoning-format-config'
|
||||
import StructureOutput from './components/structure-output'
|
||||
import useConfig from './use-config'
|
||||
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
|
||||
|
||||
const i18nPrefix = 'nodes.llm'
|
||||
|
||||
@ -67,6 +70,18 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
} = useConfig(id, data)
|
||||
|
||||
const model = inputs.model
|
||||
const isModelProviderInstalled = useProviderContextSelector((state) => {
|
||||
const modelIssue = getLLMModelIssue({ modelProvider: model?.provider })
|
||||
if (modelIssue === LLMModelIssueCode.providerRequired)
|
||||
return true
|
||||
|
||||
const modelProviderPluginId = extractPluginId(model.provider)
|
||||
return state.modelProviders.some(provider => extractPluginId(provider.provider) === modelProviderPluginId)
|
||||
})
|
||||
const hasModelWarning = getLLMModelIssue({
|
||||
modelProvider: model?.provider,
|
||||
isModelProviderInstalled,
|
||||
}) !== null
|
||||
|
||||
const handleModelChange = useCallback((model: {
|
||||
provider: string
|
||||
@ -102,6 +117,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.model`, { ns: 'workflow' })}
|
||||
required
|
||||
warningDot={hasModelWarning}
|
||||
>
|
||||
<ModelParameterModal
|
||||
popupClassName="!w-[387px]"
|
||||
@ -264,8 +280,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<div className="w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]">
|
||||
<div className="title-xs-semi-bold text-text-primary">{t('structOutput.modelNotSupported', { ns: 'app' })}</div>
|
||||
<div className="body-xs-regular mt-1 text-text-secondary">{t('structOutput.modelNotSupportedTip', { ns: 'app' })}</div>
|
||||
<div className="text-text-primary title-xs-semi-bold">{t('structOutput.modelNotSupported', { ns: 'app' })}</div>
|
||||
<div className="mt-1 text-text-secondary body-xs-regular">{t('structOutput.modelNotSupportedTip', { ns: 'app' })}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
@ -274,7 +290,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="system-xs-medium-uppercase mr-0.5 text-text-tertiary">{t('structOutput.structured', { ns: 'app' })}</div>
|
||||
<div className="mr-0.5 text-text-tertiary system-xs-medium-uppercase">{t('structOutput.structured', { ns: 'app' })}</div>
|
||||
<Tooltip popupContent={
|
||||
<div className="max-w-[150px]">{t('structOutput.structuredTip', { ns: 'app' })}</div>
|
||||
}
|
||||
|
||||
43
web/app/components/workflow/nodes/llm/utils.spec.ts
Normal file
43
web/app/components/workflow/nodes/llm/utils.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils'
|
||||
|
||||
describe('llm utils', () => {
|
||||
describe('getLLMModelIssue', () => {
|
||||
it('returns provider-required when the model provider is missing', () => {
|
||||
expect(getLLMModelIssue({ modelProvider: undefined })).toBe(LLMModelIssueCode.providerRequired)
|
||||
})
|
||||
|
||||
it('returns provider-plugin-unavailable when the provider plugin is not installed', () => {
|
||||
expect(getLLMModelIssue({
|
||||
modelProvider: 'langgenius/openai/gpt-4.1',
|
||||
isModelProviderInstalled: false,
|
||||
})).toBe(LLMModelIssueCode.providerPluginUnavailable)
|
||||
})
|
||||
|
||||
it('returns null when the provider is present and installed', () => {
|
||||
expect(getLLMModelIssue({
|
||||
modelProvider: 'langgenius/openai/gpt-4.1',
|
||||
isModelProviderInstalled: true,
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLLMModelProviderInstalled', () => {
|
||||
it('returns true when the model provider is missing', () => {
|
||||
expect(isLLMModelProviderInstalled(undefined, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('matches installed plugin ids using the provider plugin prefix', () => {
|
||||
expect(isLLMModelProviderInstalled(
|
||||
'langgenius/openai/gpt-4.1',
|
||||
new Set(['langgenius/openai']),
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the provider plugin id is not installed', () => {
|
||||
expect(isLLMModelProviderInstalled(
|
||||
'langgenius/openai/gpt-4.1',
|
||||
new Set(['langgenius/anthropic']),
|
||||
)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,12 +2,41 @@ import type { ValidationError } from 'jsonschema'
|
||||
import type { ArrayItems, Field, LLMNodeType } from './types'
|
||||
import * as z from 'zod'
|
||||
import { draft07Validator, forbidBooleanProperties } from '@/utils/validators'
|
||||
import { extractPluginId } from '../../utils/plugin'
|
||||
import { ArrayType, Type } from './types'
|
||||
|
||||
export const checkNodeValid = (_payload: LLMNodeType) => {
|
||||
return true
|
||||
}
|
||||
|
||||
export enum LLMModelIssueCode {
|
||||
providerRequired = 'provider-required',
|
||||
providerPluginUnavailable = 'provider-plugin-unavailable',
|
||||
}
|
||||
|
||||
export const getLLMModelIssue = ({
|
||||
modelProvider,
|
||||
isModelProviderInstalled = true,
|
||||
}: {
|
||||
modelProvider?: string
|
||||
isModelProviderInstalled?: boolean
|
||||
}) => {
|
||||
if (!modelProvider)
|
||||
return LLMModelIssueCode.providerRequired
|
||||
|
||||
if (!isModelProviderInstalled)
|
||||
return LLMModelIssueCode.providerPluginUnavailable
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const isLLMModelProviderInstalled = (modelProvider: string | undefined, installedPluginIds: ReadonlySet<string>) => {
|
||||
if (!modelProvider)
|
||||
return true
|
||||
|
||||
return installedPluginIds.has(extractPluginId(modelProvider))
|
||||
}
|
||||
|
||||
export const getFieldType = (field: Field) => {
|
||||
const { type, items, enum: enums } = field
|
||||
if (field.schemaType === 'file')
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { isToolAuthorizationRequired } from '../auth'
|
||||
|
||||
describe('isToolAuthorizationRequired', () => {
|
||||
it('should return true for built-in tools that require authorization and are not authorized', () => {
|
||||
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when the built-in tool is already authorized', () => {
|
||||
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
|
||||
allow_delete: true,
|
||||
is_team_authorization: true,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-built-in tools even if the provider is unauthorized', () => {
|
||||
expect(isToolAuthorizationRequired(CollectionType.custom, {
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when the collection is missing or authorization is not required', () => {
|
||||
expect(isToolAuthorizationRequired(CollectionType.builtIn)).toBe(false)
|
||||
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
|
||||
allow_delete: false,
|
||||
is_team_authorization: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,99 @@
|
||||
import type { ToolNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const mockUseNodePluginInstallation = vi.hoisted(() => vi.fn())
|
||||
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
|
||||
useNodePluginInstallation: mockUseNodePluginInstallation,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-current-tool-collection', () => ({
|
||||
__esModule: true,
|
||||
default: mockUseCurrentToolCollection,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
|
||||
InstallPluginButton: () => <button type="button">Install Plugin</button>,
|
||||
}))
|
||||
|
||||
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
|
||||
title: 'Google Search',
|
||||
desc: '',
|
||||
type: BlockEnum.Tool,
|
||||
provider_id: 'google_search',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'Google Search',
|
||||
tool_name: 'google_search',
|
||||
tool_label: 'Google Search',
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ToolNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodePluginInstallation.mockReturnValue({
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: vi.fn(),
|
||||
shouldDim: false,
|
||||
})
|
||||
mockUseCurrentToolCollection.mockReturnValue({
|
||||
currentTools: [],
|
||||
currCollection: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorization Warning', () => {
|
||||
it('should render the authorization warning when the tool requires authorization and is not authorized', () => {
|
||||
mockUseCurrentToolCollection.mockReturnValue({
|
||||
currentTools: [],
|
||||
currCollection: {
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Node id="tool-node-1" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.tool.authorizationRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep configuration rows visible when the authorization warning is shown', () => {
|
||||
mockUseCurrentToolCollection.mockReturnValue({
|
||||
currentTools: [],
|
||||
currCollection: {
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<Node
|
||||
id="tool-node-1"
|
||||
data={createNodeData({
|
||||
tool_configurations: {
|
||||
region: { value: 'us' },
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('region')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.tool.authorizationRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when there are no configs, no install action and no authorization warning', () => {
|
||||
const { container } = render(<Node id="tool-node-1" data={createNodeData()} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
309
web/app/components/workflow/nodes/tool/__tests__/panel.spec.tsx
Normal file
309
web/app/components/workflow/nodes/tool/__tests__/panel.spec.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolNodeType } from '../types'
|
||||
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockUseStore = vi.hoisted(() => vi.fn())
|
||||
const mockUseMatchSchemaType = vi.hoisted(() => vi.fn())
|
||||
const mockGetMatchedSchemaType = vi.hoisted(() => vi.fn())
|
||||
const mockWrapStructuredVarItem = vi.hoisted(() => vi.fn())
|
||||
const mockToolForm = vi.hoisted(() => vi.fn())
|
||||
const mockStructureOutputItem = vi.hoisted(() => vi.fn())
|
||||
const mockSplit = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../hooks/use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => mockUseStore(selector),
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/variable/use-match-schema-type', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseMatchSchemaType(...args),
|
||||
getMatchedSchemaType: (...args: unknown[]) => mockGetMatchedSchemaType(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/tool', () => ({
|
||||
wrapStructuredVarItem: (...args: unknown[]) => mockWrapStructuredVarItem(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
title?: string
|
||||
children: ReactNode
|
||||
collapsed?: boolean
|
||||
onCollapse?: (collapsed: boolean) => void
|
||||
}) => (
|
||||
<div data-testid="output-vars">
|
||||
<div>{props.title ?? 'workflow.nodes.common.outputVars'}</div>
|
||||
{props.onCollapse && (
|
||||
<button type="button" onClick={() => props.onCollapse?.(!props.collapsed)}>
|
||||
toggle-output-vars
|
||||
</button>
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
),
|
||||
VarItem: (props: {
|
||||
name: string
|
||||
type: string
|
||||
description: string
|
||||
}) => (
|
||||
<div data-testid={`var-item-${props.name}`}>
|
||||
<span>{props.name}</span>
|
||||
<span>{props.type}</span>
|
||||
<span>{props.description}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/tool-form', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
schema: CredentialFormSchema[]
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
}) => {
|
||||
mockToolForm(props)
|
||||
return (
|
||||
<div data-testid={`tool-form-${props.schema.map(item => item.variable).join('-') || 'empty'}`}>
|
||||
{props.showManageInputField && props.onManageInputField && (
|
||||
<button type="button" onClick={props.onManageInputField}>
|
||||
Manage Input Field
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { payload: { id: string } }) => {
|
||||
mockStructureOutputItem(props)
|
||||
return <div data-testid="structured-output">{props.payload.id}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/split', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { className?: string }) => {
|
||||
mockSplit(props)
|
||||
return <div data-testid="split">{props.className ?? 'default'}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const mockWorkflowStoreState = {
|
||||
pipelineId: undefined as string | undefined,
|
||||
setShowInputFieldPanel: vi.fn(),
|
||||
}
|
||||
|
||||
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
|
||||
title: 'Google Search',
|
||||
desc: '',
|
||||
type: BlockEnum.Tool,
|
||||
provider_id: 'google_search',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'Google Search',
|
||||
tool_name: 'google_search',
|
||||
tool_label: 'Google Search',
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createSchemaItem = (variable: string): CredentialFormSchema => ({
|
||||
name: variable,
|
||||
variable,
|
||||
label: { en_US: variable, zh_Hans: variable },
|
||||
type: FormTypeEnum.textInput,
|
||||
required: false,
|
||||
show_on: [],
|
||||
})
|
||||
|
||||
const renderPanel = (data: ToolNodeType = createNodeData()) => {
|
||||
const props: NodePanelProps<ToolNodeType> = {
|
||||
id: 'tool-node-1',
|
||||
data,
|
||||
panelProps: {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
},
|
||||
}
|
||||
|
||||
return render(<Panel {...props} />)
|
||||
}
|
||||
|
||||
const createConfigResult = (overrides: Record<string, unknown> = {}) => ({
|
||||
readOnly: false,
|
||||
inputs: {
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
},
|
||||
toolInputVarSchema: [] as CredentialFormSchema[],
|
||||
setInputVar: vi.fn(),
|
||||
toolSettingSchema: [] as CredentialFormSchema[],
|
||||
toolSettingValue: {},
|
||||
setToolSettingValue: vi.fn(),
|
||||
currCollection: { name: 'google_search' },
|
||||
isShowAuthBtn: false,
|
||||
isLoading: false,
|
||||
outputSchema: [],
|
||||
hasObjectOutput: false,
|
||||
currTool: { name: 'google_search' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ToolPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStoreState.pipelineId = undefined
|
||||
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
|
||||
mockUseStore.mockImplementation(selector => selector(mockWorkflowStoreState))
|
||||
mockUseMatchSchemaType.mockReturnValue({
|
||||
schemaTypeDefinitions: [{ name: 'structured' }],
|
||||
})
|
||||
mockGetMatchedSchemaType.mockReturnValue('')
|
||||
mockWrapStructuredVarItem.mockImplementation((outputItem, schemaType) => ({
|
||||
id: `${outputItem.name}-${schemaType || 'plain'}`,
|
||||
}))
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should render loading when config data is still loading', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
isLoading: true,
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.tool.inputVars')).not.toBeInTheDocument()
|
||||
expect(mockToolForm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Rendering', () => {
|
||||
it('should render input and settings forms and forward the manage input field action', () => {
|
||||
mockWorkflowStoreState.pipelineId = 'pipeline-1'
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
inputs: {
|
||||
tool_parameters: { query: { value: 'weather' } },
|
||||
tool_configurations: {},
|
||||
},
|
||||
toolInputVarSchema: [createSchemaItem('query')],
|
||||
toolSettingSchema: [createSchemaItem('region')],
|
||||
toolSettingValue: { region: 'us' },
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('workflow.nodes.tool.inputVars')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.tool.settings')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('split')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manage Input Field' }))
|
||||
|
||||
expect(mockToolForm).toHaveBeenCalledTimes(2)
|
||||
expect(mockToolForm.mock.calls[0][0]).toEqual(expect.objectContaining({
|
||||
nodeId: 'tool-node-1',
|
||||
showManageInputField: true,
|
||||
}))
|
||||
expect(mockToolForm.mock.calls[1][0]).toEqual(expect.objectContaining({
|
||||
nodeId: 'tool-node-1',
|
||||
}))
|
||||
expect(mockToolForm.mock.calls[1][0]).not.toHaveProperty('showManageInputField')
|
||||
expect(mockWorkflowStoreState.setShowInputFieldPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should hide editable forms when the auth button is shown but keep output variables visible', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
isShowAuthBtn: true,
|
||||
toolInputVarSchema: [createSchemaItem('query')],
|
||||
toolSettingSchema: [createSchemaItem('region')],
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.tool.inputVars')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.tool.settings')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('text')).toBeInTheDocument()
|
||||
expect(screen.getByText('files')).toBeInTheDocument()
|
||||
expect(screen.getByText('json')).toBeInTheDocument()
|
||||
expect(mockToolForm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Output Schema', () => {
|
||||
it('should render scalar and structured outputs with matched schema types', () => {
|
||||
mockGetMatchedSchemaType.mockImplementation((value: { type?: string }) => {
|
||||
return value?.type === 'string' ? 'qa_structured' : 'object_structured'
|
||||
})
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
hasObjectOutput: true,
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'summary',
|
||||
type: 'String',
|
||||
description: 'Summary field',
|
||||
value: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
type: 'Object',
|
||||
description: 'Details field',
|
||||
value: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('summary')).toBeInTheDocument()
|
||||
expect(screen.getByText('string (qa_structured)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Summary field')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('structured-output')).toHaveTextContent('details-object_structured')
|
||||
expect(mockWrapStructuredVarItem).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'details',
|
||||
}), 'object_structured')
|
||||
expect(mockStructureOutputItem).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: { id: 'details-object_structured' },
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render scalar outputs without a schema suffix when no schema type matches', () => {
|
||||
mockGetMatchedSchemaType.mockReturnValue('')
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'summary',
|
||||
type: 'String',
|
||||
description: 'Summary field',
|
||||
value: { type: 'string' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('var-item-summary')).toHaveTextContent('summary')
|
||||
expect(screen.getByTestId('var-item-summary')).toHaveTextContent('String'.toLowerCase())
|
||||
expect(screen.getByTestId('var-item-summary')).not.toHaveTextContent('qa_structured')
|
||||
})
|
||||
})
|
||||
})
|
||||
14
web/app/components/workflow/nodes/tool/auth.ts
Normal file
14
web/app/components/workflow/nodes/tool/auth.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import type { ToolNodeType } from './types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
type ToolAuthorizationCollection = Pick<ToolWithProvider, 'allow_delete' | 'is_team_authorization'>
|
||||
|
||||
export const isToolAuthorizationRequired = (
|
||||
providerType: ToolNodeType['provider_type'],
|
||||
collection?: ToolAuthorizationCollection,
|
||||
) => {
|
||||
return providerType === CollectionType.builtIn
|
||||
&& !!collection?.allow_delete
|
||||
&& collection?.is_team_authorization === false
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
import type { ToolNodeType } from '../../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { VarType } from '../../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockSetInputs = vi.hoisted(() => vi.fn())
|
||||
const mockSetControlPromptEditorRerenderKey = vi.hoisted(() => vi.fn())
|
||||
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
|
||||
const mockGetConfiguredValue = vi.hoisted(() => vi.fn())
|
||||
const mockToolParametersToFormSchemas = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (_id: string, data: ToolNodeType) => ({
|
||||
inputs: data,
|
||||
setInputs: mockSetInputs,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
getConfiguredValue: (...args: unknown[]) => mockGetConfiguredValue(...args),
|
||||
toolParametersToFormSchemas: (...args: unknown[]) => mockToolParametersToFormSchemas(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
updateBuiltInToolCredential: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidToolsByType: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-current-tool-collection', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseCurrentToolCollection(...args),
|
||||
}))
|
||||
|
||||
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
|
||||
title: 'Google Search',
|
||||
desc: '',
|
||||
type: BlockEnum.Tool,
|
||||
provider_id: 'google_search',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'Google Search',
|
||||
tool_name: 'google_search',
|
||||
tool_label: 'Google Search',
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const currentTool = {
|
||||
name: 'google_search',
|
||||
parameters: [
|
||||
{
|
||||
variable: 'query',
|
||||
form: 'llm',
|
||||
label: { en_US: 'Query' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'default query',
|
||||
},
|
||||
{
|
||||
variable: 'api_key',
|
||||
form: 'credential',
|
||||
label: { en_US: 'API Key' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'default secret',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const currentToolWithoutDefaults = {
|
||||
name: 'google_search',
|
||||
parameters: [
|
||||
{
|
||||
variable: 'query',
|
||||
form: 'llm',
|
||||
label: { en_US: 'Query' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'api_key',
|
||||
form: 'credential',
|
||||
label: { en_US: 'API Key' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const createToolVarInput = (value: string) => ({
|
||||
type: VarType.mixed,
|
||||
value,
|
||||
})
|
||||
|
||||
describe('useConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockUseCurrentToolCollection.mockReturnValue({
|
||||
currCollection: {
|
||||
name: 'google_search',
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
tools: [currentTool],
|
||||
},
|
||||
})
|
||||
|
||||
mockToolParametersToFormSchemas.mockImplementation(parameters => parameters)
|
||||
mockGetConfiguredValue.mockImplementation((_value, schema: Array<{ variable: string, default?: string }>) => {
|
||||
return schema.reduce<Record<string, ReturnType<typeof createToolVarInput>>>((acc, item) => {
|
||||
acc[item.variable] = createToolVarInput(item.default || '')
|
||||
return acc
|
||||
}, {} as Record<string, ReturnType<typeof createToolVarInput>>)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Default Value Sync', () => {
|
||||
it('should apply default values only once when the current payload is initially empty', () => {
|
||||
const emptyPayload = createNodeData()
|
||||
const syncedPayload = createNodeData({
|
||||
tool_parameters: { query: createToolVarInput('default query') },
|
||||
tool_configurations: { api_key: createToolVarInput('default secret') },
|
||||
})
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ payload }) => useConfig('tool-node-1', payload),
|
||||
{ initialProps: { payload: emptyPayload } },
|
||||
)
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tool_parameters: { query: createToolVarInput('default query') },
|
||||
tool_configurations: { api_key: createToolVarInput('default secret') },
|
||||
}))
|
||||
|
||||
rerender({ payload: syncedPayload })
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not update inputs when tool values are already populated on first render', () => {
|
||||
renderHook(() => useConfig('tool-node-1', createNodeData({
|
||||
tool_parameters: { query: createToolVarInput('existing query') },
|
||||
tool_configurations: { api_key: createToolVarInput('existing secret') },
|
||||
})))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update inputs when empty schemas do not provide any default values', () => {
|
||||
mockUseCurrentToolCollection.mockReturnValue({
|
||||
currCollection: {
|
||||
name: 'google_search',
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
tools: [currentToolWithoutDefaults],
|
||||
},
|
||||
})
|
||||
mockGetConfiguredValue.mockReturnValue({})
|
||||
|
||||
renderHook(() => useConfig('tool-node-1', createNodeData()))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,84 @@
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import useCurrentToolCollection from '../use-current-tool-collection'
|
||||
|
||||
const mockUseAllBuiltInTools = vi.hoisted(() => vi.fn())
|
||||
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
|
||||
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
|
||||
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
|
||||
const mockCanFindTool = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => mockUseAllBuiltInTools(),
|
||||
useAllCustomTools: () => mockUseAllCustomTools(),
|
||||
useAllWorkflowTools: () => mockUseAllWorkflowTools(),
|
||||
useAllMCPTools: () => mockUseAllMCPTools(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
canFindTool: (...args: unknown[]) => mockCanFindTool(...args),
|
||||
}))
|
||||
|
||||
const createToolCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
|
||||
id: 'builtin-search',
|
||||
name: 'Google Search',
|
||||
type: CollectionType.builtIn,
|
||||
label: { en_US: 'Google Search' },
|
||||
description: { en_US: 'Search provider' },
|
||||
icon: '',
|
||||
icon_dark: '',
|
||||
background: '',
|
||||
tags: [],
|
||||
tools: [],
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
meta: {} as ToolWithProvider['meta'],
|
||||
...overrides,
|
||||
}) as ToolWithProvider
|
||||
|
||||
describe('useCurrentToolCollection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanFindTool.mockImplementation((collectionId: string, providerId: string) => collectionId === providerId)
|
||||
mockUseAllBuiltInTools.mockReturnValue({ data: [] })
|
||||
mockUseAllCustomTools.mockReturnValue({ data: [] })
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: [] })
|
||||
mockUseAllMCPTools.mockReturnValue({ data: [] })
|
||||
})
|
||||
|
||||
it('should return the built-in collection list and matched provider for built-in tools', () => {
|
||||
const builtInCollection = createToolCollection({ id: 'builtin-search' })
|
||||
mockUseAllBuiltInTools.mockReturnValue({ data: [builtInCollection] })
|
||||
|
||||
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'))
|
||||
|
||||
expect(result.current.currentTools).toEqual([builtInCollection])
|
||||
expect(result.current.currCollection).toBe(builtInCollection)
|
||||
expect(mockCanFindTool).toHaveBeenCalledWith('builtin-search', 'builtin-search')
|
||||
})
|
||||
|
||||
it('should select the custom tool collection when the provider type is custom', () => {
|
||||
const customCollection = createToolCollection({
|
||||
id: 'custom-search',
|
||||
type: CollectionType.custom,
|
||||
})
|
||||
mockUseAllCustomTools.mockReturnValue({ data: [customCollection] })
|
||||
|
||||
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.custom, 'custom-search'))
|
||||
|
||||
expect(result.current.currentTools).toEqual([customCollection])
|
||||
expect(result.current.currCollection).toBe(customCollection)
|
||||
})
|
||||
|
||||
it('should return undefined when no collection matches the provider id', () => {
|
||||
mockUseAllBuiltInTools.mockReturnValue({
|
||||
data: [createToolCollection({ id: 'another-tool' })],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'))
|
||||
|
||||
expect(result.current.currentTools).toHaveLength(1)
|
||||
expect(result.current.currCollection).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,49 @@
|
||||
import type { ToolNodeType } from '../../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import useGetDataForCheckMore from '../use-get-data-for-check-more'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
|
||||
title: 'Google Search',
|
||||
desc: '',
|
||||
type: BlockEnum.Tool,
|
||||
provider_id: 'google_search',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'Google Search',
|
||||
tool_name: 'google_search',
|
||||
tool_label: 'Google Search',
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useGetDataForCheckMore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should expose the config hook validator as getData', () => {
|
||||
const getMoreDataForCheckValid = vi.fn(() => ({ provider: 'google' }))
|
||||
const payload = createNodeData()
|
||||
mockUseConfig.mockReturnValue({
|
||||
getMoreDataForCheckValid,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGetDataForCheckMore({
|
||||
id: 'tool-node-1',
|
||||
payload,
|
||||
}))
|
||||
|
||||
expect(mockUseConfig).toHaveBeenCalledWith('tool-node-1', payload)
|
||||
expect(result.current.getData).toBe(getMoreDataForCheckValid)
|
||||
expect(result.current.getData()).toEqual({ provider: 'google' })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,206 @@
|
||||
import type { ToolNodeType, VarType } from '../../types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
const mockUseToolIcon = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
|
||||
const mockFormatToTracingNodeList = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useToolIcon: (...args: unknown[]) => mockUseToolIcon(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockFormatToTracingNodeList(...args),
|
||||
}))
|
||||
|
||||
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
|
||||
title: 'Google Search',
|
||||
desc: '',
|
||||
type: BlockEnum.Tool,
|
||||
provider_id: 'google_search',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'Google Search',
|
||||
tool_name: 'google_search',
|
||||
tool_label: 'Google Search',
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createInputVar = (variable: InputVar['variable']): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: typeof variable === 'string' ? variable : 'invalid',
|
||||
variable,
|
||||
required: false,
|
||||
})
|
||||
|
||||
const createRunResult = (): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'tool-node-1',
|
||||
node_type: BlockEnum.Tool,
|
||||
title: 'Google Search',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
elapsed_time: 1,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 1,
|
||||
})
|
||||
|
||||
describe('useSingleRunFormParams', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseToolIcon.mockReturnValue('tool-icon')
|
||||
mockFormatToTracingNodeList.mockReturnValue([{ id: 'formatted-node' }])
|
||||
mockUseNodeCrud.mockImplementation((_id: string, payload: ToolNodeType) => ({
|
||||
inputs: payload,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Variable Extraction', () => {
|
||||
it('should build form inputs from variable params and settings and expose dependent vars', () => {
|
||||
const payload = createNodeData({
|
||||
tool_parameters: {
|
||||
query: { type: 'variable' as VarType, value: ['start', 'query'] },
|
||||
legacy_query: { type: 'variable' as VarType, value: 'legacy.answer' },
|
||||
constant_query: { type: 'constant' as VarType, value: 'fixed' },
|
||||
},
|
||||
tool_configurations: {
|
||||
prompt: { type: 'mixed' as VarType, value: 'prefix {{#tool.result#}}' },
|
||||
api_key: { type: 'constant' as VarType, value: 'secret' },
|
||||
plainText: 'ignored',
|
||||
},
|
||||
})
|
||||
const getInputVars = vi.fn(() => [
|
||||
createInputVar('#start.query#'),
|
||||
createInputVar(undefined as unknown as string),
|
||||
createInputVar('#legacy.answer#'),
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'tool-node-1',
|
||||
payload,
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
getInputVars,
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: vi.fn(),
|
||||
runResult: null as unknown as NodeTracing,
|
||||
}))
|
||||
|
||||
expect(getInputVars).toHaveBeenCalledWith([
|
||||
'{{#start.query#}}',
|
||||
'{{#legacy.answer#}}',
|
||||
'prefix {{#tool.result#}}',
|
||||
])
|
||||
expect(result.current.forms).toHaveLength(1)
|
||||
expect(result.current.forms[0].inputs).toEqual([
|
||||
createInputVar('#start.query#'),
|
||||
createInputVar(undefined as unknown as string),
|
||||
createInputVar('#legacy.answer#'),
|
||||
])
|
||||
expect(result.current.forms[0].values).toEqual({})
|
||||
expect(result.current.toolIcon).toBe('tool-icon')
|
||||
expect(result.current.getDependentVars()).toEqual([
|
||||
['start', 'query'],
|
||||
['legacy', 'answer'],
|
||||
])
|
||||
expect(result.current.nodeInfo).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Updates', () => {
|
||||
it('should update form values and forward run input data on change', () => {
|
||||
const payload = createNodeData({
|
||||
tool_parameters: {
|
||||
nullable_constant: { type: 'constant' as VarType, value: null },
|
||||
query: { type: 'variable' as VarType, value: ['start', 'query'] },
|
||||
},
|
||||
})
|
||||
const getInputVars = vi.fn(() => [createInputVar('#start.query#')])
|
||||
const setRunInputData = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'tool-node-1',
|
||||
payload,
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
getInputVars,
|
||||
setRunInputData,
|
||||
toVarInputs: vi.fn(),
|
||||
runResult: null as unknown as NodeTracing,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.forms[0].onChange({
|
||||
query: 'weather',
|
||||
tool_parameters: {
|
||||
nullable_constant: 'temp-value',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({
|
||||
query: 'weather',
|
||||
tool_parameters: {
|
||||
nullable_constant: 'temp-value',
|
||||
},
|
||||
})
|
||||
expect(result.current.forms[0].values).toEqual({
|
||||
query: 'weather',
|
||||
tool_parameters: {
|
||||
nullable_constant: 'temp-value',
|
||||
},
|
||||
nullable_constant: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tracing Data', () => {
|
||||
it('should format the latest run result into node info when a run result exists', () => {
|
||||
const payload = createNodeData()
|
||||
const runResult = createRunResult()
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'tool-node-1',
|
||||
payload,
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
getInputVars: vi.fn(() => []),
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: vi.fn(),
|
||||
runResult,
|
||||
}))
|
||||
|
||||
expect(mockFormatToTracingNodeList).toHaveBeenCalledWith([runResult], expect.any(Function))
|
||||
expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ToolNodeType, ToolVarInputs } from './types'
|
||||
import type { ToolNodeType, ToolVarInputs } from '../types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { capitalize } from 'es-toolkit/string'
|
||||
@ -16,17 +16,14 @@ import {
|
||||
useNodesReadOnly,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { updateBuiltInToolCredential } from '@/service/tools'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidToolsByType,
|
||||
} from '@/service/use-tools'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { useWorkflowStore } from '../../store'
|
||||
import { normalizeJsonSchemaType } from './output-schema-utils'
|
||||
import { isToolAuthorizationRequired } from '../auth'
|
||||
import { normalizeJsonSchemaType } from '../output-schema-utils'
|
||||
import useCurrentToolCollection from './use-current-tool-collection'
|
||||
|
||||
const formatDisplayType = (output: Record<string, unknown>): string => {
|
||||
const normalizedType = normalizeJsonSchemaType(output) || 'Unknown'
|
||||
@ -55,33 +52,10 @@ const useConfig = (id: string, payload: ToolNodeType) => {
|
||||
tool_parameters,
|
||||
} = inputs
|
||||
const isBuiltIn = provider_type === CollectionType.builtIn
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
|
||||
const currentTools = useMemo(() => {
|
||||
switch (provider_type) {
|
||||
case CollectionType.builtIn:
|
||||
return buildInTools || []
|
||||
case CollectionType.custom:
|
||||
return customTools || []
|
||||
case CollectionType.workflow:
|
||||
return workflowTools || []
|
||||
case CollectionType.mcp:
|
||||
return mcpTools || []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}, [buildInTools, customTools, mcpTools, provider_type, workflowTools])
|
||||
const currCollection = useMemo(() => {
|
||||
return currentTools.find(item => canFindTool(item.id, provider_id))
|
||||
}, [currentTools, provider_id])
|
||||
const { currCollection } = useCurrentToolCollection(provider_type, provider_id)
|
||||
|
||||
// Auth
|
||||
const needAuth = !!currCollection?.allow_delete
|
||||
const isAuthed = !!currCollection?.is_team_authorization
|
||||
const isShowAuthBtn = isBuiltIn && needAuth && !isAuthed
|
||||
const isShowAuthBtn = isToolAuthorizationRequired(provider_type, currCollection)
|
||||
const [
|
||||
showSetAuth,
|
||||
{ setTrue: showSetAuthModal, setFalse: hideSetAuthModal },
|
||||
@ -104,7 +78,6 @@ const useConfig = (id: string, payload: ToolNodeType) => {
|
||||
hideSetAuthModal,
|
||||
t,
|
||||
invalidToolsByType,
|
||||
provider_type,
|
||||
],
|
||||
)
|
||||
|
||||
@ -172,38 +145,49 @@ const useConfig = (id: string, payload: ToolNodeType) => {
|
||||
[inputs, setInputs],
|
||||
)
|
||||
|
||||
const formattingParameters = () => {
|
||||
const formattingParameters = useCallback(() => {
|
||||
const inputsWithDefaultValue = produce(inputs, (draft) => {
|
||||
if (
|
||||
!draft.tool_configurations
|
||||
|| Object.keys(draft.tool_configurations).length === 0
|
||||
) {
|
||||
draft.tool_configurations = getConfiguredValue(
|
||||
const configuredToolSettings = getConfiguredValue(
|
||||
tool_configurations,
|
||||
toolSettingSchema,
|
||||
) as ToolVarInputs
|
||||
if (Object.keys(configuredToolSettings).length > 0)
|
||||
draft.tool_configurations = configuredToolSettings
|
||||
}
|
||||
if (
|
||||
!draft.tool_parameters
|
||||
|| Object.keys(draft.tool_parameters).length === 0
|
||||
) {
|
||||
draft.tool_parameters = getConfiguredValue(
|
||||
const configuredToolParameters = getConfiguredValue(
|
||||
tool_parameters,
|
||||
toolInputVarSchema,
|
||||
) as ToolVarInputs
|
||||
if (Object.keys(configuredToolParameters).length > 0)
|
||||
draft.tool_parameters = configuredToolParameters
|
||||
}
|
||||
})
|
||||
return inputsWithDefaultValue
|
||||
}
|
||||
}, [inputs, toolInputVarSchema, toolSettingSchema, tool_configurations, tool_parameters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currTool)
|
||||
return
|
||||
const inputsWithDefaultValue = formattingParameters()
|
||||
if (inputsWithDefaultValue === inputs)
|
||||
return
|
||||
|
||||
const { setControlPromptEditorRerenderKey } = workflowStore.getState()
|
||||
setInputs(inputsWithDefaultValue)
|
||||
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||
}, [currTool])
|
||||
const rerenderTimeout = setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||
|
||||
return () => {
|
||||
clearTimeout(rerenderTimeout)
|
||||
}
|
||||
}, [currTool, formattingParameters, inputs, setInputs, workflowStore])
|
||||
|
||||
// setting when call
|
||||
const setInputVar = useCallback(
|
||||
@ -0,0 +1,47 @@
|
||||
import type { ToolNodeType } from '../types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { canFindTool } from '@/utils'
|
||||
|
||||
const useCurrentToolCollection = (
|
||||
providerType: ToolNodeType['provider_type'],
|
||||
providerId: string,
|
||||
) => {
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
|
||||
const currentTools = useMemo<ToolWithProvider[]>(() => {
|
||||
switch (providerType) {
|
||||
case CollectionType.builtIn:
|
||||
return buildInTools || []
|
||||
case CollectionType.custom:
|
||||
return customTools || []
|
||||
case CollectionType.workflow:
|
||||
return workflowTools || []
|
||||
case CollectionType.mcp:
|
||||
return mcpTools || []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}, [buildInTools, customTools, mcpTools, providerType, workflowTools])
|
||||
|
||||
const currCollection = useMemo(() => {
|
||||
return currentTools.find(item => canFindTool(item.id, providerId))
|
||||
}, [currentTools, providerId])
|
||||
|
||||
return {
|
||||
currentTools,
|
||||
currCollection,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCurrentToolCollection
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ToolNodeType } from './types'
|
||||
import type { ToolNodeType } from '../types'
|
||||
import useConfig from './use-config'
|
||||
|
||||
type Params = {
|
||||
@ -1,15 +1,15 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ToolNodeType } from './types'
|
||||
import type { ToolNodeType } from '../types'
|
||||
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
|
||||
import type { InputVar, ValueSelector, Variable } from '@/app/components/workflow/types'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log'
|
||||
import { useToolIcon } from '../../hooks'
|
||||
import useNodeCrud from '../_base/hooks/use-node-crud'
|
||||
import { VarType } from './types'
|
||||
import { VarType } from '../types'
|
||||
|
||||
type Params = {
|
||||
id: string
|
||||
@ -50,9 +50,9 @@ const useSingleRunFormParams = ({
|
||||
|
||||
return p.value as string
|
||||
}))
|
||||
const [inputVarValues, doSetInputVarValues] = useState<Record<string, any>>({})
|
||||
const setInputVarValues = useCallback((value: Record<string, any>) => {
|
||||
doSetInputVarValues(value)
|
||||
const [inputVarValues, setInputVarValues] = useState<Record<string, any>>({})
|
||||
const handleInputVarValuesChange = useCallback((value: Record<string, any>) => {
|
||||
setInputVarValues(value)
|
||||
setRunInputData(value)
|
||||
}, [setRunInputData])
|
||||
|
||||
@ -74,10 +74,10 @@ const useSingleRunFormParams = ({
|
||||
const forms: FormProps[] = [{
|
||||
inputs: varInputs,
|
||||
values: inputVarValuesWithConstantValue(),
|
||||
onChange: setInputVarValues,
|
||||
onChange: handleInputVarValuesChange,
|
||||
}]
|
||||
return forms
|
||||
}, [inputVarValuesWithConstantValue, setInputVarValues, varInputs])
|
||||
}, [handleInputVarValuesChange, inputVarValuesWithConstantValue, varInputs])
|
||||
|
||||
const nodeInfo = useMemo(() => {
|
||||
if (!runResult)
|
||||
@ -2,16 +2,17 @@ import type { FC } from 'react'
|
||||
import type { ToolNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { isToolAuthorizationRequired } from './auth'
|
||||
import useCurrentToolCollection from './hooks/use-current-tool-collection'
|
||||
|
||||
const Node: FC<NodeProps<ToolNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { tool_configurations, paramSchemas } = data
|
||||
const toolConfigs = Object.keys(tool_configurations || {})
|
||||
const {
|
||||
@ -22,25 +23,13 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { currCollection } = useCurrentToolCollection(data.provider_type, data.provider_id)
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
const showAuthorizationWarning = isToolAuthorizationRequired(data.provider_type, currCollection)
|
||||
|
||||
const hasConfigs = toolConfigs.length > 0
|
||||
|
||||
if (!showInstallButton && !hasConfigs)
|
||||
if (!showInstallButton && !hasConfigs && !showAuthorizationWarning)
|
||||
return null
|
||||
|
||||
return (
|
||||
@ -60,10 +49,10 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasConfigs && (
|
||||
{(hasConfigs || showAuthorizationWarning) && (
|
||||
<div className="space-y-0.5" aria-disabled={shouldDim}>
|
||||
{toolConfigs.map((key, index) => (
|
||||
<div key={index} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary">
|
||||
{hasConfigs && toolConfigs.map(key => (
|
||||
<div key={key} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary">
|
||||
<div title={key} className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary">
|
||||
{key}
|
||||
</div>
|
||||
@ -84,6 +73,14 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showAuthorizationWarning && (
|
||||
<div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5">
|
||||
<span className="mr-1 size-[4px] shrink-0 rounded-[2px] bg-text-warning-secondary" />
|
||||
<div className="grow truncate text-text-warning system-xs-medium" title={t('nodes.tool.authorizationRequired', { ns: 'workflow' })}>
|
||||
{t('nodes.tool.authorizationRequired', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ import { wrapStructuredVarItem } from '@/app/components/workflow/utils/tool'
|
||||
import Split from '../_base/components/split'
|
||||
import useMatchSchemaType, { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type'
|
||||
import ToolForm from './components/tool-form'
|
||||
import useConfig from './use-config'
|
||||
import useConfig from './hooks/use-config'
|
||||
|
||||
const i18nPrefix = 'nodes.tool'
|
||||
|
||||
|
||||
@ -2,10 +2,9 @@ import type { FC } from 'react'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import useConfig from './use-config'
|
||||
@ -54,21 +53,7 @@ const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user