mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 22:28:55 +08:00
feat: support editable class labels in question classifier (#35430)
This commit is contained in:
parent
b99ba74aa4
commit
b95e6f6a7a
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>,
|
||||
}))
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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' }) })
|
||||
|
||||
@ -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
|
||||
? (
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -3,6 +3,7 @@ import type { CommonNodeType, Memory, ModelConfig, ValueSelector, VisionSetting
|
||||
export type Topic = {
|
||||
id: string
|
||||
name: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type QuestionClassifierNodeType = CommonNodeType & {
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "組み込み変数",
|
||||
|
||||
@ -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": "内置变量",
|
||||
|
||||
@ -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": "內置變數",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user