{props.title}
@@ -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(
+
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()
})
})
diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx
index 1e90d4590d..139c314def 100644
--- a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx
+++ b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx
@@ -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 = ({
@@ -33,18 +35,49 @@ const ClassItem: FC = ({
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(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 = ({
filterVar,
})
+ const title = isEditingLabel
+ ? (
+ 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
+ ? (
+
+ {displayLabel}
+
+ )
+ : (
+
+ )
+
return (
`${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
+}
diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx
index fead81fb19..1a80266de5 100644
--- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx
+++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx
@@ -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 = ({
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 = ({
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 = ({
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 = ({
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 (
<>
@@ -100,6 +132,11 @@ const ClassList: FC
= ({
)}
+ {shouldShowRenameHint && (
+ = ({
>
{
list.map((item, index) => {
- const canDrag = (() => {
- if (readonly)
- return false
-
- return topicCount >= 2
- })()
+ const canDrag = !readonly && topicCount >= 2
return (
= ({
index={index + 1}
readonly={readonly}
filterVar={filterVar}
+ onLabelEditStart={dismissRenameHint}
/>
diff --git a/web/app/components/workflow/nodes/question-classifier/default.ts b/web/app/components/workflow/nodes/question-classifier/default.ts
index 1ee0d3e8d1..c8f882ae31 100644
--- a/web/app/components/workflow/nodes/question-classifier/default.ts
+++ b/web/app/components/workflow/nodes/question-classifier/default.ts
@@ -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