feat(question-classifier): add drag-and-drop sorting for topics list

- Implement topic sorting functionality using react-sortablejs
- Add draggable handle and visual feedback during sorting
- Update node internals after sorting to ensure proper rendering
pull/22066/head
Mminamiyama 11 months ago
parent d61ea5a2de
commit d6cfb21992

@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useCallback } from 'react' import React, { useCallback, useState } from 'react'
import produce from 'immer' import produce from 'immer'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEdgesInteractions } from '../../../hooks' import { useEdgesInteractions } from '../../../hooks'
@ -8,6 +8,10 @@ import AddButton from '../../_base/components/add-button'
import Item from './class-item' import Item from './class-item'
import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types' import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types' import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { ReactSortable } from 'react-sortablejs'
import { noop } from 'lodash-es'
import { RiDraggable } from '@remixicon/react'
import cn from '@/utils/classnames'
const i18nPrefix = 'workflow.nodes.questionClassifiers' const i18nPrefix = 'workflow.nodes.questionClassifiers'
@ -17,6 +21,7 @@ type Props = {
onChange: (list: Topic[]) => void onChange: (list: Topic[]) => void
readonly?: boolean readonly?: boolean
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
handleSortTopic?: (newTopics: (Topic & { id: string })[]) => void
} }
const ClassList: FC<Props> = ({ const ClassList: FC<Props> = ({
@ -25,6 +30,7 @@ const ClassList: FC<Props> = ({
onChange, onChange,
readonly, readonly,
filterVar, filterVar,
handleSortTopic = noop,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions() const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
@ -55,22 +61,46 @@ const ClassList: FC<Props> = ({
} }
}, [list, onChange, handleEdgeDeleteByDeleteBranch, nodeId]) }, [list, onChange, handleEdgeDeleteByDeleteBranch, nodeId])
const [willDeleteCaseId, setWillDeleteCaseId] = useState('')
const topicCount = list.length
const handleSideWidth = 3
// Todo Remove; edit topic name // Todo Remove; edit topic name
return ( return (
<div className='space-y-2'> <ReactSortable
list={list.map(item => ({ ...item }))}
setList={handleSortTopic}
handle='.handle'
ghostClass='bg-components-panel-bg'
animation={150}
disabled={readonly}
className='space-y-2'
>
{ {
list.map((item, index) => { list.map((item, index) => {
return ( return (
<Item <div key={item.id}
nodeId={nodeId} className={cn(
key={list[index].id} 'group relative rounded-[10px] bg-components-panel-bg',
payload={item} willDeleteCaseId === item.id && 'bg-state-destructive-hover',
onChange={handleClassChange(index)} `-ml-${handleSideWidth} min-h-[40px] px-0 py-0`,
onRemove={handleRemoveClass(index)} )}>
index={index + 1} <RiDraggable className={cn(
readonly={readonly} 'handle absolute left-0 top-3 hidden h-3 w-3 cursor-pointer text-text-quaternary',
filterVar={filterVar} topicCount > 1 && 'group-hover:block',
/> )} />
<div className={`ml-${handleSideWidth}`}>
<Item
nodeId={nodeId}
key={list[index].id}
payload={item}
onChange={handleClassChange(index)}
onRemove={handleRemoveClass(index)}
index={index + 1}
readonly={readonly}
filterVar={filterVar}
/>
</div>
</div>
) )
}) })
} }
@ -81,7 +111,7 @@ const ClassList: FC<Props> = ({
/> />
)} )}
</div> </ReactSortable>
) )
} }
export default React.memo(ClassList) export default React.memo(ClassList)

