feat: support editable class labels in question classifier (#35430)

This commit is contained in:
Blackoutta 2026-05-10 20:10:16 +08:00 committed by GitHub
parent b99ba74aa4
commit b95e6f6a7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 535 additions and 97 deletions

View File

@ -1465,7 +1465,7 @@
},
"web/app/components/base/prompt-editor/index.tsx": {
"ts/no-explicit-any": {
"count": 4
"count": 3
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
@ -3858,30 +3858,9 @@
"count": 9
}
},
"web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/nodes/question-classifier/components/class-list.tsx": {
"react/set-state-in-effect": {
"count": 1
},
"react/unsupported-syntax": {
"count": 2
}
},
"web/app/components/workflow/nodes/question-classifier/default.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/question-classifier/use-config.ts": {
"react/set-state-in-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts": {

View File

@ -365,6 +365,14 @@ describe('PromptEditor', () => {
expect(() => unmount()).not.toThrow()
})
it('should rerender without ref-driven update loops', () => {
const { rerender } = render(<PromptEditor value="first" />)
expect(() => {
rerender(<PromptEditor value="second" />)
}).not.toThrow()
})
it('should render hitl block when show=true', () => {
render(
<PromptEditor

View File

@ -29,7 +29,7 @@ import {
TextNode,
} from 'lexical'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
UPDATE_DATASETS_EVENT_EMITTER,
@ -203,12 +203,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
} as any)
}, [eventEmitter, historyBlock?.history])
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
const onRef = (_floatingAnchorElem: any) => {
if (_floatingAnchorElem !== null)
setFloatingAnchorElem(_floatingAnchorElem)
}
const onRef = useCallback((nextFloatingAnchorElem: HTMLDivElement | null) => {
setFloatingAnchorElem((currentFloatingAnchorElem) => {
if (currentFloatingAnchorElem === nextFloatingAnchorElem)
return currentFloatingAnchorElem
return nextFloatingAnchorElem
})
}, [])
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>

View File

@ -172,6 +172,10 @@ export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [
variable: 'class_name',
type: VarType.string,
},
{
variable: 'class_label',
type: VarType.string,
},
{
variable: 'usage',
type: VarType.object,

View File

@ -55,6 +55,12 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
default: ({ defaultModel }: any) => <div>{defaultModel.provider}:{defaultModel.model}</div>,
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
default: ({ value }: any) => <div>{value}</div>,
}))

View File

@ -76,12 +76,18 @@ describe('question-classifier/node', () => {
render(
<Node
{...baseNodeProps}
data={createData({ classes: [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })] })}
data={createData({
classes: [
createTopic({ label: 'Billing' } as Partial<Topic>),
createTopic({ id: 'topic-2', name: 'Refunds', label: 'Refund desk' } as Partial<Topic>),
],
})}
/>,
)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()
expect(screen.getByText('Billing questions')).toBeInTheDocument()
expect(screen.getByText('Billing')).toBeInTheDocument()
expect(screen.getByText('Refund desk')).toBeInTheDocument()
expect(screen.getByText('handle-topic-1')).toBeInTheDocument()
expect(screen.getByText('handle-topic-2')).toBeInTheDocument()
})

View File

