mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 00:48:04 +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:
@ -65,6 +65,7 @@ import type { Shape as HooksStoreShape } from '../hooks-store/store'
|
||||
import type { Shape } from '../store/workflow'
|
||||
import type { Edge, Node, WorkflowRunningData } from '../types'
|
||||
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
@ -154,6 +155,13 @@ function createWorkflowWrapper(
|
||||
const historyCtxValue = historyConfig
|
||||
? createTestHistoryStoreContext(historyConfig)
|
||||
: undefined
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => {
|
||||
let inner: React.ReactNode = children
|
||||
@ -175,9 +183,13 @@ function createWorkflowWrapper(
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
WorkflowContext.Provider,
|
||||
{ value: stores.store },
|
||||
inner,
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(
|
||||
WorkflowContext.Provider,
|
||||
{ value: stores.store },
|
||||
inner,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
VariableX,
|
||||
WebhookLine,
|
||||
} from '@/app/components/base/icons/src/vender/workflow'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { BlockEnum } from './types'
|
||||
|
||||
@ -82,6 +83,17 @@ const getIcon = (type: BlockEnum, className: string) => {
|
||||
|
||||
return <DefaultIcon className={className} />
|
||||
}
|
||||
|
||||
const normalizeToolIconUrl = (toolIcon: string) => {
|
||||
const protectedPluginIconPath = '/workspaces/current/plugin/icon'
|
||||
const pathIndex = toolIcon.indexOf(protectedPluginIconPath)
|
||||
|
||||
if (pathIndex < 0)
|
||||
return toolIcon
|
||||
|
||||
return `${API_PREFIX}${toolIcon.slice(pathIndex)}`
|
||||
}
|
||||
|
||||
const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
|
||||
[BlockEnum.Start]: 'bg-util-colors-blue-brand-blue-brand-500',
|
||||
[BlockEnum.LLM]: 'bg-util-colors-indigo-indigo-500',
|
||||
@ -119,6 +131,9 @@ const BlockIcon: FC<BlockIconProps> = ({
|
||||
}) => {
|
||||
const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin
|
||||
const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon
|
||||
const resolvedToolIcon = typeof toolIcon === 'string'
|
||||
? normalizeToolIconUrl(toolIcon)
|
||||
: toolIcon
|
||||
|
||||
return (
|
||||
<div className={
|
||||
@ -142,12 +157,12 @@ const BlockIcon: FC<BlockIconProps> = ({
|
||||
!showDefaultIcon && (
|
||||
<>
|
||||
{
|
||||
typeof toolIcon === 'string'
|
||||
typeof resolvedToolIcon === 'string'
|
||||
? (
|
||||
<div
|
||||
className="h-full w-full shrink-0 rounded-md bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${toolIcon})`,
|
||||
backgroundImage: `url(${resolvedToolIcon})`,
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
@ -156,8 +171,8 @@ const BlockIcon: FC<BlockIconProps> = ({
|
||||
<AppIcon
|
||||
className="!h-full !w-full shrink-0"
|
||||
size="tiny"
|
||||
icon={toolIcon?.content}
|
||||
background={toolIcon?.background}
|
||||
icon={resolvedToolIcon?.content}
|
||||
background={resolvedToolIcon?.background}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,201 +0,0 @@
|
||||
import type { ChecklistItem } from '../hooks/use-checklist'
|
||||
import type {
|
||||
BlockEnum,
|
||||
CommonEdgeType,
|
||||
} from '../types'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiListCheck3,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useEdges,
|
||||
} from 'reactflow'
|
||||
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import { IconR } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import {
|
||||
ChecklistSquare,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import BlockIcon from '../block-icon'
|
||||
import {
|
||||
useChecklist,
|
||||
useNodesInteractions,
|
||||
} from '../hooks'
|
||||
|
||||
type WorkflowChecklistProps = {
|
||||
disabled: boolean
|
||||
showGoTo?: boolean
|
||||
onItemClick?: (item: ChecklistItem) => void
|
||||
}
|
||||
const WorkflowChecklist = ({
|
||||
disabled,
|
||||
showGoTo = true,
|
||||
onItemClick,
|
||||
}: WorkflowChecklistProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const edges = useEdges<CommonEdgeType>()
|
||||
const nodes = useNodes()
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
const handleChecklistItemClick = (item: ChecklistItem) => {
|
||||
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
|
||||
if (!goToEnabled)
|
||||
return
|
||||
if (onItemClick)
|
||||
onItemClick(item)
|
||||
else
|
||||
handleNodeSelect(item.id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 12,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(v => !v)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('group flex h-full w-full cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
>
|
||||
<RiListCheck3
|
||||
className={cn('h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!!needWarningNodes.length && (
|
||||
<div className="absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-[#F79009] text-[11px] font-semibold text-white">
|
||||
{needWarningNodes.length}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[12]">
|
||||
<div
|
||||
className="w-[420px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
|
||||
style={{
|
||||
maxHeight: 'calc(2 / 3 * 100vh)',
|
||||
}}
|
||||
>
|
||||
<div className="text-md sticky top-0 z-[1] flex h-[44px] items-center bg-components-panel-bg pl-4 pr-3 pt-3 font-semibold text-text-primary">
|
||||
<div className="grow">
|
||||
{t('panel.checklist', { ns: 'workflow' })}
|
||||
{needWarningNodes.length ? `(${needWarningNodes.length})` : ''}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-2">
|
||||
{
|
||||
!!needWarningNodes.length && (
|
||||
<>
|
||||
<div className="px-4 pt-1 text-xs text-text-tertiary">{t('panel.checklistTip', { ns: 'workflow' })}</div>
|
||||
<div className="px-4 py-2">
|
||||
{
|
||||
needWarningNodes.map(node => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={cn(
|
||||
'group mb-2 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0',
|
||||
showGoTo && node.canNavigate && !node.disableGoTo ? 'cursor-pointer' : 'cursor-default opacity-80',
|
||||
)}
|
||||
onClick={() => handleChecklistItemClick(node)}
|
||||
>
|
||||
<div className="flex h-9 items-center p-2 text-xs font-medium text-text-secondary">
|
||||
<BlockIcon
|
||||
type={node.type as BlockEnum}
|
||||
className="mr-1.5"
|
||||
toolIcon={node.toolIcon}
|
||||
/>
|
||||
<span className="grow truncate">
|
||||
{node.title}
|
||||
</span>
|
||||
{
|
||||
(showGoTo && node.canNavigate && !node.disableGoTo) && (
|
||||
<div className="flex h-4 w-[60px] shrink-0 items-center justify-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<span className="whitespace-nowrap text-xs font-medium leading-4 text-primary-600">
|
||||
{t('panel.goTo', { ns: 'workflow' })}
|
||||
</span>
|
||||
<IconR className="h-3.5 w-3.5 text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-b-lg border-t-[0.5px] border-divider-regular',
|
||||
(node.unConnected || node.errorMessage) && 'bg-gradient-to-r from-components-badge-bg-orange-soft to-transparent',
|
||||
)}
|
||||
>
|
||||
{
|
||||
node.unConnected && (
|
||||
<div className="px-3 py-1 first:pt-1.5 last:pb-1.5">
|
||||
<div className="flex text-xs leading-4 text-text-tertiary">
|
||||
<Warning className="mr-2 mt-[2px] h-3 w-3 text-[#F79009]" />
|
||||
{t('common.needConnectTip', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
node.errorMessage && (
|
||||
<div className="px-3 py-1 first:pt-1.5 last:pb-1.5">
|
||||
<div className="flex text-xs leading-4 text-text-tertiary">
|
||||
<Warning className="mr-2 mt-[2px] h-3 w-3 text-[#F79009]" />
|
||||
{node.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!needWarningNodes.length && (
|
||||
<div className="mx-4 mb-3 rounded-lg bg-components-panel-bg py-4 text-center text-xs text-text-tertiary">
|
||||
<ChecklistSquare className="mx-auto mb-[5px] h-8 w-8 text-text-quaternary" />
|
||||
{t('panel.checklistResolved', { ns: 'workflow' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowChecklist)
|
||||
131
web/app/components/workflow/header/checklist/index.spec.tsx
Normal file
131
web/app/components/workflow/header/checklist/index.spec.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import WorkflowChecklist from './index'
|
||||
|
||||
let mockChecklistItems = [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Missing Plugin',
|
||||
errorMessages: [],
|
||||
canNavigate: false,
|
||||
isPluginMissing: true,
|
||||
},
|
||||
{
|
||||
id: 'node-1',
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Broken Node',
|
||||
errorMessages: ['Needs configuration'],
|
||||
canNavigate: true,
|
||||
isPluginMissing: false,
|
||||
},
|
||||
]
|
||||
|
||||
const mockHandleNodeSelect = vi.fn()
|
||||
|
||||
type PopoverProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: PopoverProps['onOpenChange']
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useEdges: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useChecklist: () => mockChecklistItems,
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/popover', () => ({
|
||||
Popover: ({ children, onOpenChange }: PopoverProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="popover">{children}</div>
|
||||
},
|
||||
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('./plugin-group', () => ({
|
||||
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./node-group', () => ({
|
||||
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
|
||||
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
|
||||
{item.title}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('WorkflowChecklist', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
mockChecklistItems = [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Missing Plugin',
|
||||
errorMessages: [],
|
||||
canNavigate: false,
|
||||
isPluginMissing: true,
|
||||
},
|
||||
{
|
||||
id: 'node-1',
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Broken Node',
|
||||
errorMessages: ['Needs configuration'],
|
||||
canNavigate: true,
|
||||
isPluginMissing: false,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => {
|
||||
render(<WorkflowChecklist disabled={false} />)
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin')
|
||||
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
|
||||
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
})
|
||||
|
||||
it('should use the custom item click handler when provided', () => {
|
||||
const onItemClick = vi.fn()
|
||||
render(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
|
||||
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the resolved state when there are no checklist warnings', () => {
|
||||
mockChecklistItems = []
|
||||
|
||||
render(<WorkflowChecklist disabled={false} />)
|
||||
|
||||
expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore popover open changes when the checklist is disabled', () => {
|
||||
render(<WorkflowChecklist disabled={true} />)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
|
||||
expect(screen.getByText('2').closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
151
web/app/components/workflow/header/checklist/index.tsx
Normal file
151
web/app/components/workflow/header/checklist/index.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type {
|
||||
CommonEdgeType,
|
||||
} from '../../types'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useEdges,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
useChecklist,
|
||||
useNodesInteractions,
|
||||
} from '../../hooks'
|
||||
import { ChecklistNodeGroup } from './node-group'
|
||||
import { ChecklistPluginGroup } from './plugin-group'
|
||||
|
||||
type WorkflowChecklistProps = {
|
||||
disabled: boolean
|
||||
showGoTo?: boolean
|
||||
onItemClick?: (item: ChecklistItem) => void
|
||||
}
|
||||
|
||||
const WorkflowChecklist = ({
|
||||
disabled,
|
||||
showGoTo = true,
|
||||
onItemClick,
|
||||
}: WorkflowChecklistProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const edges = useEdges<CommonEdgeType>()
|
||||
const nodes = useNodes()
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
const { pluginItems, nodeItems } = useMemo(() => {
|
||||
const plugins: ChecklistItem[] = []
|
||||
const regular: ChecklistItem[] = []
|
||||
for (const item of needWarningNodes) {
|
||||
if (item.isPluginMissing)
|
||||
plugins.push(item)
|
||||
else
|
||||
regular.push(item)
|
||||
}
|
||||
return { pluginItems: plugins, nodeItems: regular }
|
||||
}, [needWarningNodes])
|
||||
|
||||
const handleItemClick = (item: ChecklistItem) => {
|
||||
if (onItemClick)
|
||||
onItemClick(item)
|
||||
else
|
||||
handleNodeSelect(item.id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={newOpen => !disabled && setOpen(newOpen)}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md border-none bg-transparent p-0',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
disabled={disabled || undefined}
|
||||
>
|
||||
<span
|
||||
className={cn('group flex h-full w-full items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
>
|
||||
<span
|
||||
className={cn('i-ri-list-check-3 h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
|
||||
/>
|
||||
</span>
|
||||
{!!needWarningNodes.length && (
|
||||
<span className="absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-text-warning-secondary text-[11px] font-semibold text-white">
|
||||
{needWarningNodes.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={12}
|
||||
alignOffset={-30}
|
||||
popupClassName="w-[420px] rounded-2xl bg-background-default-subtle"
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(2 / 3 * 100vh)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 px-3 pb-1 pt-3.5">
|
||||
<div className="flex items-start px-1">
|
||||
<div className="min-w-0 grow pr-8">
|
||||
<h2 className="text-base font-semibold leading-6 text-text-primary">
|
||||
{t('panel.checklist', { ns: 'workflow' })}
|
||||
{needWarningNodes.length > 0 && `(${needWarningNodes.length})`}
|
||||
</h2>
|
||||
</div>
|
||||
<PopoverClose className="-mr-0.5 -mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg">
|
||||
<span className="i-ri-close-line size-4 text-text-tertiary" />
|
||||
</PopoverClose>
|
||||
</div>
|
||||
{needWarningNodes.length > 0 && (
|
||||
<p className="px-1 text-xs leading-4 text-text-tertiary">
|
||||
{t('panel.checklistDescription', { ns: 'workflow' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needWarningNodes.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1 px-4 pb-4 pt-1">
|
||||
{pluginItems.length > 0 && (
|
||||
<ChecklistPluginGroup items={pluginItems} />
|
||||
)}
|
||||
{nodeItems.map(item => (
|
||||
<ChecklistNodeGroup
|
||||
key={item.id}
|
||||
item={item}
|
||||
showGoTo={showGoTo}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="mx-4 mb-3 rounded-lg py-4 text-center text-xs text-text-tertiary">
|
||||
<span className="i-custom-vender-line-general-checklist-square mx-auto mb-[5px] block h-8 w-8 text-text-quaternary" />
|
||||
{t('panel.checklistResolved', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowChecklist)
|
||||
@ -0,0 +1,21 @@
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ItemIndicatorProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ItemIndicator = ({ className }: ItemIndicatorProps) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="24"
|
||||
viewBox="0 0 20 24"
|
||||
fill="none"
|
||||
className={cn('shrink-0', className)}
|
||||
>
|
||||
<path d="M9.5 0H10.5V24H9.5V0Z" fill="currentColor" className="text-divider-regular" />
|
||||
<circle cx="10" cy="12" r="3.25" fill="#F79009" stroke="white" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { ChecklistNodeGroup } from './node-group'
|
||||
|
||||
vi.mock('../../block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('./item-indicator', () => ({
|
||||
ItemIndicator: () => <div data-testid="item-indicator" />,
|
||||
}))
|
||||
|
||||
const createItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'node-1',
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Broken Node',
|
||||
errorMessages: ['Needs configuration'],
|
||||
canNavigate: true,
|
||||
disableGoTo: false,
|
||||
unConnected: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ChecklistNodeGroup', () => {
|
||||
it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ChecklistNodeGroup
|
||||
item={createItem({ unConnected: true }) as never}
|
||||
showGoTo={true}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Needs configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/goToFix/i)).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Needs configuration'))
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
|
||||
})
|
||||
|
||||
it('should not allow navigation when go-to is disabled', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ChecklistNodeGroup
|
||||
item={createItem({ disableGoTo: true }) as never}
|
||||
showGoTo={true}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Needs configuration'))
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
75
web/app/components/workflow/header/checklist/node-group.tsx
Normal file
75
web/app/components/workflow/header/checklist/node-group.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type { BlockEnum } from '../../types'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { ItemIndicator } from './item-indicator'
|
||||
|
||||
type ChecklistSubItem = {
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const ChecklistNodeGroup = memo(({
|
||||
item,
|
||||
showGoTo,
|
||||
onItemClick,
|
||||
}: {
|
||||
item: ChecklistItem
|
||||
showGoTo: boolean
|
||||
onItemClick: (item: ChecklistItem) => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
|
||||
|
||||
const subItems = useMemo(() => {
|
||||
const items: ChecklistSubItem[] = []
|
||||
for (let i = 0; i < item.errorMessages.length; i++)
|
||||
items.push({ key: `error-${i}`, message: item.errorMessages[i] })
|
||||
if (item.unConnected)
|
||||
items.push({ key: 'unconnected', message: t('common.needConnectTip', { ns: 'workflow' }) })
|
||||
return items
|
||||
}, [item.errorMessages, item.unConnected, t])
|
||||
|
||||
return (
|
||||
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<BlockIcon
|
||||
type={item.type as BlockEnum}
|
||||
size="sm"
|
||||
toolIcon={item.toolIcon}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{subItems.map(sub => (
|
||||
<div
|
||||
key={sub.key}
|
||||
className={cn(
|
||||
'group/item flex items-center gap-2 rounded-lg px-1',
|
||||
goToEnabled && 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => goToEnabled && onItemClick(item)}
|
||||
>
|
||||
<ItemIndicator />
|
||||
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
|
||||
{sub.message}
|
||||
</span>
|
||||
{goToEnabled && (
|
||||
<div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
|
||||
<span className="whitespace-nowrap text-xs font-medium leading-4 text-text-accent">
|
||||
{t('panel.goToFix', { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-line size-3.5 text-text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChecklistNodeGroup.displayName = 'ChecklistNodeGroup'
|
||||
@ -0,0 +1,96 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Popover, PopoverContent } from '@/app/components/base/ui/popover'
|
||||
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { ChecklistPluginGroup } from './plugin-group'
|
||||
|
||||
const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistItem => ({
|
||||
id: 'node-1',
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool Node',
|
||||
errorMessages: [],
|
||||
canNavigate: false,
|
||||
isPluginMissing: true,
|
||||
pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ChecklistPluginGroup', () => {
|
||||
const getInstallButton = () => {
|
||||
return screen.getByText('workflow.nodes.agent.pluginInstaller.install').closest('button') as HTMLButtonElement
|
||||
}
|
||||
|
||||
const renderInPopover = (items: ChecklistItem[]) => {
|
||||
return render(
|
||||
<Popover open>
|
||||
<PopoverContent>
|
||||
<ChecklistPluginGroup items={items} />
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
usePluginDependencyStore.setState({ dependencies: [] })
|
||||
})
|
||||
|
||||
it('should set marketplace dependencies when install button is clicked', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
createChecklistItem({ id: 'node-1', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
|
||||
createChecklistItem({ id: 'node-2', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
|
||||
createChecklistItem({ id: 'node-3', pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256' }),
|
||||
]
|
||||
|
||||
renderInPopover(items)
|
||||
|
||||
fireEvent.click(getInstallButton())
|
||||
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
|
||||
plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
|
||||
plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
|
||||
version: '2.0.0',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep install button disabled when no identifier is available', () => {
|
||||
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: undefined })])
|
||||
|
||||
const installButton = getInstallButton()
|
||||
expect(installButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(installButton)
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
|
||||
})
|
||||
|
||||
it('should omit the version when the marketplace identifier does not include one', () => {
|
||||
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })])
|
||||
|
||||
fireEvent.click(getInstallButton())
|
||||
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256',
|
||||
plugin_unique_identifier: 'langgenius/test-plugin@sha256',
|
||||
version: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,99 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type { BlockEnum } from '../../types'
|
||||
import type { Dependency } from '@/app/components/plugins/types'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { PopoverClose } from '@/app/components/base/ui/popover'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
|
||||
import { ItemIndicator } from './item-indicator'
|
||||
|
||||
function getVersionFromMarketplaceIdentifier(identifier: string): string | undefined {
|
||||
const withoutHash = identifier.split('@')[0]
|
||||
const [, version] = withoutHash.split(':')
|
||||
return version || undefined
|
||||
}
|
||||
|
||||
export const ChecklistPluginGroup = memo(({
|
||||
items,
|
||||
}: {
|
||||
items: ChecklistItem[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const identifiers = useMemo(
|
||||
() => Array.from(
|
||||
new Set(
|
||||
items
|
||||
.map(i => i.pluginUniqueIdentifier)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
),
|
||||
[items],
|
||||
)
|
||||
|
||||
const dependencies = useMemo<Dependency[]>(() => {
|
||||
return identifiers.map((identifier) => {
|
||||
return {
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: identifier,
|
||||
plugin_unique_identifier: identifier,
|
||||
version: getVersionFromMarketplaceIdentifier(identifier),
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [identifiers])
|
||||
|
||||
const handleInstallAll = () => {
|
||||
if (dependencies.length === 0)
|
||||
return
|
||||
const { setDependencies } = usePluginDependencyStore.getState()
|
||||
setDependencies(dependencies)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-icon-bg-midnight-solid shadow-xs">
|
||||
<span className="i-ri-download-line size-3.5 text-white" />
|
||||
</div>
|
||||
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
|
||||
{t('nodes.common.pluginsNotInstalled', { ns: 'workflow', count: items.length })}
|
||||
</span>
|
||||
<PopoverClose
|
||||
render={(
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleInstallAll}
|
||||
disabled={dependencies.length === 0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}
|
||||
</PopoverClose>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 rounded-lg px-1"
|
||||
>
|
||||
<ItemIndicator />
|
||||
<BlockIcon
|
||||
type={item.type as BlockEnum}
|
||||
size="xs"
|
||||
toolIcon={item.toolIcon}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChecklistPluginGroup.displayName = 'ChecklistPluginGroup'
|
||||
150
web/app/components/workflow/header/run-mode.spec.tsx
Normal file
150
web/app/components/workflow/header/run-mode.spec.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import RunMode from './run-mode'
|
||||
import { TriggerType } from './test-run-menu'
|
||||
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
let mockWarningNodes: Array<{ id: string }> = []
|
||||
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined
|
||||
let mockIsListening = false
|
||||
let mockDynamicOptions = [
|
||||
{ type: TriggerType.UserInput, nodeId: 'start-node' },
|
||||
]
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowStartRun: () => ({
|
||||
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow,
|
||||
}),
|
||||
useWorkflowRun: () => ({
|
||||
handleStopRun: mockHandleStopRun,
|
||||
}),
|
||||
useWorkflowRunValidation: () => ({
|
||||
warningNodes: mockWarningNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
|
||||
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
|
||||
useDynamicTestRunOptions: () => mockDynamicOptions,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
StopCircle: () => <span data-testid="stop-circle" />,
|
||||
}))
|
||||
|
||||
vi.mock('./test-run-menu', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./test-run-menu')>()
|
||||
return {
|
||||
...actual,
|
||||
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
toggle: vi.fn(),
|
||||
}))
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="trigger-option" onClick={() => onSelect(options[0])}>
|
||||
Trigger option
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('RunMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWarningNodes = []
|
||||
mockWorkflowRunningData = undefined
|
||||
mockIsListening = false
|
||||
mockDynamicOptions = [
|
||||
{ type: TriggerType.UserInput, nodeId: 'start-node' },
|
||||
]
|
||||
})
|
||||
|
||||
it('should render the run trigger and start the workflow when a valid trigger is selected', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText(/run/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('trigger-option'))
|
||||
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' })
|
||||
})
|
||||
|
||||
it('should show an error toast instead of running when the selected trigger has checklist warnings', () => {
|
||||
mockWarningNodes = [{ id: 'start-node' }]
|
||||
|
||||
render(<RunMode />)
|
||||
fireEvent.click(screen.getByTestId('trigger-option'))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.panel.checklistTip',
|
||||
})
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the running state and stop the workflow when it is already running', () => {
|
||||
mockWorkflowRunningData = {
|
||||
result: { status: WorkflowRunningStatus.Running },
|
||||
task_id: 'task-1',
|
||||
}
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText(/running/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement)
|
||||
|
||||
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
|
||||
})
|
||||
|
||||
it('should render the listening label when the workflow is listening', () => {
|
||||
mockIsListening = true
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText(/listening/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -114,7 +114,7 @@ const RunMode = ({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'system-xs-medium flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent',
|
||||
'flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent system-xs-medium',
|
||||
)}
|
||||
disabled={true}
|
||||
>
|
||||
@ -130,7 +130,7 @@ const RunMode = ({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-medium flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent hover:bg-state-accent-hover',
|
||||
'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent system-xs-medium hover:bg-state-accent-hover',
|
||||
)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import type { CommonNodeType, Node } from '../../types'
|
||||
import type { ChecklistItem } from '../use-checklist'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { createElement, Fragment } from 'react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { renderWorkflowComponent, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useStore } from '../../store'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useChecklist, useWorkflowRunValidation } from '../use-checklist'
|
||||
|
||||
@ -39,6 +43,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
|
||||
type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string }
|
||||
const mockNodesMap: Record<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {}
|
||||
let mockModelProviders: Array<{ provider: string }> = []
|
||||
let mockUsedVars: string[][] = []
|
||||
const mockAvailableVarMap: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
|
||||
|
||||
vi.mock('../use-nodes-meta-data', () => ({
|
||||
useNodesMetaData: () => ({
|
||||
@ -49,10 +56,10 @@ vi.mock('../use-nodes-meta-data', () => ({
|
||||
|
||||
vi.mock('../use-nodes-available-var-list', () => ({
|
||||
default: (nodes: Node[]) => {
|
||||
const map: Record<string, { availableVars: never[] }> = {}
|
||||
const map: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
|
||||
if (nodes) {
|
||||
for (const n of nodes)
|
||||
map[n.id] = { availableVars: [] }
|
||||
map[n.id] = mockAvailableVarMap[n.id] ?? { availableVars: [] }
|
||||
}
|
||||
return map
|
||||
},
|
||||
@ -60,7 +67,7 @@ vi.mock('../use-nodes-available-var-list', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../nodes/_base/components/variable/utils', () => ({
|
||||
getNodeUsedVars: () => [],
|
||||
getNodeUsedVars: () => mockUsedVars,
|
||||
isSpecialVar: () => false,
|
||||
}))
|
||||
|
||||
@ -90,6 +97,11 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: (selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown) =>
|
||||
selector({ modelProviders: mockModelProviders }),
|
||||
}))
|
||||
|
||||
// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -124,6 +136,9 @@ beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
resetFixtureCounters()
|
||||
Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k])
|
||||
Object.keys(mockAvailableVarMap).forEach(k => delete mockAvailableVarMap[k])
|
||||
mockModelProviders = []
|
||||
mockUsedVars = []
|
||||
setupNodesMap()
|
||||
})
|
||||
|
||||
@ -195,7 +210,7 @@ describe('useChecklist', () => {
|
||||
|
||||
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
|
||||
expect(warning).toBeDefined()
|
||||
expect(warning!.errorMessage).toBe('Model not configured')
|
||||
expect(warning!.errorMessages).toContain('Model not configured')
|
||||
})
|
||||
|
||||
it('should report missing start node in workflow mode', () => {
|
||||
@ -217,7 +232,9 @@ describe('useChecklist', () => {
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: 'My Tool',
|
||||
_pluginInstallLocked: true,
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'missing-provider',
|
||||
plugin_unique_identifier: 'plugin/tool@0.0.1',
|
||||
},
|
||||
})
|
||||
|
||||
@ -233,6 +250,9 @@ describe('useChecklist', () => {
|
||||
expect(warning).toBeDefined()
|
||||
expect(warning!.canNavigate).toBe(false)
|
||||
expect(warning!.disableGoTo).toBe(true)
|
||||
expect(warning!.isPluginMissing).toBe(true)
|
||||
expect(warning!.pluginUniqueIdentifier).toBe('plugin/tool@0.0.1')
|
||||
expect(warning!.errorMessages).toContain('workflow.nodes.common.pluginNotInstalled')
|
||||
})
|
||||
|
||||
it('should report required node types that are missing', () => {
|
||||
@ -279,6 +299,112 @@ describe('useChecklist', () => {
|
||||
const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien')
|
||||
expect(alienWarning).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should report configure model errors when an llm model provider plugin is missing', () => {
|
||||
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
|
||||
const llmNode = createNode({
|
||||
id: 'llm',
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'llm' }),
|
||||
]
|
||||
|
||||
const { result } = renderWorkflowHook(
|
||||
() => useChecklist([startNode, llmNode], edges),
|
||||
)
|
||||
|
||||
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
|
||||
expect(warning).toBeDefined()
|
||||
expect(warning!.errorMessages).toContain('workflow.errorMsg.configureModel')
|
||||
expect(warning!.canNavigate).toBe(true)
|
||||
})
|
||||
|
||||
it('should accumulate validation and invalid variable errors for the same node', () => {
|
||||
mockNodesMap[BlockEnum.LLM] = {
|
||||
checkValid: () => ({ errorMessage: 'Model not configured' }),
|
||||
metaData: { isStart: false, isRequired: false },
|
||||
}
|
||||
mockUsedVars = [['start', 'missingVar']]
|
||||
mockAvailableVarMap.llm = {
|
||||
availableVars: [
|
||||
{
|
||||
nodeId: 'start',
|
||||
vars: [{ variable: 'existingVar' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
|
||||
const llmNode = createNode({
|
||||
id: 'llm',
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
},
|
||||
})
|
||||
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'llm' }),
|
||||
]
|
||||
|
||||
const { result } = renderWorkflowHook(
|
||||
() => useChecklist([startNode, llmNode], edges),
|
||||
)
|
||||
|
||||
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
|
||||
expect(warning).toBeDefined()
|
||||
expect(warning!.errorMessages).toEqual([
|
||||
'Model not configured',
|
||||
'workflow.errorMsg.invalidVariable',
|
||||
])
|
||||
})
|
||||
|
||||
it('should sync checklist items to the workflow store without render phase update warnings', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
|
||||
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
|
||||
|
||||
function Operator() {
|
||||
const checklistItems = useStore(state => state.checklistItems)
|
||||
return createElement('div', { 'data-testid': 'checklist-count' }, checklistItems.length)
|
||||
}
|
||||
|
||||
function WorkflowChecklist() {
|
||||
useChecklist([startNode, codeNode], [])
|
||||
return null
|
||||
}
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(Operator),
|
||||
createElement(WorkflowChecklist),
|
||||
),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().checklistItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('checklist-count')).toHaveTextContent('1')
|
||||
expect(errorSpy.mock.calls.some(call =>
|
||||
call.some(arg => typeof arg === 'string' && arg.includes('Cannot update a component')),
|
||||
)).toBe(false)
|
||||
}
|
||||
finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -0,0 +1,253 @@
|
||||
import type { CommonNodeType } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useNodePluginInstallation } from '../use-node-plugin-installation'
|
||||
|
||||
const mockBuiltInTools = vi.fn()
|
||||
const mockCustomTools = vi.fn()
|
||||
const mockWorkflowTools = vi.fn()
|
||||
const mockMcpTools = vi.fn()
|
||||
const mockInvalidToolsByType = vi.fn()
|
||||
const mockTriggerPlugins = vi.fn()
|
||||
const mockInvalidateTriggers = vi.fn()
|
||||
const mockInvalidDataSourceList = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
|
||||
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),
|
||||
useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled),
|
||||
useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled),
|
||||
useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled),
|
||||
useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useInvalidDataSourceList: () => mockInvalidDataSourceList,
|
||||
}))
|
||||
|
||||
const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool node',
|
||||
desc: '',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'search',
|
||||
provider_name: 'search',
|
||||
plugin_id: 'plugin-search',
|
||||
plugin_unique_identifier: 'plugin-search@1.0.0',
|
||||
...overrides,
|
||||
}) as CommonNodeType
|
||||
|
||||
const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
title: 'Trigger node',
|
||||
desc: '',
|
||||
provider_id: 'trigger-provider',
|
||||
provider_name: 'trigger-provider',
|
||||
plugin_id: 'trigger-plugin',
|
||||
plugin_unique_identifier: 'trigger-plugin@1.0.0',
|
||||
...overrides,
|
||||
}) as CommonNodeType
|
||||
|
||||
const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Data source node',
|
||||
desc: '',
|
||||
provider_name: 'knowledge-provider',
|
||||
plugin_id: 'knowledge-plugin',
|
||||
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
|
||||
...overrides,
|
||||
}) as CommonNodeType
|
||||
|
||||
const matchedTool = {
|
||||
plugin_id: 'plugin-search',
|
||||
provider: 'search',
|
||||
name: 'search',
|
||||
plugin_unique_identifier: 'plugin-search@1.0.0',
|
||||
}
|
||||
|
||||
const matchedTriggerProvider = {
|
||||
id: 'trigger-provider',
|
||||
name: 'trigger-provider',
|
||||
plugin_id: 'trigger-plugin',
|
||||
}
|
||||
|
||||
const matchedDataSource = {
|
||||
provider: 'knowledge-provider',
|
||||
plugin_id: 'knowledge-plugin',
|
||||
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
|
||||
}
|
||||
|
||||
describe('useNodePluginInstallation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockCustomTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockMcpTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockInvalidToolsByType.mockReturnValue(undefined)
|
||||
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockInvalidateTriggers.mockReset()
|
||||
mockInvalidDataSourceList.mockReset()
|
||||
})
|
||||
|
||||
it('should return the noop installation state for non plugin-dependent nodes', () => {
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation({
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
} as CommonNodeType),
|
||||
)
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: expect.any(Function),
|
||||
shouldDim: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should report loading and invalidate built-in tools while the collection is resolving', () => {
|
||||
const invalidateTools = vi.fn()
|
||||
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true })
|
||||
mockInvalidToolsByType.mockReturnValue(invalidateTools)
|
||||
|
||||
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()))
|
||||
|
||||
expect(mockBuiltInTools).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isChecking).toBe(true)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0')
|
||||
expect(result.current.canInstall).toBe(true)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onInstallSuccess()
|
||||
})
|
||||
|
||||
expect(invalidateTools).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[CollectionType.custom, mockCustomTools],
|
||||
[CollectionType.workflow, mockWorkflowTools],
|
||||
[CollectionType.mcp, mockMcpTools],
|
||||
])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => {
|
||||
hookMock.mockReturnValue({ data: [matchedTool], isLoading: false })
|
||||
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeToolNode({ provider_type: providerType })),
|
||||
)
|
||||
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep unknown tool collection types installable without collection state', () => {
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeToolNode({
|
||||
provider_type: 'unknown' as CollectionType,
|
||||
plugin_unique_identifier: undefined,
|
||||
plugin_id: undefined,
|
||||
provider_id: 'legacy-provider',
|
||||
})),
|
||||
)
|
||||
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.uniqueIdentifier).toBe('legacy-provider')
|
||||
expect(result.current.canInstall).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(false)
|
||||
})
|
||||
|
||||
it('should flag missing trigger plugins and invalidate trigger data after installation', () => {
|
||||
mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false })
|
||||
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeTriggerNode({
|
||||
provider_id: 'missing-trigger',
|
||||
provider_name: 'missing-trigger',
|
||||
plugin_id: 'missing-trigger',
|
||||
})),
|
||||
)
|
||||
|
||||
expect(mockTriggerPlugins).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isMissing).toBe(true)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onInstallSuccess()
|
||||
})
|
||||
|
||||
expect(mockInvalidateTriggers).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should treat the trigger plugin list as still loading when it has not resolved yet', () => {
|
||||
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })),
|
||||
)
|
||||
|
||||
expect(result.current.isChecking).toBe(true)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.uniqueIdentifier).toBe('trigger-plugin')
|
||||
expect(result.current.canInstall).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
})
|
||||
|
||||
it('should track missing and matched data source providers based on workflow store state', () => {
|
||||
const missingRender = renderWorkflowHook(
|
||||
() => useNodePluginInstallation(makeDataSourceNode({
|
||||
provider_name: 'missing-provider',
|
||||
plugin_id: 'missing-plugin',
|
||||
plugin_unique_identifier: 'missing-plugin@1.0.0',
|
||||
})),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [matchedDataSource] as never,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(missingRender.result.current.isChecking).toBe(false)
|
||||
expect(missingRender.result.current.isMissing).toBe(true)
|
||||
expect(missingRender.result.current.shouldDim).toBe(true)
|
||||
|
||||
const matchedRender = renderWorkflowHook(
|
||||
() => useNodePluginInstallation(makeDataSourceNode()),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [matchedDataSource] as never,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(matchedRender.result.current.isMissing).toBe(false)
|
||||
expect(matchedRender.result.current.shouldDim).toBe(false)
|
||||
|
||||
act(() => {
|
||||
matchedRender.result.current.onInstallSuccess()
|
||||
})
|
||||
|
||||
expect(mockInvalidDataSourceList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep data sources in checking state before the list is loaded', () => {
|
||||
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode()))
|
||||
|
||||
expect(result.current.isChecking).toBe(true)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -8,14 +8,19 @@ import type {
|
||||
CommonEdgeType,
|
||||
CommonNodeType,
|
||||
Edge,
|
||||
ModelConfig,
|
||||
Node,
|
||||
ValueSelector,
|
||||
} from '../types'
|
||||
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
@ -28,11 +33,14 @@ import { useModelList } from '@/app/components/header/account-setting/model-prov
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { MAX_TREE_DEPTH } from '@/config'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { useStrategyProviders } from '@/service/use-strategy'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
@ -46,6 +54,8 @@ import {
|
||||
useNodesMetaData,
|
||||
} from '../hooks'
|
||||
import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils'
|
||||
import { IndexMethodEnum } from '../nodes/knowledge-base/types'
|
||||
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../nodes/llm/utils'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
@ -56,6 +66,8 @@ import {
|
||||
getToolCheckParams,
|
||||
getValidTreeNodes,
|
||||
} from '../utils'
|
||||
import { extractPluginId } from '../utils/plugin'
|
||||
import { isNodePluginMissing } from '../utils/plugin-install-check'
|
||||
import { getTriggerCheckParams } from '../utils/trigger'
|
||||
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list'
|
||||
|
||||
@ -65,9 +77,11 @@ export type ChecklistItem = {
|
||||
title: string
|
||||
toolIcon?: string | Emoji
|
||||
unConnected?: boolean
|
||||
errorMessage?: string
|
||||
errorMessages: string[]
|
||||
canNavigate: boolean
|
||||
disableGoTo?: boolean
|
||||
isPluginMissing?: boolean
|
||||
pluginUniqueIdentifier?: string
|
||||
}
|
||||
|
||||
const START_NODE_TYPES: BlockEnum[] = [
|
||||
@ -77,13 +91,6 @@ const START_NODE_TYPES: BlockEnum[] = [
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
// Node types that depend on plugins
|
||||
const PLUGIN_DEPENDENT_TYPES: BlockEnum[] = [
|
||||
BlockEnum.Tool,
|
||||
BlockEnum.DataSource,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
@ -91,6 +98,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const { data: strategyProviders } = useStrategyProviders()
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
@ -98,10 +106,49 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const getToolIcon = useGetToolIcon()
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
const modelProviders = useProviderContextSelector(s => s.modelProviders)
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const map = useNodesAvailableVarList(nodes)
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const knowledgeBaseEmbeddingProviders = useMemo(() => {
|
||||
const providers = new Set<string>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.type !== CUSTOM_NODE || node.data.type !== BlockEnum.KnowledgeBase)
|
||||
return
|
||||
|
||||
const knowledgeBaseData = node.data as CommonNodeType<KnowledgeBaseNodeType>
|
||||
if (knowledgeBaseData.indexing_technique !== IndexMethodEnum.QUALIFIED)
|
||||
return
|
||||
|
||||
const provider = knowledgeBaseData.embedding_model_provider
|
||||
if (provider)
|
||||
providers.add(provider)
|
||||
})
|
||||
|
||||
return [...providers]
|
||||
}, [nodes])
|
||||
const knowledgeBaseProviderModelMap = useQueries({
|
||||
queries: knowledgeBaseEmbeddingProviders.map(provider =>
|
||||
consoleQuery.modelProviders.models.queryOptions({
|
||||
input: { params: { provider } },
|
||||
enabled: !!provider,
|
||||
refetchOnWindowFocus: false,
|
||||
select: response => response.data,
|
||||
}),
|
||||
),
|
||||
combine: (results) => {
|
||||
const modelMap: Partial<Record<string, ModelItem[]>> = {}
|
||||
knowledgeBaseEmbeddingProviders.forEach((provider, index) => {
|
||||
const models = results[index]?.data
|
||||
if (models)
|
||||
modelMap[provider] = models
|
||||
})
|
||||
return modelMap
|
||||
},
|
||||
})
|
||||
|
||||
const getCheckData = useCallback((data: CommonNodeType<{}>) => {
|
||||
let checkData = data
|
||||
@ -118,19 +165,22 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
} as CommonNodeType<KnowledgeRetrievalNodeType>
|
||||
}
|
||||
else if (data.type === BlockEnum.KnowledgeBase) {
|
||||
const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>).embedding_model_provider
|
||||
checkData = {
|
||||
...data,
|
||||
_embeddingModelList: embeddingModelList,
|
||||
_embeddingProviderModelList: modelProviderName ? knowledgeBaseProviderModelMap[modelProviderName] : undefined,
|
||||
_rerankModelList: rerankModelList,
|
||||
} as CommonNodeType<KnowledgeBaseNodeType>
|
||||
}
|
||||
return checkData
|
||||
}, [datasetsDetail, embeddingModelList, rerankModelList])
|
||||
}, [datasetsDetail, embeddingModelList, knowledgeBaseProviderModelMap, rerankModelList])
|
||||
|
||||
const needWarningNodes = useMemo<ChecklistItem[]>(() => {
|
||||
const list: ChecklistItem[] = []
|
||||
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
|
||||
const { validNodes } = getValidTreeNodes(filteredNodes, edges)
|
||||
const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider)))
|
||||
|
||||
for (let i = 0; i < filteredNodes.length; i++) {
|
||||
const node = filteredNodes[i]
|
||||
@ -166,41 +216,50 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
if (node.type === CUSTOM_NODE) {
|
||||
const checkData = getCheckData(node.data)
|
||||
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
|
||||
const isPluginMissing = PLUGIN_DEPENDENT_TYPES.includes(node.data.type as BlockEnum) && node.data._pluginInstallLocked
|
||||
const isPluginMissing = isNodePluginMissing(node.data, { builtInTools: buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, dataSourceList })
|
||||
|
||||
// Check if plugin is installed for plugin-dependent nodes first
|
||||
let errorMessage: string | undefined
|
||||
if (isPluginMissing)
|
||||
errorMessage = t('nodes.common.pluginNotInstalled', { ns: 'workflow' })
|
||||
else if (validator)
|
||||
errorMessage = validator(checkData, t, moreDataForCheckValid).errorMessage
|
||||
const errorMessages: string[] = []
|
||||
|
||||
if (!errorMessage) {
|
||||
const availableVars = map[node.id].availableVars
|
||||
|
||||
for (const variable of usedVars) {
|
||||
const isSpecialVars = isSpecialVar(variable[0])
|
||||
if (!isSpecialVars) {
|
||||
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
|
||||
if (usedNode) {
|
||||
const usedVar = usedNode.vars.find(v => v.variable === variable?.[1])
|
||||
if (!usedVar)
|
||||
errorMessage = t('errorMsg.invalidVariable', { ns: 'workflow' })
|
||||
}
|
||||
else {
|
||||
errorMessage = t('errorMsg.invalidVariable', { ns: 'workflow' })
|
||||
}
|
||||
}
|
||||
if (isPluginMissing) {
|
||||
errorMessages.push(t('nodes.common.pluginNotInstalled', { ns: 'workflow' }))
|
||||
}
|
||||
else {
|
||||
if (node.data.type === BlockEnum.LLM) {
|
||||
const modelProvider = (node.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider
|
||||
const modelIssue = getLLMModelIssue({
|
||||
modelProvider,
|
||||
isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds),
|
||||
})
|
||||
if (modelIssue === LLMModelIssueCode.providerPluginUnavailable)
|
||||
errorMessages.push(t('errorMsg.configureModel', { ns: 'workflow' }))
|
||||
}
|
||||
|
||||
if (validator) {
|
||||
const validationError = validator(checkData, t, moreDataForCheckValid).errorMessage
|
||||
if (validationError)
|
||||
errorMessages.push(validationError)
|
||||
}
|
||||
|
||||
const availableVars = map[node.id].availableVars
|
||||
let hasInvalidVar = false
|
||||
for (const variable of usedVars) {
|
||||
if (hasInvalidVar)
|
||||
break
|
||||
if (isSpecialVar(variable[0]))
|
||||
continue
|
||||
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
|
||||
if (!usedNode || !usedNode.vars.some(v => v.variable === variable?.[1]))
|
||||
hasInvalidVar = true
|
||||
}
|
||||
if (hasInvalidVar)
|
||||
errorMessages.push(t('errorMsg.invalidVariable', { ns: 'workflow' }))
|
||||
}
|
||||
|
||||
// Start nodes and Trigger nodes should not show unConnected error if they have validation errors
|
||||
// or if they are valid start nodes (even without incoming connections)
|
||||
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
|
||||
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
|
||||
|
||||
const isUnconnected = !validNodes.find(n => n.id === node.id)
|
||||
const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck)
|
||||
const isUnconnected = !validNodes.some(n => n.id === node.id)
|
||||
const shouldShowError = errorMessages.length > 0 || (isUnconnected && !canSkipConnectionCheck)
|
||||
|
||||
if (shouldShowError) {
|
||||
list.push({
|
||||
@ -209,9 +268,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
title: node.data.title,
|
||||
toolIcon,
|
||||
unConnected: isUnconnected && !canSkipConnectionCheck,
|
||||
errorMessage,
|
||||
errorMessages,
|
||||
canNavigate: !isPluginMissing,
|
||||
disableGoTo: isPluginMissing,
|
||||
isPluginMissing,
|
||||
pluginUniqueIdentifier: isPluginMissing
|
||||
? (node.data as { plugin_unique_identifier?: string }).plugin_unique_identifier
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -225,7 +288,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
id: 'start-node-required',
|
||||
type: BlockEnum.Start,
|
||||
title: t('panel.startNode', { ns: 'workflow' }),
|
||||
errorMessage: t('common.needStartNode', { ns: 'workflow' }),
|
||||
errorMessages: [t('common.needStartNode', { ns: 'workflow' })],
|
||||
canNavigate: false,
|
||||
})
|
||||
}
|
||||
@ -234,22 +297,27 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired)
|
||||
|
||||
isRequiredNodesType.forEach((type: string) => {
|
||||
if (!filteredNodes.find(node => node.data.type === type)) {
|
||||
if (!filteredNodes.some(node => node.data.type === type)) {
|
||||
list.push({
|
||||
id: `${type}-need-added`,
|
||||
type,
|
||||
// We don't have enough type info for t() here
|
||||
|
||||
title: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }),
|
||||
|
||||
errorMessage: t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) }),
|
||||
errorMessages: [t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) })],
|
||||
canNavigate: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return list
|
||||
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map])
|
||||
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, mcpTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map, modelProviders])
|
||||
|
||||
useEffect(() => {
|
||||
const currentChecklistItems = workflowStore.getState().checklistItems
|
||||
if (isDeepEqual(currentChecklistItems, needWarningNodes))
|
||||
return
|
||||
|
||||
workflowStore.setState({ checklistItems: needWarningNodes })
|
||||
}, [needWarningNodes, workflowStore])
|
||||
|
||||
return needWarningNodes
|
||||
}
|
||||
@ -258,11 +326,13 @@ export const useChecklistBeforePublish = () => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const { notify } = useToastContext()
|
||||
const queryClient = useQueryClient()
|
||||
const store = useStoreApi()
|
||||
const { nodesMap: nodesExtraData } = useNodesMetaData()
|
||||
const { data: strategyProviders } = useStrategyProviders()
|
||||
const modelProviders = useProviderContextSelector(s => s.modelProviders)
|
||||
const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail)
|
||||
const updateTime = useRef(0)
|
||||
const updateTimeRef = useRef(0)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { getNodesAvailableVarList } = useGetNodesAvailableVarList()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
@ -273,7 +343,11 @@ export const useChecklistBeforePublish = () => {
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
|
||||
const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => {
|
||||
const getCheckData = useCallback((
|
||||
data: CommonNodeType<object>,
|
||||
datasets: DataSet[],
|
||||
embeddingProviderModelMap?: Partial<Record<string, ModelItem[]>>,
|
||||
) => {
|
||||
let checkData = data
|
||||
if (data.type === BlockEnum.KnowledgeRetrieval) {
|
||||
const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids
|
||||
@ -292,9 +366,11 @@ export const useChecklistBeforePublish = () => {
|
||||
} as CommonNodeType<KnowledgeRetrievalNodeType>
|
||||
}
|
||||
else if (data.type === BlockEnum.KnowledgeBase) {
|
||||
const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>).embedding_model_provider
|
||||
checkData = {
|
||||
...data,
|
||||
_embeddingModelList: embeddingModelList,
|
||||
_embeddingProviderModelList: modelProviderName ? embeddingProviderModelMap?.[modelProviderName] : undefined,
|
||||
_rerankModelList: rerankModelList,
|
||||
} as CommonNodeType<KnowledgeBaseNodeType>
|
||||
}
|
||||
@ -317,24 +393,67 @@ export const useChecklistBeforePublish = () => {
|
||||
notify({ type: 'error', message: t('common.maxTreeDepth', { ns: 'workflow', depth: MAX_TREE_DEPTH }) })
|
||||
return false
|
||||
}
|
||||
// Before publish, we need to fetch datasets detail, in case of the settings of datasets have been changed
|
||||
const knowledgeRetrievalNodes = filteredNodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval)
|
||||
const allDatasetIds = knowledgeRetrievalNodes.reduce<string[]>((acc, node) => {
|
||||
return Array.from(new Set([...acc, ...(node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids]))
|
||||
}, [])
|
||||
let datasets: DataSet[] = []
|
||||
if (allDatasetIds.length > 0) {
|
||||
updateTime.current = updateTime.current + 1
|
||||
const currUpdateTime = updateTime.current
|
||||
const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: allDatasetIds } })
|
||||
if (datasetsDetail && datasetsDetail.length > 0) {
|
||||
// avoid old data to overwrite the new data
|
||||
if (currUpdateTime < updateTime.current)
|
||||
return false
|
||||
datasets = datasetsDetail
|
||||
updateDatasetsDetail(datasetsDetail)
|
||||
}
|
||||
|
||||
const knowledgeBaseEmbeddingProviders = [...new Set(
|
||||
filteredNodes
|
||||
.filter(node => node.data.type === BlockEnum.KnowledgeBase)
|
||||
.map(node => node.data as CommonNodeType<KnowledgeBaseNodeType>)
|
||||
.filter(node => node.indexing_technique === IndexMethodEnum.QUALIFIED)
|
||||
.map(node => node.embedding_model_provider)
|
||||
.filter((provider): provider is string => !!provider),
|
||||
)]
|
||||
|
||||
const fetchKnowledgeBaseProviderModelMap = async () => {
|
||||
const modelMap: Partial<Record<string, ModelItem[]>> = {}
|
||||
await Promise.all(knowledgeBaseEmbeddingProviders.map(async (provider) => {
|
||||
try {
|
||||
const modelList = await queryClient.fetchQuery(
|
||||
consoleQuery.modelProviders.models.queryOptions({
|
||||
input: { params: { provider } },
|
||||
}),
|
||||
)
|
||||
|
||||
if (modelList.data)
|
||||
modelMap[provider] = modelList.data
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}))
|
||||
return modelMap
|
||||
}
|
||||
|
||||
const fetchLatestDatasets = async (): Promise<DataSet[] | null> => {
|
||||
const allDatasetIds = new Set<string>()
|
||||
filteredNodes.forEach((node) => {
|
||||
if (node.data.type !== BlockEnum.KnowledgeRetrieval)
|
||||
return
|
||||
|
||||
const datasetIds = (node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids
|
||||
datasetIds.forEach(id => allDatasetIds.add(id))
|
||||
})
|
||||
|
||||
if (allDatasetIds.size === 0)
|
||||
return []
|
||||
|
||||
updateTimeRef.current = updateTimeRef.current + 1
|
||||
const currUpdateTime = updateTimeRef.current
|
||||
const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: [...allDatasetIds] } })
|
||||
if (currUpdateTime < updateTimeRef.current)
|
||||
return null
|
||||
if (datasetsDetail?.length)
|
||||
updateDatasetsDetail(datasetsDetail)
|
||||
return datasetsDetail || []
|
||||
}
|
||||
|
||||
const [embeddingProviderModelMap, datasets] = await Promise.all([
|
||||
fetchKnowledgeBaseProviderModelMap(),
|
||||
fetchLatestDatasets(),
|
||||
])
|
||||
|
||||
if (datasets === null)
|
||||
return false
|
||||
|
||||
const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider)))
|
||||
const map = getNodesAvailableVarList(nodes)
|
||||
for (let i = 0; i < filteredNodes.length; i++) {
|
||||
const node = filteredNodes[i]
|
||||
@ -361,7 +480,20 @@ export const useChecklistBeforePublish = () => {
|
||||
else {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
}
|
||||
const checkData = getCheckData(node.data, datasets)
|
||||
|
||||
if (node.data.type === BlockEnum.LLM) {
|
||||
const modelProvider = (node.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider
|
||||
const modelIssue = getLLMModelIssue({
|
||||
modelProvider,
|
||||
isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds),
|
||||
})
|
||||
if (modelIssue === LLMModelIssueCode.providerPluginUnavailable) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${t('errorMsg.configureModel', { ns: 'workflow' })}` })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const checkData = getCheckData(node.data, datasets, embeddingProviderModelMap)
|
||||
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
|
||||
|
||||
if (errorMessage) {
|
||||
@ -391,7 +523,7 @@ export const useChecklistBeforePublish = () => {
|
||||
|
||||
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
|
||||
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
|
||||
const isUnconnected = !validNodes.find(n => n.id === node.id)
|
||||
const isUnconnected = !validNodes.some(n => n.id === node.id)
|
||||
|
||||
if (isUnconnected && !canSkipConnectionCheck) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${t('common.needConnectTip', { ns: 'workflow' })}` })
|
||||
@ -412,14 +544,14 @@ export const useChecklistBeforePublish = () => {
|
||||
for (let i = 0; i < isRequiredNodesType.length; i++) {
|
||||
const type = isRequiredNodesType[i]
|
||||
|
||||
if (!filteredNodes.find(node => node.data.type === type)) {
|
||||
if (!filteredNodes.some(node => node.data.type === type)) {
|
||||
notify({ type: 'error', message: t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, strategyProviders])
|
||||
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, queryClient, strategyProviders, modelProviders])
|
||||
|
||||
return {
|
||||
handleCheckBeforePublish,
|
||||
|
||||
@ -16,11 +16,15 @@ import {
|
||||
useAllTriggerPlugins,
|
||||
useInvalidateAllTriggerPlugins,
|
||||
} from '@/service/use-triggers'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { useStore } from '../store'
|
||||
import { BlockEnum } from '../types'
|
||||
import {
|
||||
matchDataSource,
|
||||
matchToolInCollection,
|
||||
matchTriggerProvider,
|
||||
} from '../utils/plugin-install-check'
|
||||
|
||||
type InstallationState = {
|
||||
export type InstallationState = {
|
||||
isChecking: boolean
|
||||
isMissing: boolean
|
||||
uniqueIdentifier?: string
|
||||
@ -29,14 +33,31 @@ type InstallationState = {
|
||||
shouldDim: boolean
|
||||
}
|
||||
|
||||
const useToolInstallation = (data: ToolNodeType): InstallationState => {
|
||||
const builtInQuery = useAllBuiltInTools()
|
||||
const customQuery = useAllCustomTools()
|
||||
const workflowQuery = useAllWorkflowTools()
|
||||
const mcpQuery = useAllMCPTools()
|
||||
const invalidateTools = useInvalidToolsByType(data.provider_type)
|
||||
const NOOP_INSTALLATION: InstallationState = {
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: () => undefined,
|
||||
shouldDim: false,
|
||||
}
|
||||
|
||||
const useToolInstallation = (data: ToolNodeType, enabled: boolean): InstallationState => {
|
||||
const isBuiltIn = enabled && data.provider_type === CollectionType.builtIn
|
||||
const isCustom = enabled && data.provider_type === CollectionType.custom
|
||||
const isWorkflow = enabled && data.provider_type === CollectionType.workflow
|
||||
const isMcp = enabled && data.provider_type === CollectionType.mcp
|
||||
|
||||
const builtInQuery = useAllBuiltInTools(isBuiltIn)
|
||||
const customQuery = useAllCustomTools(isCustom)
|
||||
const workflowQuery = useAllWorkflowTools(isWorkflow)
|
||||
const mcpQuery = useAllMCPTools(isMcp)
|
||||
const invalidateTools = useInvalidToolsByType(enabled ? data.provider_type : undefined)
|
||||
|
||||
const collectionInfo = useMemo(() => {
|
||||
if (!enabled)
|
||||
return undefined
|
||||
|
||||
switch (data.provider_type) {
|
||||
case CollectionType.builtIn:
|
||||
return {
|
||||
@ -62,6 +83,7 @@ const useToolInstallation = (data: ToolNodeType): InstallationState => {
|
||||
return undefined
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
builtInQuery.data,
|
||||
builtInQuery.isLoading,
|
||||
customQuery.data,
|
||||
@ -77,20 +99,13 @@ const useToolInstallation = (data: ToolNodeType): InstallationState => {
|
||||
const isLoading = collectionInfo?.isLoading ?? false
|
||||
const isResolved = !!collectionInfo && !isLoading
|
||||
|
||||
const { plugin_id, provider_id, provider_name } = data
|
||||
const matchedCollection = useMemo(() => {
|
||||
if (!collection || !collection.length)
|
||||
return undefined
|
||||
|
||||
return collection.find((toolWithProvider) => {
|
||||
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
|
||||
return true
|
||||
if (canFindTool(toolWithProvider.id, data.provider_id))
|
||||
return true
|
||||
if (toolWithProvider.name === data.provider_name)
|
||||
return true
|
||||
return false
|
||||
})
|
||||
}, [collection, data.plugin_id, data.provider_id, data.provider_name])
|
||||
return matchToolInCollection(collection, { plugin_id, provider_id, provider_name })
|
||||
}, [collection, plugin_id, provider_id, provider_name])
|
||||
|
||||
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
|
||||
const canInstall = Boolean(data.plugin_unique_identifier)
|
||||
@ -112,28 +127,20 @@ const useToolInstallation = (data: ToolNodeType): InstallationState => {
|
||||
}
|
||||
}
|
||||
|
||||
const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => {
|
||||
const triggerPluginsQuery = useAllTriggerPlugins()
|
||||
const useTriggerInstallation = (data: PluginTriggerNodeType, enabled: boolean): InstallationState => {
|
||||
const triggerPluginsQuery = useAllTriggerPlugins(enabled)
|
||||
const invalidateTriggers = useInvalidateAllTriggerPlugins()
|
||||
|
||||
const triggerProviders = triggerPluginsQuery.data
|
||||
const isLoading = triggerPluginsQuery.isLoading
|
||||
|
||||
const { plugin_id, provider_id, provider_name } = data
|
||||
const matchedProvider = useMemo(() => {
|
||||
if (!triggerProviders || !triggerProviders.length)
|
||||
return undefined
|
||||
|
||||
return triggerProviders.find(provider =>
|
||||
provider.name === data.provider_name
|
||||
|| provider.id === data.provider_id
|
||||
|| (data.plugin_id && provider.plugin_id === data.plugin_id),
|
||||
)
|
||||
}, [
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
triggerProviders,
|
||||
])
|
||||
return matchTriggerProvider(triggerProviders, { plugin_id, provider_id, provider_name })
|
||||
}, [plugin_id, provider_id, provider_name, triggerProviders])
|
||||
|
||||
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
|
||||
const canInstall = Boolean(data.plugin_unique_identifier)
|
||||
@ -154,24 +161,17 @@ const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState
|
||||
}
|
||||
}
|
||||
|
||||
const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => {
|
||||
const useDataSourceInstallation = (data: DataSourceNodeType, _enabled: boolean): InstallationState => {
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const invalidateDataSourceList = useInvalidDataSourceList()
|
||||
|
||||
const { plugin_unique_identifier, plugin_id, provider_name } = data
|
||||
const matchedPlugin = useMemo(() => {
|
||||
if (!dataSourceList || !dataSourceList.length)
|
||||
return undefined
|
||||
|
||||
return dataSourceList.find((item) => {
|
||||
if (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier)
|
||||
return true
|
||||
if (data.plugin_id && item.plugin_id === data.plugin_id)
|
||||
return true
|
||||
if (data.provider_name && item.provider === data.provider_name)
|
||||
return true
|
||||
return false
|
||||
})
|
||||
}, [data.plugin_id, data.plugin_unique_identifier, data.provider_name, dataSourceList])
|
||||
return matchDataSource(dataSourceList, { plugin_unique_identifier, plugin_id, provider_name })
|
||||
}, [dataSourceList, plugin_id, plugin_unique_identifier, provider_name])
|
||||
|
||||
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id
|
||||
const canInstall = Boolean(data.plugin_unique_identifier)
|
||||
@ -195,25 +195,20 @@ const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState
|
||||
}
|
||||
|
||||
export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => {
|
||||
const toolInstallation = useToolInstallation(data as ToolNodeType)
|
||||
const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType)
|
||||
const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType)
|
||||
const isTool = data.type === BlockEnum.Tool
|
||||
const isTrigger = data.type === BlockEnum.TriggerPlugin
|
||||
const isDataSource = data.type === BlockEnum.DataSource
|
||||
|
||||
switch (data.type as BlockEnum) {
|
||||
case BlockEnum.Tool:
|
||||
return toolInstallation
|
||||
case BlockEnum.TriggerPlugin:
|
||||
return triggerInstallation
|
||||
case BlockEnum.DataSource:
|
||||
return dataSourceInstallation
|
||||
default:
|
||||
return {
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: () => undefined,
|
||||
shouldDim: false,
|
||||
}
|
||||
}
|
||||
const toolInstallation = useToolInstallation(data as ToolNodeType, isTool)
|
||||
const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType, isTrigger)
|
||||
const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType, isDataSource)
|
||||
|
||||
if (isTool)
|
||||
return toolInstallation
|
||||
if (isTrigger)
|
||||
return triggerInstallation
|
||||
if (isDataSource)
|
||||
return dataSourceInstallation
|
||||
|
||||
return NOOP_INSTALLATION
|
||||
}
|
||||
|
||||
@ -420,8 +420,6 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
if (node.data.type === BlockEnum.DataSourceEmpty)
|
||||
return
|
||||
if (node.data._pluginInstallLocked)
|
||||
return
|
||||
handleNodeSelect(node.id)
|
||||
},
|
||||
[handleNodeSelect],
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@ const ObjectValueItem: FC<Props> = ({
|
||||
{/* Key */}
|
||||
<div className="w-[120px] border-r border-gray-200">
|
||||
<input
|
||||
className="system-xs-regular placeholder:system-xs-regular block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
|
||||
className="block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none system-xs-regular placeholder:text-components-input-text-placeholder placeholder:system-xs-regular hover:bg-state-base-hover focus:bg-components-input-bg-active"
|
||||
placeholder={t('chatVariable.modal.objectKey', { ns: 'workflow' }) || ''}
|
||||
value={list[index].key}
|
||||
onChange={handleKeyChange(index)}
|
||||
@ -115,7 +115,7 @@ const ObjectValueItem: FC<Props> = ({
|
||||
{/* Value */}
|
||||
<div className="relative w-[230px]">
|
||||
<input
|
||||
className="system-xs-regular placeholder:system-xs-regular block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
|
||||
className="block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none system-xs-regular placeholder:text-components-input-text-placeholder placeholder:system-xs-regular hover:bg-state-base-hover focus:bg-components-input-bg-active"
|
||||
placeholder={t('chatVariable.modal.objectValue', { ns: 'workflow' }) || ''}
|
||||
value={list[index].value}
|
||||
onChange={handleValueChange(index)}
|
||||
|
||||
@ -273,7 +273,7 @@ const ChatVariableModal = ({
|
||||
<div
|
||||
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
|
||||
>
|
||||
<div className="system-xl-semibold mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary system-xl-semibold">
|
||||
{!chatVar ? t('chatVariable.modal.title', { ns: 'workflow' }) : t('chatVariable.modal.editTitle', { ns: 'workflow' })}
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
@ -287,7 +287,7 @@ const ChatVariableModal = ({
|
||||
<div className="max-h-[480px] overflow-y-auto px-4 py-2">
|
||||
{/* name */}
|
||||
<div className="mb-4">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
@ -300,7 +300,7 @@ const ChatVariableModal = ({
|
||||
</div>
|
||||
{/* type */}
|
||||
<div className="mb-4">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<VariableTypeSelector
|
||||
value={type}
|
||||
@ -312,7 +312,7 @@ const ChatVariableModal = ({
|
||||
</div>
|
||||
{/* default value */}
|
||||
<div className="mb-4">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center justify-between text-text-secondary">
|
||||
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
|
||||
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
|
||||
{(type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber || type === ChatVarType.ArrayBoolean) && (
|
||||
<Button
|
||||
@ -341,7 +341,7 @@ const ChatVariableModal = ({
|
||||
{type === ChatVarType.String && (
|
||||
// Input will remove \n\r, so use Textarea just like description area
|
||||
<textarea
|
||||
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={value}
|
||||
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
@ -404,10 +404,10 @@ const ChatVariableModal = ({
|
||||
</div>
|
||||
{/* description */}
|
||||
<div className="">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<textarea
|
||||
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={description}
|
||||
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
|
||||
@ -88,7 +88,7 @@ const VariableModal = ({
|
||||
<div
|
||||
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl')}
|
||||
>
|
||||
<div className="system-xl-semibold mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary system-xl-semibold">
|
||||
{!env ? t('env.modal.title', { ns: 'workflow' }) : t('env.modal.editTitle', { ns: 'workflow' })}
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
@ -102,12 +102,12 @@ const VariableModal = ({
|
||||
<div className="px-4 py-2">
|
||||
{/* type */}
|
||||
<div className="mb-4">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.type', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.type', { ns: 'workflow' })}</div>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'radius-md system-sm-regular flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
type === 'string' && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
|
||||
'flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular radius-md hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
type === 'string' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs system-sm-medium hover:border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => setType('string')}
|
||||
>
|
||||
@ -115,7 +115,7 @@ const VariableModal = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'radius-md system-sm-regular flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
'flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular radius-md hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
type === 'number' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => {
|
||||
@ -128,7 +128,7 @@ const VariableModal = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'radius-md system-sm-regular flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
'flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular radius-md hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
type === 'secret' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => setType('secret')}
|
||||
@ -147,7 +147,7 @@ const VariableModal = ({
|
||||
</div>
|
||||
{/* name */}
|
||||
<div className="mb-4">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.name', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.name', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder={t('env.modal.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
@ -160,13 +160,13 @@ const VariableModal = ({
|
||||
</div>
|
||||
{/* value */}
|
||||
<div className="mb-4">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.value', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.value', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
{
|
||||
type !== 'number'
|
||||
? (
|
||||
<textarea
|
||||
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={value}
|
||||
placeholder={t('env.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
@ -185,10 +185,10 @@ const VariableModal = ({
|
||||
</div>
|
||||
{/* description */}
|
||||
<div className="">
|
||||
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.description', { ns: 'workflow' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.description', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<textarea
|
||||
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={description}
|
||||
placeholder={t('env.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
|
||||
@ -124,7 +124,7 @@ const RunPanel: FC<RunProps> = ({
|
||||
{!hideResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
|
||||
currentTab === 'RESULT' && '!border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => switchTab('RESULT')}
|
||||
@ -134,7 +134,7 @@ const RunPanel: FC<RunProps> = ({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
|
||||
currentTab === 'DETAIL' && '!border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => switchTab('DETAIL')}
|
||||
@ -143,7 +143,7 @@ const RunPanel: FC<RunProps> = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
|
||||
currentTab === 'TRACING' && '!border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => switchTab('TRACING')}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import type {
|
||||
VariableAssignerNodeType,
|
||||
} from '@/app/components/workflow/nodes/variable-assigner/types'
|
||||
@ -10,6 +11,7 @@ import type {
|
||||
} from '@/types/workflow'
|
||||
|
||||
export type NodeSliceShape = {
|
||||
checklistItems: ChecklistItem[]
|
||||
showSingleRunPanel: boolean
|
||||
setShowSingleRunPanel: (showSingleRunPanel: boolean) => void
|
||||
nodeAnimation: boolean
|
||||
@ -56,6 +58,7 @@ export type NodeSliceShape = {
|
||||
}
|
||||
|
||||
export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({
|
||||
checklistItems: [],
|
||||
showSingleRunPanel: false,
|
||||
setShowSingleRunPanel: showSingleRunPanel => set(() => ({ showSingleRunPanel })),
|
||||
nodeAnimation: false,
|
||||
|
||||
@ -112,7 +112,6 @@ export type CommonNodeType<T = {}> = {
|
||||
subscription_id?: string
|
||||
provider_id?: string
|
||||
_dimmed?: boolean
|
||||
_pluginInstallLocked?: boolean
|
||||
} & T & Partial<PluginDefaultValue>
|
||||
|
||||
export type CommonEdgeType = {
|
||||
|
||||
@ -262,7 +262,7 @@ const UpdateDSLModal = ({
|
||||
onClose={onCancel}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('common.importDSL', { ns: 'workflow' })}</div>
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('common.importDSL', { ns: 'workflow' })}</div>
|
||||
<div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}>
|
||||
<RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" />
|
||||
</div>
|
||||
@ -273,7 +273,7 @@ const UpdateDSLModal = ({
|
||||
<RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" />
|
||||
</div>
|
||||
<div className="flex grow flex-col items-start gap-0.5 py-1">
|
||||
<div className="system-xs-medium whitespace-pre-line text-text-primary">{t('common.importDSLTip', { ns: 'workflow' })}</div>
|
||||
<div className="whitespace-pre-line text-text-primary system-xs-medium">{t('common.importDSLTip', { ns: 'workflow' })}</div>
|
||||
<div className="flex items-start gap-1 self-stretch pb-0.5 pt-1">
|
||||
<Button
|
||||
size="small"
|
||||
@ -290,7 +290,7 @@ const UpdateDSLModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-md-semibold pt-2 text-text-primary">
|
||||
<div className="pt-2 text-text-primary system-md-semibold">
|
||||
{t('common.chooseDSL', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4">
|
||||
@ -319,8 +319,8 @@ const UpdateDSLModal = ({
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="system-md-regular flex grow flex-col text-text-secondary">
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="flex grow flex-col text-text-secondary system-md-regular">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
|
||||
195
web/app/components/workflow/utils/plugin-install-check.spec.ts
Normal file
195
web/app/components/workflow/utils/plugin-install-check.spec.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import type { TriggerWithProvider } from '../block-selector/types'
|
||||
import type { CommonNodeType, ToolWithProvider } from '../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '../types'
|
||||
import {
|
||||
isNodePluginMissing,
|
||||
isPluginDependentNode,
|
||||
matchDataSource,
|
||||
matchToolInCollection,
|
||||
matchTriggerProvider,
|
||||
} from './plugin-install-check'
|
||||
|
||||
const createTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
|
||||
id: 'langgenius/search/search',
|
||||
name: 'search',
|
||||
plugin_id: 'plugin-search',
|
||||
provider: 'search-provider',
|
||||
plugin_unique_identifier: 'plugin-search@1.0.0',
|
||||
...overrides,
|
||||
} as ToolWithProvider)
|
||||
|
||||
const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({
|
||||
id: 'trigger-provider-id',
|
||||
name: 'trigger-provider',
|
||||
plugin_id: 'trigger-plugin',
|
||||
...overrides,
|
||||
} as TriggerWithProvider)
|
||||
|
||||
describe('plugin install check', () => {
|
||||
describe('isPluginDependentNode', () => {
|
||||
it('should return true for plugin dependent node types', () => {
|
||||
expect(isPluginDependentNode(BlockEnum.Tool)).toBe(true)
|
||||
expect(isPluginDependentNode(BlockEnum.DataSource)).toBe(true)
|
||||
expect(isPluginDependentNode(BlockEnum.TriggerPlugin)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-plugin node types', () => {
|
||||
expect(isPluginDependentNode(BlockEnum.LLM)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchToolInCollection', () => {
|
||||
const collection = [createTool()]
|
||||
|
||||
it('should match a tool by plugin id', () => {
|
||||
expect(matchToolInCollection(collection, { plugin_id: 'plugin-search' })).toEqual(collection[0])
|
||||
})
|
||||
|
||||
it('should match a tool by legacy provider id', () => {
|
||||
expect(matchToolInCollection(collection, { provider_id: 'search' })).toEqual(collection[0])
|
||||
})
|
||||
|
||||
it('should match a tool by provider name', () => {
|
||||
expect(matchToolInCollection(collection, { provider_name: 'search' })).toEqual(collection[0])
|
||||
})
|
||||
|
||||
it('should return undefined when no tool matches', () => {
|
||||
expect(matchToolInCollection(collection, { plugin_id: 'missing-plugin' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchTriggerProvider', () => {
|
||||
const providers = [createTriggerProvider()]
|
||||
|
||||
it('should match a trigger provider by name', () => {
|
||||
expect(matchTriggerProvider(providers, { provider_name: 'trigger-provider' })).toEqual(providers[0])
|
||||
})
|
||||
|
||||
it('should match a trigger provider by id', () => {
|
||||
expect(matchTriggerProvider(providers, { provider_id: 'trigger-provider-id' })).toEqual(providers[0])
|
||||
})
|
||||
|
||||
it('should match a trigger provider by plugin id', () => {
|
||||
expect(matchTriggerProvider(providers, { plugin_id: 'trigger-plugin' })).toEqual(providers[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchDataSource', () => {
|
||||
const dataSources = [createTool({
|
||||
provider: 'knowledge-provider',
|
||||
plugin_id: 'knowledge-plugin',
|
||||
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
|
||||
})]
|
||||
|
||||
it('should match a data source by unique identifier', () => {
|
||||
expect(matchDataSource(dataSources, { plugin_unique_identifier: 'knowledge-plugin@1.0.0' })).toEqual(dataSources[0])
|
||||
})
|
||||
|
||||
it('should match a data source by plugin id', () => {
|
||||
expect(matchDataSource(dataSources, { plugin_id: 'knowledge-plugin' })).toEqual(dataSources[0])
|
||||
})
|
||||
|
||||
it('should match a data source by provider name', () => {
|
||||
expect(matchDataSource(dataSources, { provider_name: 'knowledge-provider' })).toEqual(dataSources[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNodePluginMissing', () => {
|
||||
it('should report missing tool plugins when the collection is loaded but unmatched', () => {
|
||||
const node = {
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool',
|
||||
desc: '',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'missing-provider',
|
||||
plugin_unique_identifier: 'missing-plugin@1.0.0',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { builtInTools: [createTool()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep tool nodes installable when the collection has not loaded yet', () => {
|
||||
const node = {
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool',
|
||||
desc: '',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'missing-provider',
|
||||
plugin_unique_identifier: 'missing-plugin@1.0.0',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { builtInTools: undefined })).toBe(false)
|
||||
})
|
||||
|
||||
it('should ignore unmatched tool nodes without plugin identifiers', () => {
|
||||
const node = {
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool',
|
||||
desc: '',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'missing-provider',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { builtInTools: [createTool()] })).toBe(false)
|
||||
})
|
||||
|
||||
it('should report missing trigger plugins when no provider matches', () => {
|
||||
const node = {
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
title: 'Trigger',
|
||||
desc: '',
|
||||
provider_id: 'missing-trigger',
|
||||
plugin_unique_identifier: 'trigger-plugin@1.0.0',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { triggerPlugins: [createTriggerProvider()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep trigger plugin nodes installable when the provider list has not loaded yet', () => {
|
||||
const node = {
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
title: 'Trigger',
|
||||
desc: '',
|
||||
provider_id: 'missing-trigger',
|
||||
plugin_unique_identifier: 'trigger-plugin@1.0.0',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { triggerPlugins: undefined })).toBe(false)
|
||||
})
|
||||
|
||||
it('should report missing data source plugins when the list is loaded but unmatched', () => {
|
||||
const node = {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Data Source',
|
||||
desc: '',
|
||||
provider_name: 'missing-provider',
|
||||
plugin_unique_identifier: 'missing-data-source@1.0.0',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { dataSourceList: [createTool()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep data source nodes installable when the list has not loaded yet', () => {
|
||||
const node = {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Data Source',
|
||||
desc: '',
|
||||
provider_name: 'missing-provider',
|
||||
plugin_unique_identifier: 'missing-data-source@1.0.0',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, { dataSourceList: undefined })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for unsupported node types', () => {
|
||||
const node = {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
} as CommonNodeType
|
||||
|
||||
expect(isNodePluginMissing(node, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
95
web/app/components/workflow/utils/plugin-install-check.ts
Normal file
95
web/app/components/workflow/utils/plugin-install-check.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { TriggerWithProvider } from '../block-selector/types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
|
||||
import type { CommonNodeType, ToolWithProvider } from '../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
export const PLUGIN_DEPENDENT_TYPES: BlockEnum[] = [
|
||||
BlockEnum.Tool,
|
||||
BlockEnum.DataSource,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
export function isPluginDependentNode(type: string): boolean {
|
||||
return PLUGIN_DEPENDENT_TYPES.includes(type as BlockEnum)
|
||||
}
|
||||
|
||||
export function matchToolInCollection(
|
||||
collection: ToolWithProvider[],
|
||||
data: { plugin_id?: string, provider_id?: string, provider_name?: string },
|
||||
): ToolWithProvider | undefined {
|
||||
return collection.find(tool =>
|
||||
(data.plugin_id && tool.plugin_id === data.plugin_id)
|
||||
|| canFindTool(tool.id, data.provider_id)
|
||||
|| tool.name === data.provider_name,
|
||||
)
|
||||
}
|
||||
|
||||
export function matchTriggerProvider(
|
||||
providers: TriggerWithProvider[],
|
||||
data: { provider_name?: string, provider_id?: string, plugin_id?: string },
|
||||
): TriggerWithProvider | undefined {
|
||||
return providers.find(provider =>
|
||||
provider.name === data.provider_name
|
||||
|| provider.id === data.provider_id
|
||||
|| (data.plugin_id && provider.plugin_id === data.plugin_id),
|
||||
)
|
||||
}
|
||||
|
||||
export function matchDataSource(
|
||||
list: ToolWithProvider[],
|
||||
data: { plugin_unique_identifier?: string, plugin_id?: string, provider_name?: string },
|
||||
): ToolWithProvider | undefined {
|
||||
return list.find(item =>
|
||||
(data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier)
|
||||
|| (data.plugin_id && item.plugin_id === data.plugin_id)
|
||||
|| (data.provider_name && item.provider === data.provider_name),
|
||||
)
|
||||
}
|
||||
|
||||
export type PluginInstallCheckContext = {
|
||||
builtInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
triggerPlugins?: TriggerWithProvider[]
|
||||
dataSourceList?: ToolWithProvider[]
|
||||
}
|
||||
|
||||
export function isNodePluginMissing(
|
||||
data: CommonNodeType,
|
||||
context: PluginInstallCheckContext,
|
||||
): boolean {
|
||||
switch (data.type as BlockEnum) {
|
||||
case BlockEnum.Tool: {
|
||||
const toolData = data as ToolNodeType
|
||||
const collectionMap: Partial<Record<CollectionType, ToolWithProvider[] | undefined>> = {
|
||||
[CollectionType.builtIn]: context.builtInTools,
|
||||
[CollectionType.custom]: context.customTools,
|
||||
[CollectionType.workflow]: context.workflowTools,
|
||||
[CollectionType.mcp]: context.mcpTools,
|
||||
}
|
||||
const collection = collectionMap[toolData.provider_type]
|
||||
if (!collection)
|
||||
return false
|
||||
return !matchToolInCollection(collection, toolData) && Boolean(toolData.plugin_unique_identifier)
|
||||
}
|
||||
case BlockEnum.TriggerPlugin: {
|
||||
const triggerData = data as PluginTriggerNodeType
|
||||
if (!context.triggerPlugins)
|
||||
return false
|
||||
return !matchTriggerProvider(context.triggerPlugins, triggerData) && Boolean(triggerData.plugin_unique_identifier)
|
||||
}
|
||||
case BlockEnum.DataSource: {
|
||||
const dataSourceData = data as DataSourceNodeType
|
||||
if (!context.dataSourceList)
|
||||
return false
|
||||
return !matchDataSource(context.dataSourceList, dataSourceData) && Boolean(dataSourceData.plugin_unique_identifier)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
4
web/app/components/workflow/utils/plugin.ts
Normal file
4
web/app/components/workflow/utils/plugin.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export function extractPluginId(provider: string): string {
|
||||
const parts = provider.split('/')
|
||||
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : provider
|
||||
}
|
||||
@ -7,6 +7,7 @@ import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { isToolAuthorizationRequired } from '@/app/components/workflow/nodes/tool/auth'
|
||||
import { canFindTool } from '@/utils'
|
||||
|
||||
export const getToolCheckParams = (
|
||||
@ -17,7 +18,6 @@ export const getToolCheckParams = (
|
||||
language: string,
|
||||
) => {
|
||||
const { provider_id, provider_type, tool_name } = toolData
|
||||
const isBuiltIn = provider_type === CollectionType.builtIn
|
||||
const currentTools = provider_type === CollectionType.builtIn ? buildInTools : provider_type === CollectionType.custom ? customTools : workflowTools
|
||||
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
|
||||
const currTool = currCollection?.tools.find(tool => tool.name === tool_name)
|
||||
@ -38,7 +38,7 @@ export const getToolCheckParams = (
|
||||
})
|
||||
return formInputs
|
||||
})(),
|
||||
notAuthed: isBuiltIn && !!currCollection?.allow_delete && !currCollection?.is_team_authorization,
|
||||
notAuthed: isToolAuthorizationRequired(provider_type, currCollection),
|
||||
toolSettingSchema,
|
||||
language,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user