@ -1,144 +1,146 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import VarReferencePicker from '../_base/components/variable/var-reference-picker' import VarReferencePicker from '../_base/components/variable/var-reference-picker'
import ConfigVision from '../_base/components/config-vision' import ConfigVision from '../_base/components/config-vision'
import useConfig from './use-config' import useConfig from './use-config'
import ClassList from './components/class-list' import ClassList from './components/class-list'
import AdvancedSetting from './components/advanced-setting' import AdvancedSetting from './components/advanced-setting'
import type { QuestionClassifierNodeType } from './types' import type { QuestionClassifierNodeType } from './types'
import Field from '@/app/components/workflow/nodes/_base/components/field' import Field from '@/app/components/workflow/nodes/_base/components/field'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { NodePanelProps } from '@/app/components/workflow/types' import type { NodePanelProps } from '@/app/components/workflow/types'
import Split from '@/app/components/workflow/nodes/_base/components/split' import Split from '@/app/components/workflow/nodes/_base/components/split'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse' import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
const i18nPrefix = 'workflow.nodes.questionClassifiers' const i18nPrefix = 'workflow.nodes.questionClassifiers'
const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
id, id,
data, data,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
readOnly, readOnly,
inputs, inputs,
handleModelChanged, handleModelChanged,
isChatMode, isChatMode,
isChatModel, isChatModel,
handleCompletionParamsChange, handleCompletionParamsChange,
handleQueryVarChange, handleQueryVarChange,
handleTopicsChange, handleTopicsChange,
hasSetBlockStatus, hasSetBlockStatus,
availableVars, availableVars,
availableNodesWithParent, availableNodesWithParent,
handleInstructionChange, handleInstructionChange,
handleMemoryChange, handleMemoryChange,
isVisionModel, isVisionModel,
handleVisionResolutionChange, handleVisionResolutionChange,
handleVisionResolutionEnabledChange, handleVisionResolutionEnabledChange,
filterVar, filterVar,
} = useConfig(id, data) handleSortTopic,
} = useConfig(id, data)
const model = inputs.model
const model = inputs.model
return (
<div className='pt-2'> return (
<div className='space-y-4 px-4'> <div className='pt-2'>
<Field <div className='space-y-4 px-4'>
title={t(`${i18nPrefix}.model`)} <Field
required title={t(`${i18nPrefix}.model`)}
> required
<ModelParameterModal >
popupClassName='!w-[387px]' <ModelParameterModal
isInWorkflow popupClassName='!w-[387px]'
isAdvancedMode={true} isInWorkflow
mode={model?.mode} isAdvancedMode={true}
provider={model?.provider} mode={model?.mode}
completionParams={model.completion_params} provider={model?.provider}
modelId={model.name} completionParams={model.completion_params}
setModel={handleModelChanged} modelId={model.name}
onCompletionParamsChange={handleCompletionParamsChange} setModel={handleModelChanged}
hideDebugWithMultipleModel onCompletionParamsChange={handleCompletionParamsChange}
debugWithMultipleModel={false} hideDebugWithMultipleModel
readonly={readOnly} debugWithMultipleModel={false}
/> readonly={readOnly}
</Field> />
<Field </Field>
title={t(`${i18nPrefix}.inputVars`)} <Field
required title={t(`${i18nPrefix}.inputVars`)}
> required
<VarReferencePicker >
readonly={readOnly} <VarReferencePicker
isShowNodeName readonly={readOnly}
nodeId={id} isShowNodeName
value={inputs.query_variable_selector} nodeId={id}
onChange={handleQueryVarChange} value={inputs.query_variable_selector}
filterVar={filterVar} onChange={handleQueryVarChange}
/> filterVar={filterVar}
</Field> />
<Split /> </Field>
<ConfigVision <Split />
nodeId={id} <ConfigVision
readOnly={readOnly} nodeId={id}
isVisionModel={isVisionModel} readOnly={readOnly}
enabled={inputs.vision?.enabled} isVisionModel={isVisionModel}
onEnabledChange={handleVisionResolutionEnabledChange} enabled={inputs.vision?.enabled}
config={inputs.vision?.configs} onEnabledChange={handleVisionResolutionEnabledChange}
onConfigChange={handleVisionResolutionChange} config={inputs.vision?.configs}
/> onConfigChange={handleVisionResolutionChange}
<Field />
title={t(`${i18nPrefix}.class`)} <Field
required title={t(`${i18nPrefix}.class`)}
> required
<ClassList >
nodeId={id} <ClassList
list={inputs.classes} nodeId={id}
onChange={handleTopicsChange} list={inputs.classes}
readonly={readOnly} onChange={handleTopicsChange}
filterVar={filterVar} readonly={readOnly}
/> filterVar={filterVar}
</Field> handleSortTopic={handleSortTopic}
<Split /> />
</div> </Field>
<FieldCollapse <Split />
title={t(`${i18nPrefix}.advancedSetting`)} </div>
> <FieldCollapse
<AdvancedSetting title={t(`${i18nPrefix}.advancedSetting`)}
hideMemorySetting={!isChatMode} >
instruction={inputs.instruction} <AdvancedSetting
onInstructionChange={handleInstructionChange} hideMemorySetting={!isChatMode}
memory={inputs.memory} instruction={inputs.instruction}
onMemoryChange={handleMemoryChange} onInstructionChange={handleInstructionChange}
readonly={readOnly} memory={inputs.memory}
isChatApp={isChatMode} onMemoryChange={handleMemoryChange}
isChatModel={isChatModel} readonly={readOnly}
hasSetBlockStatus={hasSetBlockStatus} isChatApp={isChatMode}
nodesOutputVars={availableVars} isChatModel={isChatModel}
availableNodes={availableNodesWithParent} hasSetBlockStatus={hasSetBlockStatus}
/> nodesOutputVars={availableVars}
</FieldCollapse> availableNodes={availableNodesWithParent}
<Split /> />
<div> </FieldCollapse>
<OutputVars> <Split />
<> <div>
<VarItem <OutputVars>
name='class_name' <>
type='string' <VarItem
description={t(`${i18nPrefix}.outputVars.className`)} name='class_name'
/> type='string'
<VarItem description={t(`${i18nPrefix}.outputVars.className`)}
name='usage' />
type='object' <VarItem
description={t(`${i18nPrefix}.outputVars.usage`)} name='usage'
/> type='object'
</> description={t(`${i18nPrefix}.outputVars.usage`)}
</OutputVars> />
</div> </>
</div> </OutputVars>
) </div>
} </div>
)
export default React.memo(Panel) }
export default React.memo(Panel)