@ -144,6 +144,7 @@ describe('question-classifier/panel', () => {
expect(handleVisionResolutionEnabledChange).toHaveBeenCalledWith(true)
expect(handleVisionResolutionChange).toHaveBeenCalledWith({ resolution: 'high' })
expect(screen.getByText('class_name:string')).toBeInTheDocument()
expect(screen.getByText('class_label:string')).toBeInTheDocument()
expect(screen.getByText('usage:object')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,147 @@
import type { QuestionClassifierNodeType } from '../types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
useIsChatMode,
useNodesReadOnly,
useWorkflow,
} from '@/app/components/workflow/hooks'
import useConfigVision from '@/app/components/workflow/hooks/use-config-vision'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: vi.fn(),
useIsChatMode: vi.fn(),
useWorkflow: vi.fn(),
}))
vi.mock('reactflow', () => ({
useUpdateNodeInternals: vi.fn(() => vi.fn()),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks/use-config-vision', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const mockUseWorkflow = vi.mocked(useWorkflow)
const mockUseNodeCrud = vi.mocked(useNodeCrud)
const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked(useModelListAndDefaultModelAndCurrentProviderAndModel)
const mockUseStore = vi.mocked(useStore)
const mockUseConfigVision = vi.mocked(useConfigVision)
const mockUseAvailableVarList = vi.mocked(useAvailableVarList)
const createPayload = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({
type: BlockEnum.QuestionClassifier,
title: 'Question Classifier',
desc: '',
model: {
provider: '',
name: '',
mode: AppModeEnum.CHAT,
completion_params: {},
},
classes: [{ id: 'topic-1', name: 'Billing questions', label: 'CLASS 1' }],
query_variable_selector: ['start-node', 'sys.query'],
instruction: 'Route by topic',
vision: {
enabled: false,
},
...overrides,
})
describe('question-classifier/use-config', () => {
const setInputs = vi.fn()
let latestVisionOptions: {
onChange: (payload: QuestionClassifierNodeType['vision']) => void
} | null = null
beforeEach(() => {
vi.clearAllMocks()
latestVisionOptions = null
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
mockUseIsChatMode.mockReturnValue(true)
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranch: vi.fn(() => []),
} as unknown as ReturnType<typeof useWorkflow>)
mockUseNodeCrud.mockReturnValue({
inputs: createPayload(),
setInputs,
})
mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
} as ReturnType<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>)
mockUseStore.mockImplementation((selector) => {
return selector({ nodesDefaultConfigs: {} } as never)
})
mockUseConfigVision.mockImplementation((_model, options) => {
latestVisionOptions = options as typeof latestVisionOptions
return {
isVisionModel: false,
handleVisionResolutionEnabledChange: vi.fn(),
handleVisionResolutionChange: vi.fn(),
handleModelChanged: vi.fn(() => {
latestVisionOptions?.onChange({ enabled: false })
}),
}
})
mockUseAvailableVarList.mockReturnValue({
availableVars: [],
availableNodes: [],
availableNodesWithParent: [],
} as unknown as ReturnType<typeof useAvailableVarList>)
})
it('preserves the selected model when the vision follow-up updates after model selection', async () => {
const { result } = renderHook(() => useConfig('question-classifier-node', createPayload()))
act(() => {
result.current.handleModelChanged({
provider: 'openai',
modelId: 'gpt-4o',
mode: AppModeEnum.CHAT,
})
})
await waitFor(() => {
expect(setInputs).toHaveBeenLastCalledWith(expect.objectContaining({
model: expect.objectContaining({
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
}),
vision: {
enabled: false,
},
}))
})
})
})

View File

@ -3,8 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ClassItem from '../class-item'
const mockEditorRender = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: () => ({
@ -16,13 +14,12 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', ()
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
__esModule: true,
default: (props: {
title: string
title: React.ReactNode
value: string
onChange: (value: string) => void
onRemove: () => void
showRemove?: boolean
}) => {
mockEditorRender(props)
return (
<div>
<div>{props.title}</div>
@ -70,9 +67,32 @@ describe('question-classifier/class-item', () => {
name: 'Billing questions updated',
})
expect(onRemove).toHaveBeenCalledTimes(1)
expect(mockEditorRender).toHaveBeenCalledWith(expect.objectContaining({
title: 'workflow.nodes.questionClassifiers.class 1',
value: 'Billing questions',
}))
expect(screen.getByRole('button', { name: 'CLASS 1' })).toBeInTheDocument()
})
it('preserves a custom label when editing the classifier name', () => {
const onChange = vi.fn()
render(
<ClassItem
nodeId="node-1"
payload={{ id: 'topic-1', name: 'Billing questions', label: 'Billing' } as Topic}
onChange={onChange}
onRemove={vi.fn()}
index={1}
filterVar={() => true}
/>,
)
fireEvent.change(screen.getByLabelText('class-name'), {
target: { value: 'Billing questions updated' },
})
expect(onChange).toHaveBeenCalledWith({
id: 'topic-1',
name: 'Billing questions updated',
label: 'Billing',
})
expect(screen.getByRole('button', { name: 'Billing' })).toBeInTheDocument()
})
})

View File