@ -9,13 +9,15 @@ import {
import { useStore } from '../../store' import { useStore } from '../../store'
import useAvailableVarList from '../_base/hooks/use-available-var-list' import useAvailableVarList from '../_base/hooks/use-available-var-list'
import useConfigVision from '../../hooks/use-config-vision' import useConfigVision from '../../hooks/use-config-vision'
import type { QuestionClassifierNodeType } from './types' import type { QuestionClassifierNodeType, Topic } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
import { useUpdateNodeInternals } from 'reactflow'
const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
const updateNodeInternals = useUpdateNodeInternals()
const { nodesReadOnly: readOnly } = useNodesReadOnly() const { nodesReadOnly: readOnly } = useNodesReadOnly()
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const defaultConfig = useStore(s => s.nodesDefaultConfigs)[payload.type] const defaultConfig = useStore(s => s.nodesDefaultConfigs)[payload.type]
@ -166,6 +168,17 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
return varPayload.type === VarType.string return varPayload.type === VarType.string
}, []) }, [])
const handleSortTopic = useCallback((newTopics: (Topic & { id: string })[]) => {
const newInputs = produce(inputs, (draft) => {
draft.classes = newTopics.filter(Boolean).map(item => ({
id: item.id,
name: item.name,
}))
})
setInputs(newInputs)
updateNodeInternals(id)
}, [id, inputs, setInputs, updateNodeInternals])
return { return {
readOnly, readOnly,
inputs, inputs,
@ -185,6 +198,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
isVisionModel, isVisionModel,
handleVisionResolutionEnabledChange, handleVisionResolutionEnabledChange,
handleVisionResolutionChange, handleVisionResolutionChange,
handleSortTopic,
} }
} }

Loading…
Cancel
Save