@ -2,12 +2,13 @@
import type { FC } from 'react'
import type { Topic } from '../types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { uniqueId } from 'es-toolkit/compat'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { getCanonicalClassLabel, getDisplayClassLabel } from './class-label-utils'
const i18nPrefix = 'nodes.questionClassifiers'
@ -21,6 +22,7 @@ type Props = {
index: number
readonly?: boolean
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
onLabelEditStart?: () => void
}
const ClassItem: FC<Props> = ({
@ -33,18 +35,49 @@ const ClassItem: FC<Props> = ({
index,
readonly,
filterVar,
onLabelEditStart,
}) => {
const { t } = useTranslation()
const [instanceId, setInstanceId] = useState(() => uniqueId())
const reactId = useId()
const [isEditingLabel, setIsEditingLabel] = useState(false)
const [draftLabel, setDraftLabel] = useState('')
const labelInputRef = useRef<HTMLInputElement>(null)
const displayLabel = getDisplayClassLabel(payload.label, index, t)
const instanceId = `${nodeId}-${reactId}`
useEffect(() => {
setInstanceId(`${nodeId}-${uniqueId()}`)
}, [nodeId])
if (isEditingLabel)
labelInputRef.current?.select()
}, [isEditingLabel])
const handleNameChange = useCallback((value: string) => {
onChange({ ...payload, name: value })
}, [onChange, payload])
const handleLabelSave = useCallback((nextValue: string) => {
const normalizedLabel = getCanonicalClassLabel(nextValue, index, t)
setIsEditingLabel(false)
setDraftLabel(normalizedLabel)
const shouldPersistLabel = normalizedLabel !== displayLabel
|| (payload.label !== undefined && payload.label !== normalizedLabel)
if (shouldPersistLabel)
onChange({ ...payload, label: normalizedLabel })
}, [displayLabel, index, onChange, payload, t])
const handleLabelCancel = useCallback(() => {
setDraftLabel(displayLabel)
setIsEditingLabel(false)
}, [displayLabel])
const handleLabelEditStart = useCallback(() => {
if (readonly)
return
setDraftLabel(displayLabel)
setIsEditingLabel(true)
onLabelEditStart?.()
}, [displayLabel, onLabelEditStart, readonly])
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
hideChatVar: false,
@ -52,11 +85,65 @@ const ClassItem: FC<Props> = ({
filterVar,
})
const title = isEditingLabel
? (
<input
ref={labelInputRef}
value={draftLabel}
aria-label={t(`${i18nPrefix}.labelEditorAriaLabel`, { ns: 'workflow' })}
className={cn(
'h-6 w-full rounded-md border border-divider-regular bg-components-input-bg-normal px-2 text-xs font-semibold text-text-secondary ring-0 outline-none',
'focus:border-components-input-border-active',
)}
onChange={event => setDraftLabel(event.target.value)}
onBlur={() => handleLabelSave(draftLabel)}
onClick={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleLabelSave(draftLabel)
}
if (event.key === 'Escape') {
event.preventDefault()
handleLabelCancel()
}
}}
autoFocus
/>
)
: readonly
? (
<div className="-ml-1 px-1 py-0.5 text-left text-xs leading-4 font-semibold text-text-secondary">
{displayLabel}
</div>
)
: (
<button
type="button"
aria-label={displayLabel}
className={cn(
'-ml-1 rounded px-1 py-0.5 text-left text-xs leading-4 font-semibold text-text-secondary transition-colors',
'cursor-text hover:bg-state-base-hover',
)}
onDoubleClick={handleLabelEditStart}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleLabelEditStart()
}
}}
>
{displayLabel}
</button>
)
return (
<Editor
className={className}
headerClassName={headerClassName}
title={`${t(`${i18nPrefix}.class`, { ns: 'workflow' })} ${index}`}
title={title}
placeholder={t(`${i18nPrefix}.topicPlaceholder`, { ns: 'workflow' })!}
value={payload.name}
onChange={handleNameChange}

View File

@ -0,0 +1,66 @@
'use client'
import type { TFunction } from 'i18next'
const i18nPrefix = 'nodes.questionClassifiers'
const LEGACY_DEFAULT_LABEL_PREFIX = 'CLASS'
const DEFAULT_EQUIVALENT_PREFIXES = ['CLASS', '分类', '分類', 'クラス']
const getCanonicalDefaultClassLabel = (index: number) => `${LEGACY_DEFAULT_LABEL_PREFIX} ${index}`
const getTranslatedDefaultClassLabel = (t: TFunction, index: number) => {
const translated = t(`${i18nPrefix}.defaultLabel`, { ns: 'workflow', index })
if (typeof translated !== 'string')
return undefined
const resolvedLabel = translated.replace('{{index}}', String(index))
const rawWorkflowKey = `workflow.${i18nPrefix}.defaultLabel`
const rawKey = `${i18nPrefix}.defaultLabel`
if (
resolvedLabel === rawWorkflowKey
|| resolvedLabel === rawKey
|| resolvedLabel.startsWith(`${rawWorkflowKey}:`)
|| resolvedLabel.startsWith(`${rawKey}:`)
) {
return undefined
}
return resolvedLabel
}
const normalizeClassLabel = (label?: string | null) => label?.trim() ?? ''
export const getDefaultClassLabel = (_t: TFunction, index: number) => getCanonicalDefaultClassLabel(index)
export const getDisplayClassLabel = (
label: string | undefined,
index: number,
t: TFunction,
) => normalizeClassLabel(label) || getTranslatedDefaultClassLabel(t, index) || getCanonicalDefaultClassLabel(index)
export const isDefaultClassLabel = (
label: string | undefined,
index: number,
t: TFunction,
) => {
const normalizedLabel = normalizeClassLabel(label)
if (!normalizedLabel)
return true
return DEFAULT_EQUIVALENT_PREFIXES.some(prefix => normalizedLabel === `${prefix} ${index}`)
|| normalizedLabel === getTranslatedDefaultClassLabel(t, index)
}
export const getCanonicalClassLabel = (
label: string | undefined,
index: number,
t: TFunction,
) => {
const normalizedLabel = normalizeClassLabel(label)
if (!normalizedLabel)
return getCanonicalDefaultClassLabel(index)
if (isDefaultClassLabel(normalizedLabel, index, t))
return getCanonicalDefaultClassLabel(index)
return normalizedLabel
}

View File

@ -14,8 +14,10 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
import { useEdgesInteractions } from '../../../hooks'
import AddButton from '../../_base/components/add-button'
import Item from './class-item'
import { getDefaultClassLabel, isDefaultClassLabel } from './class-label-utils'
const i18nPrefix = 'nodes.questionClassifiers'
const INLINE_LABEL_HINT_STORAGE_KEY = 'question-classifier-inline-label-hint-dismissed'
type Props = {
nodeId: string
@ -40,6 +42,17 @@ const ClassList: FC<Props> = ({
const [shouldScrollToEnd, setShouldScrollToEnd] = useState(false)
const prevListLength = useRef(list.length)
const [collapsed, setCollapsed] = useState(false)
const [isRenameHintDismissed, setIsRenameHintDismissed] = useState(() => {
if (typeof window === 'undefined')
return true
try {
return window.localStorage.getItem(INLINE_LABEL_HINT_STORAGE_KEY) === 'true'
}
catch {
return false
}
})
const handleClassChange = useCallback((index: number) => {
return (value: Topic) => {
@ -52,13 +65,17 @@ const ClassList: FC<Props> = ({
const handleAddClass = useCallback(() => {
const newList = produce(list, (draft) => {
draft.push({ id: `${Date.now()}`, name: '' })
draft.push({
id: `${Date.now()}`,
name: '',
label: getDefaultClassLabel(t, draft.length + 1),
})
})
onChange(newList)
setShouldScrollToEnd(true)
if (collapsed)
setCollapsed(false)
}, [list, onChange, collapsed])
}, [collapsed, list, onChange, t])
const handleRemoveClass = useCallback((index: number) => {
return () => {
@ -72,7 +89,6 @@ const ClassList: FC<Props> = ({
const topicCount = list.length
// Scroll to the newly added item after the list updates
useEffect(() => {
if (shouldScrollToEnd && list.length > prevListLength.current)
setShouldScrollToEnd(false)
@ -83,6 +99,22 @@ const ClassList: FC<Props> = ({
setCollapsed(!collapsed)
}, [collapsed])
const dismissRenameHint = useCallback(() => {
if (isRenameHintDismissed)
return
setIsRenameHintDismissed(true)
try {
window.localStorage.setItem(INLINE_LABEL_HINT_STORAGE_KEY, 'true')
}
catch {
}
}, [isRenameHintDismissed])
const shouldShowRenameHint = !readonly && !isRenameHintDismissed && list.some((item, index) => {
return isDefaultClassLabel(item.label, index + 1, t)
})
return (
<>
<div className="mb-2 flex items-center justify-between" onClick={handleCollapse}>
@ -100,6 +132,11 @@ const ClassList: FC<Props> = ({
)}
</div>
</div>
{shouldShowRenameHint && (
<div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary">
{t(`${i18nPrefix}.renameHint`, { ns: 'workflow' })}
</div>
)}
{!collapsed && (
<div
@ -117,12 +154,7 @@ const ClassList: FC<Props> = ({
>
{
list.map((item, index) => {
const canDrag = (() => {
if (readonly)
return false
return topicCount >= 2
})()
const canDrag = !readonly && topicCount >= 2
return (
<div
key={item.id}
@ -153,6 +185,7 @@ const ClassList: FC<Props> = ({
index={index + 1}
readonly={readonly}
filterVar={filterVar}
onLabelEditStart={dismissRenameHint}
/>
</div>
</div>

View File

@ -1,3 +1,4 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { QuestionClassifierNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
@ -28,10 +29,12 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = {
{
id: '1',
name: '',
label: 'CLASS 1',
},
{
id: '2',
name: '',
label: 'CLASS 2',
},
],
_targetBranches: [
@ -48,7 +51,7 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = {
enabled: false,
},
},
checkValid(payload: QuestionClassifierNodeType, t: any) {
checkValid(payload: QuestionClassifierNodeType, t: TFunction<'workflow'>) {
let errorMessages = ''
if (!errorMessages && (!payload.query_variable_selector || payload.query_variable_selector.length === 0))
errorMessages = t(`${i18nPrefix}errorMsg.fieldRequired`, { ns: 'workflow', field: t(`${i18nPrefix}nodes.questionClassifiers.inputVars`, { ns: 'workflow' }) })

View File

@ -11,19 +11,19 @@ import {
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { NodeSourceHandle } from '../_base/components/node-handle'
import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var'
const i18nPrefix = 'nodes.questionClassifiers'
import { getDisplayClassLabel } from './components/class-label-utils'
const MAX_CLASS_TEXT_LENGTH = 50
type TruncatedClassItemProps = {
topic: { id: string, name: string }
topic: { id: string, name: string, label?: string }
index: number
nodeId: string
t: TFunction
}
const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, t }) => {
const displayLabel = getDisplayClassLabel(topic.label, index + 1, t)
const truncatedText = topic.name.length > MAX_CLASS_TEXT_LENGTH
? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...`
: topic.name
@ -42,8 +42,8 @@ const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId,
return (
<div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-[5px] py-[3px]">
<div className="system-2xs-semibold-uppercase text-text-secondary uppercase">
{`${t(`${i18nPrefix}.class`, { ns: 'workflow' })} ${index + 1}`}
<div className="text-xs leading-4 font-semibold text-text-secondary">
{displayLabel}
</div>
{shouldShowTooltip
? (

View File

@ -127,6 +127,11 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
type="string"
description={t(`${i18nPrefix}.outputVars.className`, { ns: 'workflow' })}
/>
<VarItem
name="class_label"
type="string"
description={t(`${i18nPrefix}.outputVars.classLabel`, { ns: 'workflow' })}
/>
<VarItem
name="usage"
type="object"

View File

@ -3,6 +3,7 @@ import type { CommonNodeType, Memory, ModelConfig, ValueSelector, VisionSetting
export type Topic = {
id: string
name: string
label?: string
}
export type QuestionClassifierNodeType = CommonNodeType & {

View File

@ -1,7 +1,7 @@
import type { Memory, ValueSelector, Var } from '../../types'
import type { QuestionClassifierNodeType, Topic } from './types'
import { produce } from 'immer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { startTransition, useCallback, useEffect, useRef } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -26,13 +26,17 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
const { getBeforeNodesInSameBranch } = useWorkflow()
const startNode = getBeforeNodesInSameBranch(id).find(node => node.data.type === BlockEnum.Start)
const startNodeId = startNode?.id
const { inputs, setInputs } = useNodeCrud<QuestionClassifierNodeType>(id, payload)
const { inputs, setInputs: doSetInputs } = useNodeCrud<QuestionClassifierNodeType>(id, payload)
const inputRef = useRef(inputs)
const setInputs = useCallback((newInputs: QuestionClassifierNodeType) => {
doSetInputs(newInputs)
inputRef.current = newInputs
}, [doSetInputs])
useEffect(() => {
inputRef.current = inputs
}, [inputs])
const [modelChanged, setModelChanged] = useState(false)
const isHandlingModelChangeRef = useRef(false)
const {
currentProvider,
currentModel,
@ -42,6 +46,13 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
const modelMode = inputs.model?.mode
const isChatModel = modelMode === AppModeEnum.CHAT
const handleVisionChange = useCallback((newPayload: QuestionClassifierNodeType['vision']) => {
const newInputs = produce(inputRef.current, (draft) => {
draft.vision = newPayload
})
setInputs(newInputs)
}, [setInputs])
const {
isVisionModel,
handleVisionResolutionEnabledChange,
@ -49,12 +60,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
handleModelChanged: handleVisionConfigAfterModelChanged,
} = useConfigVision(model, {
payload: inputs.vision,
onChange: (newPayload) => {
const newInputs = produce(inputs, (draft) => {
draft.vision = newPayload
})
setInputs(newInputs)
},
onChange: handleVisionChange,
})
const handleModelChanged = useCallback((model: { provider: string, modelId: string, mode?: string }) => {
@ -63,21 +69,23 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
draft.model.name = model.modelId
draft.model.mode = model.mode!
})
isHandlingModelChangeRef.current = true
setInputs(newInputs)
setModelChanged(true)
}, [setInputs])
useEffect(() => {
if (currentProvider?.provider && currentModel?.model && !model.provider) {
handleModelChanged({
provider: currentProvider?.provider,
modelId: currentModel?.model,
mode: currentModel?.model_properties?.mode as string,
startTransition(() => {
handleModelChanged({
provider: currentProvider?.provider,
modelId: currentModel?.model,
mode: currentModel?.model_properties?.mode as string | undefined,
})
})
}
}, [model.provider, currentProvider, currentModel, handleModelChanged])
const handleCompletionParamsChange = useCallback((newParams: Record<string, any>) => {
const handleCompletionParamsChange = useCallback((newParams: Record<string, unknown>) => {
const newInputs = produce(inputs, (draft) => {
draft.model.completion_params = newParams
})
@ -86,11 +94,13 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
// change to vision model to set vision enabled, else disabled
useEffect(() => {
if (!modelChanged)
if (!isHandlingModelChangeRef.current)
return
setModelChanged(false)
handleVisionConfigAfterModelChanged()
}, [isVisionModel, modelChanged])
isHandlingModelChangeRef.current = false
startTransition(() => {
handleVisionConfigAfterModelChanged()
})
}, [handleVisionConfigAfterModelChanged, isVisionModel])
const handleQueryVarChange = useCallback((newVar: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {
@ -101,22 +111,58 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
useEffect(() => {
const isReady = defaultConfig && Object.keys(defaultConfig).length > 0
if (isReady) {
let query_variable_selector: ValueSelector = []
if (isChatMode && inputs.query_variable_selector.length === 0 && startNodeId)
query_variable_selector = [startNodeId, 'sys.query']
setInputs({
...inputs,
...defaultConfig,
query_variable_selector: inputs.query_variable_selector.length > 0 ? inputs.query_variable_selector : query_variable_selector,
})
}
}, [defaultConfig])
if (!isReady)
return
const handleClassesChange = useCallback((newClasses: any) => {
const currentInputs = inputRef.current
let shouldUpdate = false
const nextInputs = produce(currentInputs, (draft) => {
if (!draft.model)
draft.model = defaultConfig.model
if (!draft.classes)
draft.classes = defaultConfig.classes
if (!draft._targetBranches)
draft._targetBranches = defaultConfig._targetBranches
if (!draft.vision)
draft.vision = defaultConfig.vision
if (draft.query_variable_selector.length === 0 && isChatMode && startNodeId) {
draft.query_variable_selector = [startNodeId, 'sys.query']
shouldUpdate = true
}
if (!currentInputs.model && defaultConfig.model)
shouldUpdate = true
if (!currentInputs.classes && defaultConfig.classes)
shouldUpdate = true
if (!currentInputs._targetBranches && defaultConfig._targetBranches)
shouldUpdate = true
if (!currentInputs.vision && defaultConfig.vision)
shouldUpdate = true
})
if (!shouldUpdate)
return
startTransition(() => {
setInputs(nextInputs)
})
}, [defaultConfig, isChatMode, setInputs, startNodeId])
const handleClassesChange = useCallback((newClasses: Topic[]) => {
const newInputs = produce(inputs, (draft) => {
draft.classes = newClasses
draft._targetBranches = newClasses
draft._targetBranches = newClasses.map((item: Topic) => ({
id: item.id,
name: item.name,
}))
})
setInputs(newInputs)
}, [inputs, setInputs])
@ -170,7 +216,13 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
const handleSortTopic = useCallback((newTopics: (Topic & { id: string })[]) => {
const newInputs = produce(inputs, (draft) => {
draft.classes = newTopics.filter(Boolean).map(item => ({
const sortedTopics = newTopics.filter(Boolean)
draft.classes = sortedTopics.map(item => ({
id: item.id,
name: item.name,
label: item.label,
}))
draft._targetBranches = sortedTopics.map(item => ({
id: item.id,
name: item.name,
}))

View File

@ -28,7 +28,7 @@ describe('workflow preview question classifier node', () => {
title: 'Classifier',
desc: '',
classes: [
{ id: 'class-1', name: 'Billing' },
{ id: 'class-1', name: 'Billing', label: 'Billing label' },
{ id: 'class-2', name: 'Support' },
],
} as never,
@ -38,7 +38,8 @@ describe('workflow preview question classifier node', () => {
<Node {...props} />,
)
expect(getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument()
expect(getByText('Billing label')).toBeInTheDocument()
expect(getByText('CLASS 2')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="class-1"]')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="class-2"]')).toBeInTheDocument()
})

View File

@ -4,10 +4,9 @@ import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
import { getDisplayClassLabel } from '@/app/components/workflow/nodes/question-classifier/components/class-label-utils'
import { NodeSourceHandle } from '../../node-handle'
const i18nPrefix = 'nodes.questionClassifiers'
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
const { t } = useTranslation()
const { data } = props
@ -24,7 +23,7 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
className="relative"
>
<InfoPanel
title={`${t(`${i18nPrefix}.class`, { ns: 'workflow' })} ${index + 1}`}
title={getDisplayClassLabel(topic.label, index + 1, t)}
content=""
/>
<NodeSourceHandle

View File

@ -897,13 +897,17 @@
"nodes.questionClassifiers.advancedSetting": "Advanced Setting",
"nodes.questionClassifiers.class": "Class",
"nodes.questionClassifiers.classNamePlaceholder": "Write your class name",
"nodes.questionClassifiers.defaultLabel": "CLASS {{index}}",
"nodes.questionClassifiers.inputVars": "Input Variables",
"nodes.questionClassifiers.instruction": "Instruction",
"nodes.questionClassifiers.instructionPlaceholder": "Write your instruction",
"nodes.questionClassifiers.instructionTip": "Input additional instructions to help the question classifier better understand how to categorize questions.",
"nodes.questionClassifiers.labelEditorAriaLabel": "Class label editor",
"nodes.questionClassifiers.model": "model",
"nodes.questionClassifiers.outputVars.classLabel": "Class Label",
"nodes.questionClassifiers.outputVars.className": "Class Name",
"nodes.questionClassifiers.outputVars.usage": "Model Usage Information",
"nodes.questionClassifiers.renameHint": "Double-click class title to rename",
"nodes.questionClassifiers.topicName": "Topic Name",
"nodes.questionClassifiers.topicPlaceholder": "Write your topic name",
"nodes.start.builtInVar": "Built-in Variables",

View File

@ -897,13 +897,17 @@
"nodes.questionClassifiers.advancedSetting": "高度な設定",
"nodes.questionClassifiers.class": "クラス",
"nodes.questionClassifiers.classNamePlaceholder": "クラス名を入力してください",
"nodes.questionClassifiers.defaultLabel": "クラス {{index}}",
"nodes.questionClassifiers.inputVars": "入力変数",
"nodes.questionClassifiers.instruction": "指示",
"nodes.questionClassifiers.instructionPlaceholder": "指示を入力してください",
"nodes.questionClassifiers.instructionTip": "質問分類器が質問をどのように分類するかをよりよく理解するための追加の指示を入力します。",
"nodes.questionClassifiers.labelEditorAriaLabel": "クラスラベルエディター",
"nodes.questionClassifiers.model": "モデル",
"nodes.questionClassifiers.outputVars.classLabel": "クラスラベル",
"nodes.questionClassifiers.outputVars.className": "クラス名",
"nodes.questionClassifiers.outputVars.usage": "モデル使用量",
"nodes.questionClassifiers.renameHint": "クラスタイトルをダブルクリックして名前を変更",
"nodes.questionClassifiers.topicName": "トピック名",
"nodes.questionClassifiers.topicPlaceholder": "トピック名を入力してください",
"nodes.start.builtInVar": "組み込み変数",

View File

@ -897,13 +897,17 @@
"nodes.questionClassifiers.advancedSetting": "高级设置",
"nodes.questionClassifiers.class": "分类",
"nodes.questionClassifiers.classNamePlaceholder": "输入你的分类名称",
"nodes.questionClassifiers.defaultLabel": "分类 {{index}}",
"nodes.questionClassifiers.inputVars": "输入变量",
"nodes.questionClassifiers.instruction": "指令",
"nodes.questionClassifiers.instructionPlaceholder": "在这里输入你的指令",
"nodes.questionClassifiers.instructionTip": "你可以输入额外的附加指令,帮助问题分类器更好的理解如何分类",
"nodes.questionClassifiers.labelEditorAriaLabel": "分类标签编辑器",
"nodes.questionClassifiers.model": "模型",
"nodes.questionClassifiers.outputVars.classLabel": "分类标签",
"nodes.questionClassifiers.outputVars.className": "分类名称",
"nodes.questionClassifiers.outputVars.usage": "模型用量信息",
"nodes.questionClassifiers.renameHint": "双击分类标题以重命名",
"nodes.questionClassifiers.topicName": "主题内容",
"nodes.questionClassifiers.topicPlaceholder": "在这里输入你的主题内容",
"nodes.start.builtInVar": "内置变量",

View File

@ -897,13 +897,17 @@
"nodes.questionClassifiers.advancedSetting": "高級設置",
"nodes.questionClassifiers.class": "分類",
"nodes.questionClassifiers.classNamePlaceholder": "輸入你的分類名稱",
"nodes.questionClassifiers.defaultLabel": "分類 {{index}}",
"nodes.questionClassifiers.inputVars": "輸入變數",
"nodes.questionClassifiers.instruction": "指令",
"nodes.questionClassifiers.instructionPlaceholder": "在這裡輸入你的指令",
"nodes.questionClassifiers.instructionTip": "你可以輸入額外的附加指令,幫助問題分類器更好的理解如何分類",
"nodes.questionClassifiers.labelEditorAriaLabel": "分類標籤編輯器",
"nodes.questionClassifiers.model": "模型",
"nodes.questionClassifiers.outputVars.classLabel": "分類標籤",
"nodes.questionClassifiers.outputVars.className": "分類名稱",
"nodes.questionClassifiers.outputVars.usage": "模型用量信息",
"nodes.questionClassifiers.renameHint": "雙擊分類標題以重新命名",
"nodes.questionClassifiers.topicName": "主題內容",
"nodes.questionClassifiers.topicPlaceholder": "在這裡輸入你的主題內容",
"nodes.start.builtInVar": "內置變數",