Compare commits

..

6 Commits

69 changed files with 1441 additions and 1202 deletions

View File

@@ -7,7 +7,7 @@ import { I18nClientProvider as I18N } from '../app/components/provider/i18n'
import commonEnUS from '../i18n/en-US/common.json'
import '../app/styles/globals.css'
import '../app/styles/markdown.css'
import '../app/styles/markdown.scss'
import './storybook.css'
const queryClient = new QueryClient({

View File

@@ -12,15 +12,15 @@ vi.mock('ahooks', async (importOriginal) => {
}
})
vi.mock('@/app/components/base/ui/slider', () => ({
Slider: (props: { className?: string, min?: number, max?: number, value: number, onValueChange: (value: number) => void }) => (
vi.mock('react-slider', () => ({
default: (props: { className?: string, min?: number, max?: number, value: number, onChange: (value: number) => void }) => (
<input
type="range"
className={`slider ${props.className ?? ''}`}
className={props.className}
min={props.min}
max={props.max}
value={props.value}
onChange={e => props.onValueChange(Number(e.target.value))}
onChange={e => props.onChange(Number(e.target.value))}
/>
),
}))

View File

@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
import { Unblur } from '@/app/components/base/icons/src/vender/solid/education'
import { Slider } from '@/app/components/base/ui/slider'
import Slider from '@/app/components/base/slider'
import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config'
import ItemPanel from './item-panel'
@@ -105,13 +105,12 @@ const AgentSetting: FC<Props> = ({
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
value={tempPayload.max_iteration}
onValueChange={(value) => {
onChange={(value) => {
setTempPayload({
...tempPayload,
max_iteration: value,
})
}}
aria-label={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })}
/>
<input

View File

@@ -288,8 +288,10 @@ describe('ConfigContent', () => {
/>,
)
const weightedScoreSlider = screen.getByLabelText('dataset.weightedScore.semantic')
weightedScoreSlider.focus()
const weightedScoreSlider = screen.getAllByRole('slider')
.find(slider => slider.getAttribute('aria-valuemax') === '1')
expect(weightedScoreSlider).toBeDefined()
await user.click(weightedScoreSlider!)
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')

View File

@@ -0,0 +1,7 @@
.weightedScoreSliderTrack {
background: var(--color-util-colors-blue-light-blue-light-500) !important;
}
.weightedScoreSliderTrack-1 {
background: transparent !important;
}

View File

@@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'
import WeightedScore from './weighted-score'
describe('WeightedScore', () => {
const getSliderInput = () => screen.getByLabelText('dataset.weightedScore.semantic')
beforeEach(() => {
vi.clearAllMocks()
})
@@ -50,8 +48,8 @@ describe('WeightedScore', () => {
render(<WeightedScore value={value} onChange={onChange} />)
// Act
const slider = getSliderInput()
slider.focus()
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')
@@ -71,8 +69,9 @@ describe('WeightedScore', () => {
render(<WeightedScore value={value} onChange={onChange} readonly />)
// Act
const slider = getSliderInput()
expect(slider).toBeDisabled()
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
await user.keyboard('{ArrowRight}')
// Assert

View File

@@ -1,13 +1,9 @@
import type { CSSProperties } from 'react'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Slider } from '@/app/components/base/ui/slider'
const weightedScoreSliderStyle: CSSProperties & Record<'--slider-track' | '--slider-range', string> = {
'--slider-track': 'var(--color-util-colors-teal-teal-500)',
'--slider-range': 'var(--color-util-colors-blue-light-blue-light-500)',
}
import Slider from '@/app/components/base/slider'
import { cn } from '@/utils/classnames'
import './weighted-score.css'
const formatNumber = (value: number) => {
if (value > 0 && value < 1)
@@ -37,26 +33,24 @@ const WeightedScore = ({
return (
<div>
<div className="space-x-3 rounded-lg border border-components-panel-border px-3 pb-2 pt-5">
<div className="grow" style={weightedScoreSliderStyle}>
<Slider
className="grow"
max={1.0}
min={0}
step={0.1}
value={value.value[0]}
onValueChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
disabled={readonly}
aria-label={t('weightedScore.semantic', { ns: 'dataset' })}
/>
</div>
<Slider
className={cn('h-0.5 grow rounded-full !bg-util-colors-teal-teal-500')}
max={1.0}
min={0}
step={0.1}
value={value.value[0]}
onChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
trackClassName="weightedScoreSliderTrack"
disabled={readonly}
/>
<div className="mt-3 flex justify-between">
<div className="flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500">
<div className="mr-1 truncate uppercase" title={t('weightedScore.semantic', { ns: 'dataset' }) || ''}>
{t('weightedScore.semantic', { ns: 'dataset' })}
</div>
{formatNumber(value.value[0])}
</div>
<div className="flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500">
{formatNumber(value.value[1])}
<div className="ml-1 truncate uppercase" title={t('weightedScore.keyword', { ns: 'dataset' }) || ''}>
{t('weightedScore.keyword', { ns: 'dataset' })}

View File

@@ -588,66 +588,6 @@ describe('useChat', () => {
expect(lastResponse.workflowProcess?.status).toBe('failed')
})
it('should keep separate iteration traces for repeated executions of the same iteration node', async () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'iteration trace test' }, {})
})
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
callbacks.onIterationStart({ data: { id: 'iter-run-1', node_id: 'iter-1' } })
callbacks.onIterationStart({ data: { id: 'iter-run-2', node_id: 'iter-1' } })
callbacks.onIterationFinish({ data: { id: 'iter-run-1', node_id: 'iter-1', status: 'succeeded' } })
callbacks.onIterationFinish({ data: { id: 'iter-run-2', node_id: 'iter-1', status: 'succeeded' } })
})
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
expect(tracing).toHaveLength(2)
expect(tracing).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'iter-run-1', status: 'succeeded' }),
expect.objectContaining({ id: 'iter-run-2', status: 'succeeded' }),
]))
})
it('should keep separate top-level traces for repeated executions of the same node', async () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'top-level trace test' }, {})
})
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
callbacks.onNodeStarted({ data: { id: 'node-run-1', node_id: 'node-1', title: 'Node 1' } })
callbacks.onNodeStarted({ data: { id: 'node-run-2', node_id: 'node-1', title: 'Node 1 retry' } })
callbacks.onNodeFinished({ data: { id: 'node-run-1', node_id: 'node-1', status: 'succeeded' } })
callbacks.onNodeFinished({ data: { id: 'node-run-2', node_id: 'node-1', status: 'succeeded' } })
})
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
expect(tracing).toHaveLength(2)
expect(tracing).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'node-run-1', status: 'succeeded' }),
expect.objectContaining({ id: 'node-run-2', status: 'succeeded' }),
]))
})
it('should handle early exits in tracing events during iteration or loop', async () => {
let callbacks: HookCallbacks
@@ -683,7 +623,7 @@ describe('useChat', () => {
callbacks.onNodeFinished({ data: { id: 'n-1', iteration_id: 'iter-1' } })
})
const traceLen1 = result.current.chatList.at(-1)!.workflowProcess?.tracing?.length
const traceLen1 = result.current.chatList[result.current.chatList.length - 1].workflowProcess?.tracing?.length
expect(traceLen1).toBe(0) // None added due to iteration early hits
})
@@ -767,7 +707,7 @@ describe('useChat', () => {
expect(result.current.chatList.some(item => item.id === 'question-m-child')).toBe(true)
expect(result.current.chatList.some(item => item.id === 'm-child')).toBe(true)
expect(result.current.chatList.at(-1)!.content).toBe('child answer')
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('child answer')
})
it('should strip local file urls before sending payload', () => {
@@ -865,7 +805,7 @@ describe('useChat', () => {
})
expect(onGetConversationMessages).toHaveBeenCalled()
expect(result.current.chatList.at(-1)!.content).toBe('streamed content')
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('streamed content')
})
it('should clear suggested questions when suggestion fetch fails after completion', async () => {
@@ -911,7 +851,7 @@ describe('useChat', () => {
callbacks.onNodeFinished({ data: { node_id: 'n-loop', id: 'n-loop' } })
})
const latestResponse = result.current.chatList.at(-1)!
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
expect(latestResponse.workflowProcess?.tracing).toHaveLength(0)
})
@@ -938,7 +878,7 @@ describe('useChat', () => {
callbacks.onTTSChunk('m-th-bind', '')
})
const latestResponse = result.current.chatList.at(-1)!
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
expect(latestResponse.id).toBe('m-th-bind')
expect(latestResponse.conversationId).toBe('c-th-bind')
expect(latestResponse.workflowProcess?.status).toBe('succeeded')
@@ -1031,7 +971,7 @@ describe('useChat', () => {
callbacks.onCompleted()
})
const lastResponse = result.current.chatList.at(-1)!
const lastResponse = result.current.chatList[result.current.chatList.length - 1]
expect(lastResponse.agent_thoughts![0].thought).toContain('resumed')
expect(lastResponse.workflowProcess?.tracing?.length).toBeGreaterThan(0)

View File

@@ -12,7 +12,6 @@ import type {
IOnDataMoreInfo,
IOtherOptions,
} from '@/service/base'
import type { NodeTracing } from '@/types/workflow'
import { uniqBy } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { produce, setAutoFreeze } from 'immer'
@@ -32,7 +31,6 @@ import {
} from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
import useTimestamp from '@/hooks/use-timestamp'
import { useParams, usePathname } from '@/next/navigation'
import {
@@ -54,39 +52,6 @@ type SendCallback = {
isPublicAPI?: boolean
}
type ParallelTraceLike = Pick<NodeTracing, 'id' | 'node_id' | 'parallel_id' | 'execution_metadata'>
const findParallelTraceIndex = (
tracing: ParallelTraceLike[],
data: Partial<ParallelTraceLike>,
) => {
const incomingParallelId = data.execution_metadata?.parallel_id ?? data.parallel_id
if (data.id) {
const matchedByIdIndex = tracing.findIndex((item) => {
if (item.id !== data.id)
return false
const existingParallelId = item.execution_metadata?.parallel_id ?? item.parallel_id
if (!existingParallelId || !incomingParallelId)
return true
return existingParallelId === incomingParallelId
})
if (matchedByIdIndex > -1)
return matchedByIdIndex
}
return tracing.findIndex((item) => {
if (item.node_id !== data.node_id)
return false
const existingParallelId = item.execution_metadata?.parallel_id ?? item.parallel_id
return existingParallelId === incomingParallelId
})
}
export const useChat = (
config?: ChatConfig,
formSettings?: {
@@ -454,7 +419,8 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const iterationIndex = findParallelTraceIndex(tracing, iterationFinishedData)
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
if (iterationIndex > -1) {
tracing[iterationIndex] = {
...tracing[iterationIndex],
@@ -466,34 +432,38 @@ export const useChat = (
},
onNodeStarted: ({ data: nodeStartedData }) => {
updateChatTreeNode(messageId, (responseItem) => {
if (params.loop_id)
return
if (!responseItem.workflowProcess)
return
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
...nodeStartedData,
status: NodeRunningStatus.Running,
})
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
// if the node is already started, update the node
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
if (nodeStartedData.iteration_id)
return
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
}
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
updateChatTreeNode(messageId, (responseItem) => {
if (params.loop_id)
return
if (!responseItem.workflowProcess?.tracing)
return
if (nodeFinishedData.iteration_id)
return
if (nodeFinishedData.loop_id)
return
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.id === nodeFinishedData.id
@@ -535,7 +505,8 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const loopIndex = findParallelTraceIndex(tracing, loopFinishedData)
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
if (loopIndex > -1) {
tracing[loopIndex] = {
...tracing[loopIndex],
@@ -611,7 +582,7 @@ export const useChat = (
{},
otherOptions,
)
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer, params.loop_id])
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
const updateCurrentQAOnTree = useCallback(({
parentId,
@@ -1001,13 +972,12 @@ export const useChat = (
},
onIterationFinish: ({ data: iterationFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const iterationIndex = findParallelTraceIndex(tracing, iterationFinishedData)
if (iterationIndex > -1) {
tracing[iterationIndex] = {
...tracing[iterationIndex],
...iterationFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
tracing[iterationIndex] = {
...tracing[iterationIndex],
...iterationFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
updateCurrentQAOnTree({
@@ -1018,19 +988,30 @@ export const useChat = (
})
},
onNodeStarted: ({ data: nodeStartedData }) => {
// `data` is the outer send payload for this request; loop child runs should not emit top-level node traces here.
if (data.loop_id)
return
if (!responseItem.workflowProcess)
return
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
...nodeStartedData,
status: NodeRunningStatus.Running,
})
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
if (nodeStartedData.iteration_id)
return
if (data.loop_id)
return
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
@@ -1039,14 +1020,10 @@ export const useChat = (
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
// Use the outer request payload here as well so loop child runs skip top-level finish handling entirely.
if (data.loop_id)
return
if (nodeFinishedData.iteration_id)
return
if (nodeFinishedData.loop_id)
if (data.loop_id)
return
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
@@ -1092,13 +1069,12 @@ export const useChat = (
},
onLoopFinish: ({ data: loopFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const loopIndex = findParallelTraceIndex(tracing, loopFinishedData)
if (loopIndex > -1) {
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
updateCurrentQAOnTree({

View File

@@ -93,6 +93,7 @@ const ConfigParamModal: FC<Props> = ({
className="mt-1"
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
onChange={(val) => {
/* v8 ignore next -- callback dispatch depends on react-slider drag mechanics that are flaky in jsdom. @preserve */
setAnnotationConfig({
...annotationConfig,
score_threshold: val / 100,

View File

@@ -1,9 +1,20 @@
import { render, screen } from '@testing-library/react'
import ScoreSlider from '../index'
describe('ScoreSlider', () => {
const getSliderInput = () => screen.getByLabelText('appDebug.feature.annotation.scoreThreshold.title')
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({
default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => (
<input
type="range"
data-testid="slider"
value={value}
min={min}
max={max}
onChange={e => onChange(Number(e.target.value))}
/>
),
}))
describe('ScoreSlider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@@ -11,7 +22,7 @@ describe('ScoreSlider', () => {
it('should render the slider', () => {
render(<ScoreSlider value={90} onChange={vi.fn()} />)
expect(getSliderInput()).toBeInTheDocument()
expect(screen.getByTestId('slider')).toBeInTheDocument()
})
it('should display easy match and accurate match labels', () => {
@@ -26,14 +37,14 @@ describe('ScoreSlider', () => {
it('should render with custom className', () => {
const { container } = render(<ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />)
expect(getSliderInput()).toBeInTheDocument()
// Verifying the component renders successfully with a custom className
expect(screen.getByTestId('slider')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('custom-class')
})
it('should pass value to the slider', () => {
render(<ScoreSlider value={95} onChange={vi.fn()} />)
expect(getSliderInput()).toHaveValue('95')
expect(screen.getByText('0.95')).toBeInTheDocument()
expect(screen.getByTestId('slider')).toHaveValue('95')
})
})

View File

@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react'
import Slider from '../index'
describe('BaseSlider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the slider component', () => {
render(<Slider value={50} onChange={vi.fn()} />)
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should display the formatted value in the thumb', () => {
render(<Slider value={85} onChange={vi.fn()} />)
expect(screen.getByText('0.85')).toBeInTheDocument()
})
it('should use default min/max/step when not provided', () => {
render(<Slider value={50} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should use custom min/max/step when provided', () => {
render(<Slider value={90} min={80} max={100} step={5} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '80')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '90')
})
it('should handle NaN value as 0', () => {
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
})
it('should pass disabled prop', () => {
render(<Slider value={50} disabled onChange={vi.fn()} />)
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})

View File

@@ -0,0 +1,40 @@
import ReactSlider from 'react-slider'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
type ISliderProps = {
className?: string
value: number
max?: number
min?: number
step?: number
disabled?: boolean
onChange: (value: number) => void
}
const Slider: React.FC<ISliderProps> = ({ className, max, min, step, value, disabled, onChange }) => {
return (
<ReactSlider
disabled={disabled}
value={isNaN(value) ? 0 : value}
min={min || 0}
max={max || 100}
step={step || 1}
className={cn(className, s.slider)}
thumbClassName={cn(s['slider-thumb'], 'top-[-7px] h-[18px] w-2 cursor-pointer rounded-[36px] border !border-black/8 bg-white shadow-md')}
trackClassName={s['slider-track']}
onChange={onChange}
renderThumb={(props, state) => (
<div {...props}>
<div className="relative h-full w-full">
<div className="absolute left-[50%] top-[-16px] translate-x-[-50%] text-text-primary system-sm-semibold">
{(state.valueNow / 100).toFixed(2)}
</div>
</div>
</div>
)}
/>
)
}
export default Slider

View File

@@ -0,0 +1,20 @@
.slider {
position: relative;
}
.slider.disabled {
opacity: 0.6;
}
.slider-thumb:focus {
outline: none;
}
.slider-track {
background-color: #528BFF;
height: 2px;
}
.slider-track-1 {
background-color: #E5E7EB;
}

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Slider } from '@/app/components/base/ui/slider'
import Slider from '@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider'
type Props = {
className?: string
@@ -10,42 +10,23 @@ type Props = {
onChange: (value: number) => void
}
const clamp = (value: number, min: number, max: number) => {
if (!Number.isFinite(value))
return min
return Math.min(Math.max(value, min), max)
}
const ScoreSlider: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
const safeValue = clamp(value, 80, 100)
return (
<div className={className}>
<div className="relative mt-[14px]">
<div className="mt-[14px] h-px">
<Slider
className="w-full"
value={safeValue}
min={80}
max={100}
min={80}
step={1}
onValueChange={onChange}
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
value={value}
onChange={onChange}
/>
<div
className="pointer-events-none absolute top-[-16px] text-text-primary system-sm-semibold"
style={{
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
transform: 'translateX(-50%)',
}}
>
{(safeValue / 100).toFixed(2)}
</div>
</div>
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">

View File

@@ -14,14 +14,12 @@ describe('ParamItem Slider onChange', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('Test Param')
it('should divide slider value by 100 when max < 5', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={0.5} min={0} max={1} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
slider.focus()
await user.click(slider)
await user.keyboard('{ArrowRight}')
// max=1 < 5, so slider value change (50->51) becomes 0.51
@@ -31,9 +29,9 @@ describe('ParamItem Slider onChange', () => {
it('should not divide slider value when max >= 5', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
slider.focus()
await user.click(slider)
await user.keyboard('{ArrowRight}')
// max=10 >= 5, so value remains raw (5->6)

View File

@@ -17,8 +17,6 @@ describe('ParamItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('Test Param')
describe('Rendering', () => {
it('should render the parameter name', () => {
render(<ParamItem {...defaultProps} />)
@@ -56,7 +54,7 @@ describe('ParamItem', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -76,7 +74,7 @@ describe('ParamItem', () => {
it('should disable Slider when enable is false', () => {
render(<ParamItem {...defaultProps} enable={false} />)
expect(getSlider()).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
it('should set switch value based on enable prop', () => {
@@ -137,7 +135,7 @@ describe('ParamItem', () => {
await user.clear(input)
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0)
expect(getSlider()).toHaveAttribute('aria-valuenow', '0')
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
await user.tab()
@@ -168,12 +166,12 @@ describe('ParamItem', () => {
await user.type(input, '1.5')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
expect(getSlider()).toHaveAttribute('aria-valuenow', '100')
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100')
})
it('should pass scaled value to slider when max < 5', () => {
render(<ParamItem {...defaultProps} value={0.5} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
// When max < 5, slider value = value * 100 = 50
expect(slider).toHaveAttribute('aria-valuenow', '50')
@@ -181,7 +179,7 @@ describe('ParamItem', () => {
it('should pass raw value to slider when max >= 5', () => {
render(<ParamItem {...defaultProps} value={5} max={10} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
// When max >= 5, slider value = value = 5
expect(slider).toHaveAttribute('aria-valuenow', '5')
@@ -214,15 +212,15 @@ describe('ParamItem', () => {
render(<ParamItem {...defaultProps} value={0.5} min={0} />)
// Slider should get value * 100 = 50, min * 100 = 0, max * 100 = 100
const slider = getSlider()
expect(slider).toHaveAttribute('max', '100')
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '100')
})
it('should not scale slider value when max >= 5', () => {
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
const slider = getSlider()
expect(slider).toHaveAttribute('max', '10')
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '10')
})
it('should expose default minimum of 0 when min is not provided', () => {

View File

@@ -14,8 +14,6 @@ describe('ScoreThresholdItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold')
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<ScoreThresholdItem {...defaultProps} />)
@@ -34,7 +32,7 @@ describe('ScoreThresholdItem', () => {
render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -65,7 +63,7 @@ describe('ScoreThresholdItem', () => {
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(getSlider()).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})

View File

@@ -19,8 +19,6 @@ describe('TopKItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k')
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<TopKItem {...defaultProps} />)
@@ -39,7 +37,7 @@ describe('TopKItem', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -54,7 +52,7 @@ describe('TopKItem', () => {
render(<TopKItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(getSlider()).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -79,10 +77,10 @@ describe('TopKItem', () => {
it('should render slider with max >= 5 so no scaling is applied', () => {
render(<TopKItem {...defaultProps} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
// max=10 >= 5 so slider shows raw values
expect(slider).toHaveAttribute('max', '10')
expect(slider).toHaveAttribute('aria-valuemax', '10')
})
it('should not render a switch (no hasSwitch prop)', () => {
@@ -118,9 +116,9 @@ describe('TopKItem', () => {
it('should call onChange with integer value when slider changes', async () => {
const user = userEvent.setup()
render(<TopKItem {...defaultProps} value={2} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
slider.focus()
await user.click(slider)
await user.keyboard('{ArrowRight}')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('top_k', 3)

View File

@@ -1,8 +1,8 @@
'use client'
import type { FC } from 'react'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import { Slider } from '@/app/components/base/ui/slider'
import {
NumberField,
NumberFieldControls,
@@ -78,8 +78,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
value={max < 5 ? value * 100 : value}
min={min < 1 ? min * 100 : min}
max={max < 5 ? max * 100 : max}
onValueChange={value => onChange(id, value / (max < 5 ? 100 : 1))}
aria-label={name}
onChange={value => onChange(id, value / (max < 5 ? 100 : 1))}
/>
</div>
</div>

View File

@@ -3,13 +3,10 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import OnBlurBlock from '../on-blur-or-focus-block'
import { CaptureEditorPlugin } from '../test-utils'
import { CLEAR_HIDE_MENU_TIMEOUT } from '../workflow-variable-block'
const renderOnBlurBlock = (props?: {
onBlur?: () => void
@@ -75,7 +72,7 @@ describe('OnBlurBlock', () => {
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should call onBlur and dispatch escape after delay when blur target is not var-search-input', async () => {
it('should call onBlur when blur target is not var-search-input', async () => {
const onBlur = vi.fn()
const { getEditor } = renderOnBlurBlock({ onBlur })
@@ -85,14 +82,6 @@ describe('OnBlurBlock', () => {
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
@@ -101,18 +90,9 @@ describe('OnBlurBlock', () => {
expect(handled).toBe(true)
expect(onBlur).toHaveBeenCalledTimes(1)
expect(onEscape).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).toHaveBeenCalledTimes(1)
unregister()
vi.useRealTimers()
})
it('should dispatch delayed escape when onBlur callback is not provided', async () => {
it('should handle blur when onBlur callback is not provided', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
@@ -121,28 +101,16 @@ describe('OnBlurBlock', () => {
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
act(() => {
vi.advanceTimersByTime(200)
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
expect(onEscape).toHaveBeenCalledTimes(1)
unregister()
vi.useRealTimers()
expect(handled).toBe(true)
})
it('should skip onBlur and delayed escape when blur target is var-search-input', async () => {
it('should skip onBlur when blur target is var-search-input', async () => {
const onBlur = vi.fn()
const { getEditor } = renderOnBlurBlock({ onBlur })
@@ -152,31 +120,17 @@ describe('OnBlurBlock', () => {
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const target = document.createElement('input')
target.classList.add('var-search-input')
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(target))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(handled).toBe(true)
expect(onBlur).not.toHaveBeenCalled()
expect(onEscape).not.toHaveBeenCalled()
unregister()
vi.useRealTimers()
})
it('should handle focus command when onFocus callback is not provided', async () => {
@@ -198,59 +152,6 @@ describe('OnBlurBlock', () => {
})
})
describe('Clear timeout command', () => {
it('should clear scheduled escape timeout when clear command is dispatched', async () => {
const { getEditor } = renderOnBlurBlock({ onBlur: vi.fn() })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
act(() => {
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
act(() => {
editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).not.toHaveBeenCalled()
unregister()
vi.useRealTimers()
})
it('should handle clear command when no timeout is scheduled', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle cleanup', () => {
it('should unregister commands when component unmounts', async () => {
const { getEditor, unmount } = renderOnBlurBlock()
@@ -266,16 +167,13 @@ describe('OnBlurBlock', () => {
let blurHandled = true
let focusHandled = true
let clearHandled = true
act(() => {
blurHandled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
focusHandled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
clearHandled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
expect(blurHandled).toBe(false)
expect(focusHandled).toBe(false)
expect(clearHandled).toBe(false)
})
})
})

View File

@@ -1,14 +1,13 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $getRoot, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { $getRoot } from 'lexical'
import { CustomTextNode } from '../custom-text/node'
import { CaptureEditorPlugin } from '../test-utils'
import UpdateBlock, {
PROMPT_EDITOR_INSERT_QUICKLY,
PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
} from '../update-block'
import { CLEAR_HIDE_MENU_TIMEOUT } from '../workflow-variable-block'
const { mockUseEventEmitterContextContext } = vi.hoisted(() => ({
mockUseEventEmitterContextContext: vi.fn(),
@@ -157,7 +156,7 @@ describe('UpdateBlock', () => {
})
describe('Quick insert event', () => {
it('should insert slash and dispatch clear command when quick insert event matches instance id', async () => {
it('should insert slash when quick insert event matches instance id', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
@@ -168,13 +167,6 @@ describe('UpdateBlock', () => {
selectRootEnd(editor!)
const clearCommandHandler = vi.fn(() => true)
const unregister = editor!.registerCommand(
CLEAR_HIDE_MENU_TIMEOUT,
clearCommandHandler,
COMMAND_PRIORITY_EDITOR,
)
emit({
type: PROMPT_EDITOR_INSERT_QUICKLY,
instanceId: 'instance-1',
@@ -183,9 +175,6 @@ describe('UpdateBlock', () => {
await waitFor(() => {
expect(readEditorText(editor!)).toBe('/')
})
expect(clearCommandHandler).toHaveBeenCalledTimes(1)
unregister()
})
it('should ignore quick insert event when instance id does not match', async () => {

View File

@@ -23,6 +23,8 @@ import {
$createTextNode,
$getRoot,
$setSelection,
BLUR_COMMAND,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import * as React from 'react'
@@ -631,4 +633,180 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
// With a single option group, the only divider should be the workflow-var/options separator.
expect(document.querySelectorAll('.bg-divider-subtle')).toHaveLength(1)
})
describe('blur/focus menu visibility', () => {
it('hides the menu after a 200ms delay when blur command is dispatched', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('restores menu visibility when focus command is dispatched after blur hides it', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
act(() => {
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
})
vi.useRealTimers()
await setEditorText(editor, '{', true)
await waitFor(() => {
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
})
})
it('cancels the blur timer when focus arrives before the 200ms timeout', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
act(() => {
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useRealTimers()
})
it('cancels a pending blur timer when a subsequent blur targets var-search-input', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
})
const varInput = document.createElement('input')
varInput.classList.add('var-search-input')
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: varInput }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useRealTimers()
})
it('does not hide the menu when blur target is var-search-input', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useFakeTimers()
const target = document.createElement('input')
target.classList.add('var-search-input')
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: target }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
vi.useRealTimers()
})
})
})

View File

@@ -21,11 +21,19 @@ import {
} from '@floating-ui/react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { KEY_ESCAPE_COMMAND } from 'lexical'
import { mergeRegister } from '@lexical/utils'
import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import {
Fragment,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import ReactDOM from 'react-dom'
@@ -87,6 +95,46 @@ const ComponentPicker = ({
})
const [queryString, setQueryString] = useState<string | null>(null)
const [blurHidden, setBlurHidden] = useState(false)
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearBlurTimer = useCallback(() => {
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current)
blurTimerRef.current = null
}
}, [])
useEffect(() => {
const unregister = mergeRegister(
editor.registerCommand(
BLUR_COMMAND,
(event) => {
clearBlurTimer()
const target = event?.relatedTarget as HTMLElement
if (!target?.classList?.contains('var-search-input'))
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
return false
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
FOCUS_COMMAND,
() => {
clearBlurTimer()
setBlurHidden(false)
return false
},
COMMAND_PRIORITY_EDITOR,
),
)
return () => {
if (blurTimerRef.current)
clearTimeout(blurTimerRef.current)
unregister()
}
}, [editor, clearBlurTimer])
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
@@ -159,6 +207,8 @@ const ComponentPicker = ({
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
if (blurHidden)
return null
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
return null
@@ -240,7 +290,7 @@ const ComponentPicker = ({
}
</>
)
}, [allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
return (
<LexicalTypeaheadMenuPlugin

View File

@@ -5,10 +5,8 @@ import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import { useEffect, useRef } from 'react'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
import { useEffect } from 'react'
type OnBlurBlockProps = {
onBlur?: () => void
@@ -20,35 +18,13 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({
}) => {
const [editor] = useLexicalComposerContext()
const ref = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const clearHideMenuTimeout = () => {
if (ref.current) {
clearTimeout(ref.current)
ref.current = null
}
}
const unregister = mergeRegister(
editor.registerCommand(
CLEAR_HIDE_MENU_TIMEOUT,
() => {
clearHideMenuTimeout()
return true
},
COMMAND_PRIORITY_EDITOR,
),
return mergeRegister(
editor.registerCommand(
BLUR_COMMAND,
(event) => {
// Check if the clicked target element is var-search-input
const target = event?.relatedTarget as HTMLElement
if (!target?.classList?.contains('var-search-input')) {
clearHideMenuTimeout()
ref.current = setTimeout(() => {
editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' }))
}, 200)
if (onBlur)
onBlur()
}
@@ -66,11 +42,6 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({
COMMAND_PRIORITY_EDITOR,
),
)
return () => {
clearHideMenuTimeout()
unregister()
}
}, [editor, onBlur, onFocus])
return null

View File

@@ -3,7 +3,6 @@ import { $insertNodes } from 'lexical'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { textToEditorState } from '../utils'
import { CustomTextNode } from './custom-text/node'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER'
export const PROMPT_EDITOR_INSERT_QUICKLY = 'PROMPT_EDITOR_INSERT_QUICKLY'
@@ -30,8 +29,6 @@ const UpdateBlock = ({
editor.update(() => {
const textNode = new CustomTextNode('/')
$insertNodes([textNode])
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
}
})

View File

@@ -9,7 +9,6 @@ import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
CLEAR_HIDE_MENU_TIMEOUT,
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP,
@@ -134,7 +133,6 @@ describe('WorkflowVariableBlock', () => {
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
const result = insertHandler(['node-1', 'answer'])
expect(mockDispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined)
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'answer'],
workflowNodesMap,

View File

@@ -18,7 +18,6 @@ import {
export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND')
export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND')
export const CLEAR_HIDE_MENU_TIMEOUT = createCommand('CLEAR_HIDE_MENU_TIMEOUT')
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
export type WorkflowVariableBlockProps = {
@@ -49,7 +48,6 @@ const WorkflowVariableBlock = memo(({
editor.registerCommand(
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => {
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
$insertNodes([workflowVariableBlockNode])

View File

@@ -0,0 +1,77 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Slider from '../index'
describe('Slider Component', () => {
it('should render with correct default ARIA limits and current value', () => {
render(<Slider value={50} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should apply custom min, max, and step values', () => {
render(<Slider value={10} min={5} max={20} step={5} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '5')
expect(slider).toHaveAttribute('aria-valuemax', '20')
expect(slider).toHaveAttribute('aria-valuenow', '10')
})
it('should default to 0 if the value prop is NaN', () => {
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuenow', '0')
})
it('should call onChange when arrow keys are pressed', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Slider value={20} onChange={onChange} />)
const slider = screen.getByRole('slider')
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(21, 0)
})
it('should not trigger onChange when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Slider value={20} onChange={onChange} disabled />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-disabled', 'true')
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onChange).not.toHaveBeenCalled()
})
it('should apply custom class names', () => {
render(
<Slider value={10} onChange={vi.fn()} className="outer-test" thumbClassName="thumb-test" />,
)
const sliderWrapper = screen.getByRole('slider').closest('.outer-test')
expect(sliderWrapper).toBeInTheDocument()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveClass('thumb-test')
})
})

View File

@@ -0,0 +1,635 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import Slider from '.'
const meta = {
title: 'Base/Data Entry/Slider',
component: Slider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current slider value',
},
min: {
control: 'number',
description: 'Minimum value (default: 0)',
},
max: {
control: 'number',
description: 'Maximum value (default: 100)',
},
step: {
control: 'number',
description: 'Step increment (default: 1)',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
},
args: {
onChange: (value) => {
console.log('Slider value:', value)
},
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SliderDemo = (args: any) => {
const [value, setValue] = useState(args.value || 50)
return (
<div style={{ width: '400px' }}>
<Slider
{...args}
value={value}
onChange={(v) => {
setValue(v)
console.log('Slider value:', v)
}}
/>
<div className="mt-4 text-center text-sm text-gray-600">
Value:
{' '}
<span className="text-lg font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}
// With custom range
export const CustomRange: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 25,
min: 0,
max: 50,
step: 1,
disabled: false,
},
}
// With step increment
export const WithStepIncrement: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 10,
disabled: false,
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 2.5,
min: 0,
max: 5,
step: 0.5,
disabled: false,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 75,
min: 0,
max: 100,
step: 1,
disabled: true,
},
}
// Real-world example - Volume control
const VolumeControlDemo = () => {
const [volume, setVolume] = useState(70)
const getVolumeIcon = (vol: number) => {
if (vol === 0)
return '🔇'
if (vol < 33)
return '🔈'
if (vol < 66)
return '🔉'
return '🔊'
}
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Volume Control</h3>
<span className="text-2xl">{getVolumeIcon(volume)}</span>
</div>
<Slider
value={volume}
min={0}
max={100}
step={1}
onChange={setVolume}
/>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>Mute</span>
<span className="text-lg font-semibold">
{volume}
%
</span>
<span>Max</span>
</div>
</div>
)
}
export const VolumeControl: Story = {
render: () => <VolumeControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Brightness control
const BrightnessControlDemo = () => {
const [brightness, setBrightness] = useState(80)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Screen Brightness</h3>
<span className="text-2xl"></span>
</div>
<Slider
value={brightness}
min={0}
max={100}
step={5}
onChange={setBrightness}
/>
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
<div className="text-sm text-gray-700">
Preview at
{' '}
{brightness}
% brightness
</div>
</div>
</div>
)
}
export const BrightnessControl: Story = {
render: () => <BrightnessControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Price range filter
const PriceRangeFilterDemo = () => {
const [maxPrice, setMaxPrice] = useState(500)
const minPrice = 0
const products = [
{ name: 'Product A', price: 150 },
{ name: 'Product B', price: 350 },
{ name: 'Product C', price: 600 },
{ name: 'Product D', price: 250 },
{ name: 'Product E', price: 450 },
]
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
<div className="mb-2">
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
<span>Maximum Price</span>
<span className="font-semibold text-gray-900">
$
{maxPrice}
</span>
</div>
<Slider
value={maxPrice}
min={0}
max={1000}
step={50}
onChange={setMaxPrice}
/>
</div>
<div className="mt-6">
<div className="mb-3 text-sm font-medium text-gray-700">
Showing
{' '}
{filteredProducts.length}
{' '}
of
{' '}
{products.length}
{' '}
products
</div>
<div className="space-y-2">
{filteredProducts.map(product => (
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<span className="text-sm">{product.name}</span>
<span className="font-semibold text-gray-900">
$
{product.price}
</span>
</div>
))}
</div>
</div>
</div>
)
}
export const PriceRangeFilter: Story = {
render: () => <PriceRangeFilterDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Temperature selector
const TemperatureSelectorDemo = () => {
const [temperature, setTemperature] = useState(22)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
<div className="mb-6">
<Slider
value={temperature}
min={16}
max={30}
step={0.5}
onChange={setTemperature}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-blue-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Celsius</div>
<div className="text-3xl font-bold text-blue-600">
{temperature}
°C
</div>
</div>
<div className="rounded-lg bg-orange-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
<div className="text-3xl font-bold text-orange-600">
{fahrenheit}
°F
</div>
</div>
</div>
<div className="mt-4 text-center text-xs text-gray-500">
{temperature < 18 && '🥶 Too cold'}
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
{temperature > 24 && '🥵 Too warm'}
</div>
</div>
)
}
export const TemperatureSelector: Story = {
render: () => <TemperatureSelectorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Progress/completion slider
const ProgressSliderDemo = () => {
const [progress, setProgress] = useState(65)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
<Slider
value={progress}
min={0}
max={100}
step={5}
onChange={setProgress}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm text-gray-600">Progress</span>
<span className="text-lg font-bold text-blue-600">
{progress}
%
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
<span className="text-xs text-gray-500">25%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
<span className="text-xs text-gray-500">50%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
<span className="text-xs text-gray-500">75%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
<span className="text-xs text-gray-500">100%</span>
</div>
</div>
</div>
</div>
)
}
export const ProgressSlider: Story = {
render: () => <ProgressSliderDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Zoom control
const ZoomControlDemo = () => {
const [zoom, setZoom] = useState(100)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
<div className="flex items-center gap-4">
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.max(50, zoom - 10))}
>
-
</button>
<div className="flex-1">
<Slider
value={zoom}
min={50}
max={200}
step={10}
onChange={setZoom}
/>
</div>
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.min(200, zoom + 10))}
>
+
</button>
</div>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>50%</span>
<span className="text-lg font-semibold">
{zoom}
%
</span>
<span>200%</span>
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
<div className="text-sm">Preview content</div>
</div>
</div>
)
}
export const ZoomControl: Story = {
render: () => <ZoomControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - AI model parameters
const AIModelParametersDemo = () => {
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [topP, setTopP] = useState(0.9)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
<div className="space-y-6">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Temperature</label>
<span className="text-sm font-semibold">{temperature}</span>
</div>
<Slider
value={temperature}
min={0}
max={2}
step={0.1}
onChange={setTemperature}
/>
<p className="mt-1 text-xs text-gray-500">
Controls randomness. Lower is more focused, higher is more creative.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
<span className="text-sm font-semibold">{maxTokens}</span>
</div>
<Slider
value={maxTokens}
min={100}
max={4000}
step={100}
onChange={setMaxTokens}
/>
<p className="mt-1 text-xs text-gray-500">
Maximum length of generated response.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Top P</label>
<span className="text-sm font-semibold">{topP}</span>
</div>
<Slider
value={topP}
min={0}
max={1}
step={0.05}
onChange={setTopP}
/>
<p className="mt-1 text-xs text-gray-500">
Nucleus sampling threshold.
</p>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
<div>
<strong>Temperature:</strong>
{' '}
{temperature}
</div>
<div>
<strong>Max Tokens:</strong>
{' '}
{maxTokens}
</div>
<div>
<strong>Top P:</strong>
{' '}
{topP}
</div>
</div>
</div>
)
}
export const AIModelParameters: Story = {
render: () => <AIModelParametersDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Image quality selector
const ImageQualitySelectorDemo = () => {
const [quality, setQuality] = useState(80)
const getQualityLabel = (q: number) => {
if (q < 50)
return 'Low'
if (q < 70)
return 'Medium'
if (q < 90)
return 'High'
return 'Maximum'
}
const estimatedSize = Math.round((quality / 100) * 5)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
<Slider
value={quality}
min={10}
max={100}
step={10}
onChange={setQuality}
/>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">Quality</div>
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
<div className="text-xs text-gray-500">
{quality}
%
</div>
</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">File Size</div>
<div className="text-lg font-semibold">
~
{estimatedSize}
{' '}
MB
</div>
<div className="text-xs text-gray-500">Estimated</div>
</div>
</div>
</div>
)
}
export const ImageQualitySelector: Story = {
render: () => <ImageQualitySelectorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Multiple sliders
const MultipleSlidersDemo = () => {
const [red, setRed] = useState(128)
const [green, setGreen] = useState(128)
const [blue, setBlue] = useState(128)
const rgbColor = `rgb(${red}, ${green}, ${blue})`
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-red-600">Red</label>
<span className="text-sm font-semibold">{red}</span>
</div>
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-green-600">Green</label>
<span className="text-sm font-semibold">{green}</span>
</div>
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-blue-600">Blue</label>
<span className="text-sm font-semibold">{blue}</span>
</div>
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<div
className="h-24 w-24 rounded-lg border-2 border-gray-300"
style={{ backgroundColor: rgbColor }}
/>
<div className="text-right">
<div className="mb-1 text-xs text-gray-600">Color Value</div>
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
<div className="mt-1 font-mono text-xs text-gray-500">
#
{red.toString(16).padStart(2, '0')}
{green.toString(16).padStart(2, '0')}
{blue.toString(16).padStart(2, '0')}
</div>
</div>
</div>
</div>
)
}
export const MultipleSliders: Story = {
render: () => <MultipleSlidersDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}

View File

@@ -0,0 +1,43 @@
import ReactSlider from 'react-slider'
import { cn } from '@/utils/classnames'
import './style.css'
type ISliderProps = {
className?: string
thumbClassName?: string
trackClassName?: string
value: number
max?: number
min?: number
step?: number
disabled?: boolean
onChange: (value: number) => void
}
const Slider: React.FC<ISliderProps> = ({
className,
thumbClassName,
trackClassName,
max,
min,
step,
value,
disabled,
onChange,
}) => {
return (
<ReactSlider
disabled={disabled}
value={Number.isNaN(value) ? 0 : value}
min={min || 0}
max={max || 100}
step={step || 1}
className={cn('slider relative', className)}
thumbClassName={cn('absolute top-[-9px] h-5 w-2 rounded-[3px] border-[0.5px] border-components-slider-knob-border bg-components-slider-knob shadow-sm focus:outline-none', !disabled && 'cursor-pointer', thumbClassName)}
trackClassName={cn('h-0.5 rounded-full', 'slider-track', trackClassName)}
onChange={onChange}
/>
)
}
export default Slider

View File

@@ -0,0 +1,11 @@
.slider.disabled {
opacity: 0.6;
}
.slider-track {
background-color: var(--color-components-slider-range);
}
.slider-track-1 {
background-color: var(--color-components-slider-track);
}

View File

@@ -1,73 +0,0 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { Slider } from '../index'
describe('Slider', () => {
const getSliderInput = () => screen.getByLabelText('Value')
it('should render with correct default ARIA limits and current value', () => {
render(<Slider value={50} onValueChange={vi.fn()} aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should apply custom min, max, and step values', () => {
render(<Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toHaveAttribute('min', '5')
expect(slider).toHaveAttribute('max', '20')
expect(slider).toHaveAttribute('aria-valuenow', '10')
})
it('should clamp non-finite values to min', () => {
render(<Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />)
expect(getSliderInput()).toHaveAttribute('aria-valuenow', '5')
})
it('should call onValueChange when arrow keys are pressed', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<Slider value={20} onValueChange={onValueChange} aria-label="Value" />)
const slider = getSliderInput()
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenLastCalledWith(21, expect.anything())
})
it('should not trigger onValueChange when disabled', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toBeDisabled()
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onValueChange).not.toHaveBeenCalled()
})
it('should apply custom class names on root', () => {
const { container } = render(<Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />)
const sliderWrapper = container.querySelector('.outer-test')
expect(sliderWrapper).toBeInTheDocument()
})
})

View File

@@ -1,92 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type * as React from 'react'
import { useState } from 'react'
import { Slider } from '.'
const meta = {
title: 'Base UI/Data Entry/Slider',
component: Slider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Single-value horizontal slider built on Base UI.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
},
min: {
control: 'number',
},
max: {
control: 'number',
},
step: {
control: 'number',
},
disabled: {
control: 'boolean',
},
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
function SliderDemo({
value: initialValue = 50,
defaultValue: _defaultValue,
...args
}: React.ComponentProps<typeof Slider>) {
const [value, setValue] = useState(initialValue)
return (
<div className="w-[320px] space-y-3">
<Slider
{...args}
value={value}
onValueChange={setValue}
aria-label="Demo slider"
/>
<div className="text-center text-text-secondary system-sm-medium">
{value}
</div>
</div>
)
}
export const Default: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
},
}
export const Decimal: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 0.5,
min: 0,
max: 1,
step: 0.1,
},
}
export const Disabled: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 75,
min: 0,
max: 100,
step: 1,
disabled: true,
},
}

View File

@@ -1,100 +0,0 @@
'use client'
import { Slider as BaseSlider } from '@base-ui/react/slider'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type SliderRootProps = BaseSlider.Root.Props<number>
type SliderThumbProps = BaseSlider.Thumb.Props
type SliderBaseProps = Pick<
SliderRootProps,
'onValueChange' | 'min' | 'max' | 'step' | 'disabled' | 'name'
> & Pick<SliderThumbProps, 'aria-label' | 'aria-labelledby'> & {
className?: string
}
type ControlledSliderProps = SliderBaseProps & {
value: number
defaultValue?: never
}
type UncontrolledSliderProps = SliderBaseProps & {
value?: never
defaultValue?: number
}
export type SliderProps = ControlledSliderProps | UncontrolledSliderProps
const sliderRootClassName = 'group/slider relative inline-flex w-full data-[disabled]:opacity-30'
const sliderControlClassName = cn(
'relative flex h-5 w-full touch-none select-none items-center',
'data-[disabled]:cursor-not-allowed',
)
const sliderTrackClassName = cn(
'relative h-1 w-full overflow-hidden rounded-full',
'bg-[var(--slider-track,var(--color-components-slider-track))]',
)
const sliderIndicatorClassName = cn(
'h-full rounded-full',
'bg-[var(--slider-range,var(--color-components-slider-range))]',
)
const sliderThumbClassName = cn(
'block h-5 w-2 shrink-0 rounded-[3px] border-[0.5px]',
'border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]',
'bg-[var(--slider-knob,var(--color-components-slider-knob))] shadow-sm',
'transition-[background-color,border-color,box-shadow,opacity] motion-reduce:transition-none',
'hover:bg-[var(--slider-knob-hover,var(--color-components-slider-knob-hover))]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-slider-knob-border-hover focus-visible:ring-offset-0',
'active:shadow-md',
'group-data-[disabled]/slider:bg-[var(--slider-knob-disabled,var(--color-components-slider-knob-disabled))]',
'group-data-[disabled]/slider:border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]',
'group-data-[disabled]/slider:shadow-none',
)
const getSafeValue = (value: number | undefined, min: number) => {
if (value === undefined)
return undefined
return Number.isFinite(value) ? value : min
}
export function Slider({
value,
defaultValue,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
name,
className,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
}: SliderProps) {
return (
<BaseSlider.Root
value={getSafeValue(value, min)}
defaultValue={getSafeValue(defaultValue, min)}
onValueChange={onValueChange}
min={min}
max={max}
step={step}
disabled={disabled}
name={name}
thumbAlignment="edge"
className={cn(sliderRootClassName, className)}
>
<BaseSlider.Control className={sliderControlClassName}>
<BaseSlider.Track className={sliderTrackClassName}>
<BaseSlider.Indicator className={sliderIndicatorClassName} />
</BaseSlider.Track>
<BaseSlider.Thumb
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
className={sliderThumbClassName}
/>
</BaseSlider.Control>
</BaseSlider.Root>
)
}

View File

@@ -1,45 +0,0 @@
import { render } from '@testing-library/react'
import PartnerStackCookieRecorder from '../cookie-recorder'
let isCloudEdition = true
const saveOrUpdate = vi.fn()
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return isCloudEdition
},
}))
vi.mock('../use-ps-info', () => ({
default: () => ({
saveOrUpdate,
}),
}))
describe('PartnerStackCookieRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
isCloudEdition = true
})
it('should call saveOrUpdate once on mount when running in cloud edition', () => {
render(<PartnerStackCookieRecorder />)
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
})
it('should not call saveOrUpdate when not running in cloud edition', () => {
isCloudEdition = false
render(<PartnerStackCookieRecorder />)
expect(saveOrUpdate).not.toHaveBeenCalled()
})
it('should render null', () => {
const { container } = render(<PartnerStackCookieRecorder />)
expect(container.innerHTML).toBe('')
})
})

View File

@@ -1,19 +0,0 @@
'use client'
import { useEffect } from 'react'
import { IS_CLOUD_EDITION } from '@/config'
import usePSInfo from './use-ps-info'
const PartnerStackCookieRecorder = () => {
const { saveOrUpdate } = usePSInfo()
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
saveOrUpdate()
}, [])
return null
}
export default PartnerStackCookieRecorder

View File

@@ -24,7 +24,7 @@ const usePSInfo = () => {
}] = useBoolean(false)
const { mutateAsync } = useBindPartnerStackInfo()
// Save to top domain. cloud.dify.ai => .dify.ai
const domain = globalThis.location?.hostname.replace('cloud', '')
const domain = globalThis.location.hostname.replace('cloud', '')
const saveOrUpdate = useCallback(() => {
if (!psPartnerKey || !psClickId)
@@ -39,7 +39,7 @@ const usePSInfo = () => {
path: '/',
domain,
})
}, [psPartnerKey, psClickId, isPSChanged, domain])
}, [psPartnerKey, psClickId, isPSChanged])
const bind = useCallback(async () => {
if (psPartnerKey && psClickId && !hasBind) {
@@ -59,7 +59,7 @@ const usePSInfo = () => {
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
setBind()
}
}, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync])
}, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind])
return {
psPartnerKey,
psClickId,

View File

@@ -264,7 +264,7 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: longUrl } })
await userEvent.type(input, longUrl)
expect(input).toHaveValue(longUrl)
})
@@ -275,7 +275,7 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: unicodeUrl } })
await userEvent.type(input, unicodeUrl)
expect(input).toHaveValue(unicodeUrl)
})
@@ -285,7 +285,7 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://rapid.com' } })
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
expect(input).toHaveValue('https://rapid.com')
})
@@ -297,7 +297,7 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://enter.com' } })
await userEvent.type(input, 'https://enter.com')
// Focus button and press enter
const button = screen.getByRole('button', { name: /run/i })

View File

@@ -157,7 +157,7 @@ describe('useDatasetCardState', () => {
expect(result.current.modalState.showRenameModal).toBe(false)
})
it('should close confirm delete modal when closeConfirmDelete is called', async () => {
it('should close confirm delete modal when closeConfirmDelete is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
@@ -168,7 +168,7 @@ describe('useDatasetCardState', () => {
result.current.detectIsUsedByApp()
})
await waitFor(() => {
waitFor(() => {
expect(result.current.modalState.showConfirmDelete).toBe(true)
})

View File

@@ -14,8 +14,6 @@ describe('IndexMethod', () => {
vi.clearAllMocks()
})
const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords')
describe('Rendering', () => {
it('should render without crashing', () => {
render(<IndexMethod {...defaultProps} />)
@@ -125,7 +123,8 @@ describe('IndexMethod', () => {
describe('KeywordNumber', () => {
it('should render KeywordNumber component inside Economy option', () => {
render(<IndexMethod {...defaultProps} />)
expect(getKeywordSlider()).toBeInTheDocument()
// KeywordNumber has a slider
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should pass keywordNumber to KeywordNumber component', () => {

View File

@@ -11,8 +11,6 @@ describe('KeyWordNumber', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords')
describe('Rendering', () => {
it('should render without crashing', () => {
render(<KeyWordNumber {...defaultProps} />)
@@ -33,7 +31,8 @@ describe('KeyWordNumber', () => {
it('should render slider', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(getSlider()).toBeInTheDocument()
// Slider has a slider role
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should render input number field', () => {
@@ -62,7 +61,7 @@ describe('KeyWordNumber', () => {
it('should pass correct value to slider', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuenow', '30')
})
})
@@ -72,7 +71,8 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
// Verify slider is rendered and interactive
expect(slider).toBeInTheDocument()
expect(slider).not.toBeDisabled()
})
@@ -109,14 +109,14 @@ describe('KeyWordNumber', () => {
describe('Slider Configuration', () => {
it('should have max value of 50', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = getSlider()
expect(slider).toHaveAttribute('max', '50')
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '50')
})
it('should have min value of 0', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = getSlider()
expect(slider).toHaveAttribute('min', '0')
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
})
})
@@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
describe('Accessibility', () => {
it('should have accessible slider', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = getSlider()
const slider = screen.getByRole('slider')
expect(slider).toBeInTheDocument()
})

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'
import Tooltip from '@/app/components/base/tooltip'
import {
NumberField,
@@ -10,7 +11,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import { Slider } from '@/app/components/base/ui/slider'
const MIN_KEYWORD_NUMBER = 0
const MAX_KEYWORD_NUMBER = 50
@@ -47,8 +47,7 @@ const KeyWordNumber = ({
value={keywordNumber}
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
onValueChange={onKeywordNumberChange}
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
onChange={onKeywordNumberChange}
/>
<NumberField
className="w-12 shrink-0"

View File

@@ -11,9 +11,9 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/ui/slider', () => ({
Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => (
<button onClick={() => onValueChange(2)} data-testid="slider-btn">Slide 2</button>
vi.mock('@/app/components/base/slider', () => ({
default: ({ onChange }: { onChange: (v: number) => void }) => (
<button onClick={() => onChange(2)} data-testid="slider-btn">Slide 2</button>
),
}))

View File

@@ -7,10 +7,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Slider } from '@/app/components/base/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
@@ -78,7 +78,6 @@ function ParameterItem({
}
const renderValue = value ?? localValue ?? getDefaultValue()
const sliderLabel = parameterRule.label[language] || parameterRule.label.en_US
const handleInputChange = (newValue: ParameterValue) => {
setLocalValue(newValue)
@@ -171,8 +170,7 @@ function ParameterItem({
min={parameterRule.min}
max={parameterRule.max}
step={step}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
onChange={handleSlideChange}
/>
)}
<input
@@ -199,8 +197,7 @@ function ParameterItem({
min={parameterRule.min}
max={parameterRule.max}
step={0.1}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
onChange={handleSlideChange}
/>
)}
<input
@@ -340,9 +337,9 @@ function ParameterItem({
}
<div
className="mr-0.5 truncate text-text-secondary system-xs-regular"
title={sliderLabel}
title={parameterRule.label[language] || parameterRule.label.en_US}
>
{sliderLabel}
{parameterRule.label[language] || parameterRule.label.en_US}
</div>
{
parameterRule.help && (

View File

@@ -101,7 +101,6 @@ const createHumanInput = (overrides: Partial<HumanInputFormData> = {}): HumanInp
describe('workflow-stream-handlers helpers', () => {
it('should update tracing, result text, and human input state', () => {
const parallelTrace = createTrace({
id: 'parallel-trace-1',
node_id: 'parallel-node',
execution_metadata: { parallel_id: 'parallel-1' },
details: [[]],
@@ -110,13 +109,11 @@ describe('workflow-stream-handlers helpers', () => {
let workflowProcessData = appendParallelStart(undefined, parallelTrace)
workflowProcessData = appendParallelNext(workflowProcessData, parallelTrace)
workflowProcessData = finishParallelTrace(workflowProcessData, createTrace({
id: 'parallel-trace-1',
node_id: 'parallel-node',
execution_metadata: { parallel_id: 'parallel-1' },
error: 'failed',
}))
workflowProcessData = upsertWorkflowNode(workflowProcessData, createTrace({
id: 'node-trace-1',
node_id: 'node-1',
execution_metadata: { parallel_id: 'parallel-2' },
}))!
@@ -163,129 +160,6 @@ describe('workflow-stream-handlers helpers', () => {
expect(nextProcess.tracing[0]?.details).toEqual([[], []])
})
it('should keep separate iteration and loop traces for repeated executions with different ids', () => {
const process = createWorkflowProcess()
process.tracing = [
createTrace({
id: 'iter-trace-1',
node_id: 'iter-1',
details: [[]],
}),
createTrace({
id: 'iter-trace-2',
node_id: 'iter-1',
details: [[]],
}),
createTrace({
id: 'loop-trace-1',
node_id: 'loop-1',
details: [[]],
}),
createTrace({
id: 'loop-trace-2',
node_id: 'loop-1',
details: [[]],
}),
]
const iterNextProcess = appendParallelNext(process, createTrace({
id: 'iter-trace-2',
node_id: 'iter-1',
}))
const iterFinishedProcess = finishParallelTrace(iterNextProcess, createTrace({
id: 'iter-trace-2',
node_id: 'iter-1',
status: NodeRunningStatus.Succeeded,
details: undefined,
}))
const loopNextProcess = appendParallelNext(iterFinishedProcess, createTrace({
id: 'loop-trace-2',
node_id: 'loop-1',
}))
const loopFinishedProcess = finishParallelTrace(loopNextProcess, createTrace({
id: 'loop-trace-2',
node_id: 'loop-1',
status: NodeRunningStatus.Succeeded,
details: undefined,
}))
expect(loopFinishedProcess.tracing[0]).toEqual(expect.objectContaining({
id: 'iter-trace-1',
details: [[]],
status: NodeRunningStatus.Running,
}))
expect(loopFinishedProcess.tracing[1]).toEqual(expect.objectContaining({
id: 'iter-trace-2',
details: [[], []],
status: NodeRunningStatus.Succeeded,
}))
expect(loopFinishedProcess.tracing[2]).toEqual(expect.objectContaining({
id: 'loop-trace-1',
details: [[]],
status: NodeRunningStatus.Running,
}))
expect(loopFinishedProcess.tracing[3]).toEqual(expect.objectContaining({
id: 'loop-trace-2',
details: [[], []],
status: NodeRunningStatus.Succeeded,
}))
})
it('should append a new top-level trace when the same node starts with a different execution id', () => {
const process = createWorkflowProcess()
process.tracing = [
createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
}),
]
const updatedProcess = upsertWorkflowNode(process, createTrace({
id: 'trace-2',
node_id: 'node-1',
}))!
expect(updatedProcess.tracing).toHaveLength(2)
expect(updatedProcess.tracing[1]).toEqual(expect.objectContaining({
id: 'trace-2',
node_id: 'node-1',
status: NodeRunningStatus.Running,
}))
})
it('should finish the matching top-level trace when the same node runs again with a new execution id', () => {
const process = createWorkflowProcess()
process.tracing = [
createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
}),
createTrace({
id: 'trace-2',
node_id: 'node-1',
status: NodeRunningStatus.Running,
}),
]
const updatedProcess = finishWorkflowNode(process, createTrace({
id: 'trace-2',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
}))!
expect(updatedProcess.tracing).toHaveLength(2)
expect(updatedProcess.tracing[0]).toEqual(expect.objectContaining({
id: 'trace-1',
status: NodeRunningStatus.Succeeded,
}))
expect(updatedProcess.tracing[1]).toEqual(expect.objectContaining({
id: 'trace-2',
status: NodeRunningStatus.Succeeded,
}))
})
it('should leave tracing unchanged when a parallel next event has no matching trace', () => {
const process = createWorkflowProcess()
process.tracing = [
@@ -297,7 +171,6 @@ describe('workflow-stream-handlers helpers', () => {
]
const nextProcess = appendParallelNext(process, createTrace({
id: 'trace-missing',
node_id: 'missing-node',
execution_metadata: { parallel_id: 'parallel-2' },
}))
@@ -355,7 +228,6 @@ describe('workflow-stream-handlers helpers', () => {
},
}))
const notFinished = finishParallelTrace(process, createTrace({
id: 'trace-missing',
node_id: 'missing',
execution_metadata: {
parallel_id: 'parallel-missing',
@@ -371,7 +243,6 @@ describe('workflow-stream-handlers helpers', () => {
loop_id: 'loop-1',
}))
const unmatchedFinish = finishWorkflowNode(process, createTrace({
id: 'trace-missing',
node_id: 'missing',
execution_metadata: {
parallel_id: 'missing',

View File

@@ -5,7 +5,6 @@ import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse }
import { produce } from 'immer'
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
import { sseGet } from '@/service/base'
type Notify = (payload: { type: 'error' | 'warning', message: string }) => void
@@ -50,15 +49,6 @@ const matchParallelTrace = (trace: WorkflowProcess['tracing'][number], data: Nod
|| trace.parallel_id === data.execution_metadata?.parallel_id)
}
const findParallelTraceIndex = (tracing: WorkflowProcess['tracing'], data: NodeTracing) => {
return tracing.findIndex((trace) => {
if (trace.id && data.id)
return trace.id === data.id
return matchParallelTrace(trace, data)
})
}
const ensureParallelTraceDetails = (details?: NodeTracing['details']) => {
return details?.length ? details : [[]]
}
@@ -78,8 +68,7 @@ const appendParallelStart = (current: WorkflowProcess | undefined, data: NodeTra
const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTracing) => {
return updateWorkflowProcess(current, (draft) => {
draft.expand = true
const traceIndex = findParallelTraceIndex(draft.tracing, data)
const trace = draft.tracing[traceIndex]
const trace = draft.tracing.find(item => matchParallelTrace(item, data))
if (!trace)
return
@@ -91,13 +80,10 @@ const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTrac
const finishParallelTrace = (current: WorkflowProcess | undefined, data: NodeTracing) => {
return updateWorkflowProcess(current, (draft) => {
draft.expand = true
const traceIndex = findParallelTraceIndex(draft.tracing, data)
const traceIndex = draft.tracing.findIndex(item => matchParallelTrace(item, data))
if (traceIndex > -1) {
const currentTrace = draft.tracing[traceIndex]
draft.tracing[traceIndex] = {
...currentTrace,
...data,
details: data.details ?? currentTrace.details,
expand: !!data.error,
}
}
@@ -110,22 +96,17 @@ const upsertWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac
return updateWorkflowProcess(current, (draft) => {
draft.expand = true
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
const nextTrace = {
...data,
status: NodeRunningStatus.Running,
expand: true,
}
upsertTopLevelTracingNodeOnStart(draft.tracing, nextTrace)
})
}
const findWorkflowNodeTraceIndex = (tracing: WorkflowProcess['tracing'], data: NodeTracing) => {
return tracing.findIndex((trace) => {
if (trace.id && data.id)
return trace.id === data.id
return matchParallelTrace(trace, data)
if (currentIndex > -1)
draft.tracing[currentIndex] = nextTrace
else
draft.tracing.push(nextTrace)
})
}
@@ -134,7 +115,7 @@ const finishWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac
return current
return updateWorkflowProcess(current, (draft) => {
const currentIndex = findWorkflowNodeTraceIndex(draft.tracing, data)
const currentIndex = draft.tracing.findIndex(trace => matchParallelTrace(trace, data))
if (currentIndex > -1) {
draft.tracing[currentIndex] = {
...(draft.tracing[currentIndex].extras

View File

@@ -109,13 +109,13 @@ describe('useWorkflowAgentLog', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', execution_metadata: {} }],
tracing: [{ node_id: 'n1', execution_metadata: {} }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1' },
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
@@ -128,7 +128,6 @@ describe('useWorkflowAgentLog', () => {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
id: 'trace-1',
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
}],
@@ -137,7 +136,7 @@ describe('useWorkflowAgentLog', () => {
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm2' },
data: { node_id: 'n1', message_id: 'm2' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
@@ -148,7 +147,6 @@ describe('useWorkflowAgentLog', () => {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
id: 'trace-1',
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
}],
@@ -157,7 +155,7 @@ describe('useWorkflowAgentLog', () => {
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1', text: 'new' },
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
} as unknown as AgentLogResponse)
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
@@ -169,39 +167,17 @@ describe('useWorkflowAgentLog', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1' }],
tracing: [{ node_id: 'n1' }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1' },
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
})
it('should attach the log to the matching execution id when a node runs multiple times', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ id: 'trace-1', node_id: 'n1', execution_metadata: {} },
{ id: 'trace-2', node_id: 'n1', execution_metadata: {} },
],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', node_execution_id: 'trace-2', message_id: 'm2' },
} as AgentLogResponse)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].execution_metadata!.agent_log).toBeUndefined()
expect(tracing[1].execution_metadata!.agent_log).toHaveLength(1)
expect(tracing[1].execution_metadata!.agent_log![0].message_id).toBe('m2')
})
})
describe('useWorkflowNodeHumanInputFormFilled', () => {

View File

@@ -109,7 +109,7 @@ describe('useWorkflowNodeStarted', () => {
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { id: 'trace-n1', node_id: 'n1' } } as NodeStartedResponse,
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
@@ -138,7 +138,7 @@ describe('useWorkflowNodeStarted', () => {
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { id: 'trace-n2', node_id: 'n2' } } as NodeStartedResponse,
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
})
@@ -157,8 +157,8 @@ describe('useWorkflowNodeStarted', () => {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
@@ -166,7 +166,7 @@ describe('useWorkflowNodeStarted', () => {
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { id: 'trace-1', node_id: 'n1' } } as NodeStartedResponse,
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
@@ -175,32 +175,6 @@ describe('useWorkflowNodeStarted', () => {
expect(tracing).toHaveLength(2)
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
})
it('should append a new tracing entry when the same node starts a new execution id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { id: 'trace-2', node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(3)
expect(tracing[2].id).toBe('trace-2')
expect(tracing[2].node_id).toBe('n1')
expect(tracing[2].status).toBe(NodeRunningStatus.Running)
})
})
describe('useWorkflowNodeIterationStarted', () => {

View File

@@ -14,7 +14,7 @@ export const useWorkflowAgentLog = () => {
} = workflowStore.getState()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const currentIndex = draft.tracing!.findIndex(item => item.id === data.node_execution_id)
const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentIndex > -1) {
const current = draft.tracing![currentIndex]

View File

@@ -33,8 +33,8 @@ export const useWorkflowNodeStarted = () => {
transform,
} = store.getState()
const nodes = getNodes()
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.id === data.id)
if (currentIndex !== undefined && currentIndex > -1) {
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.node_id === data.node_id)
if (currentIndex && currentIndex > -1) {
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.tracing![currentIndex] = {
...data,

View File

@@ -145,7 +145,7 @@ describe('AgentStrategy', () => {
/>,
)
expect(screen.getByLabelText('Count')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})

View File

@@ -9,6 +9,7 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import ListEmpty from '@/app/components/base/list-empty'
import Slider from '@/app/components/base/slider'
import {
NumberField,
NumberFieldControls,
@@ -17,7 +18,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import { Slider } from '@/app/components/base/ui/slider'
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
@@ -147,11 +147,10 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
<div className="flex w-[200px] items-center gap-3">
<Slider
value={value}
onValueChange={onChange}
onChange={onChange}
className="w-full"
min={def.min}
max={def.max}
aria-label={renderI18nObject(def.label)}
/>
<NumberField
value={value}

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import * as React from 'react'
import { useCallback } from 'react'
import { Slider } from '@/app/components/base/ui/slider'
import Slider from '@/app/components/base/slider'
export type InputNumberWithSliderProps = {
value: number
@@ -22,7 +22,7 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
onChange,
}) => {
const handleBlur = useCallback(() => {
if (value === undefined || value === null || Number.isNaN(value)) {
if (value === undefined || value === null) {
onChange(defaultValue)
return
}
@@ -57,9 +57,8 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
min={min}
max={max}
step={1}
onValueChange={onChange}
onChange={onChange}
disabled={readonly}
aria-label="Number input slider"
/>
</div>
)

View File

@@ -6,8 +6,8 @@ import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import { Slider } from '@/app/components/base/ui/slider'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { cn } from '@/utils/classnames'
import { MemoryRole } from '../../../types'
@@ -154,7 +154,7 @@ const MemoryConfig: FC<Props> = ({
size="md"
disabled={readonly}
/>
<div className="text-text-tertiary system-xs-medium-uppercase">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
<div className="system-xs-medium-uppercase text-text-tertiary">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
</div>
<div className="flex h-8 items-center space-x-2">
<Slider
@@ -163,9 +163,8 @@ const MemoryConfig: FC<Props> = ({
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onValueChange={handleWindowSizeChange}
onChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
aria-label={t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}
/>
<Input
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}

View File

@@ -3,8 +3,8 @@ import type {
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import { Slider } from '@/app/components/base/ui/slider'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { useRetryConfig } from './hooks'
import s from './style.module.css'
@@ -70,10 +70,9 @@ const RetryOnPanel = ({
<Slider
className="mr-3 w-[108px]"
value={retry_config?.max_retries || 3}
onValueChange={handleMaxRetriesChange}
onChange={handleMaxRetriesChange}
min={1}
max={10}
aria-label={t('nodes.common.retry.maxRetries', { ns: 'workflow' })}
/>
<Input
type="number"
@@ -92,10 +91,9 @@ const RetryOnPanel = ({
<Slider
className="mr-3 w-[108px]"
value={retry_config?.retry_interval || 1000}
onValueChange={handleRetryIntervalChange}
onChange={handleRetryIntervalChange}
min={100}
max={5000}
aria-label={t('nodes.common.retry.retryInterval', { ns: 'workflow' })}
/>
<Input
type="number"

View File

@@ -5,8 +5,8 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import { Slider } from '@/app/components/base/ui/slider'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { MAX_PARALLEL_LIMIT } from '@/config'
@@ -57,7 +57,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
title={t(`${i18nPrefix}.input`, { ns: 'workflow' })}
required
operations={(
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary system-2xs-medium-uppercase">Array</div>
<div className="system-2xs-medium-uppercase flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary">Array</div>
)}
>
<VarReferencePicker
@@ -76,7 +76,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
title={t(`${i18nPrefix}.output`, { ns: 'workflow' })}
required
operations={(
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary system-2xs-medium-uppercase">Array</div>
<div className="system-2xs-medium-uppercase flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary">Array</div>
)}
>
<VarReferencePicker
@@ -103,11 +103,10 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
<Input type="number" wrapperClassName="w-18 mr-4 " max={MAX_PARALLEL_LIMIT} min={MIN_ITERATION_PARALLEL_NUM} value={inputs.parallel_nums} onChange={(e) => { changeParallelNums(Number(e.target.value)) }} />
<Slider
value={inputs.parallel_nums}
onValueChange={changeParallelNums}
onChange={changeParallelNums}
max={MAX_PARALLEL_LIMIT}
min={MIN_ITERATION_PARALLEL_NUM}
className="mt-4 flex-1 shrink-0"
aria-label={t(`${i18nPrefix}.MaxParallelismTitle`, { ns: 'workflow' })}
className=" mt-4 flex-1 shrink-0"
/>
</div>

View File

@@ -39,7 +39,7 @@ describe('IndexMethod', () => {
fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } })
expect(onKeywordNumberChange).toHaveBeenCalledWith(7, expect.anything())
expect(onKeywordNumberChange).toHaveBeenCalledWith(7)
})
it('should disable keyword controls when readonly is enabled', () => {

View File

@@ -9,8 +9,8 @@ import {
HighQuality,
} from '@/app/components/base/icons/src/vender/knowledge'
import Input from '@/app/components/base/input'
import Slider from '@/app/components/base/slider'
import Tooltip from '@/app/components/base/tooltip'
import { Slider } from '@/app/components/base/ui/slider'
import { Field } from '@/app/components/workflow/nodes/_base/components/layout'
import { cn } from '@/utils/classnames'
import {
@@ -94,7 +94,7 @@ const IndexMethod = ({
>
<div className="flex items-center">
<div className="flex grow items-center">
<div className="truncate text-text-secondary system-xs-medium">
<div className="system-xs-medium truncate text-text-secondary">
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
</div>
<Tooltip
@@ -107,8 +107,7 @@ const IndexMethod = ({
disabled={readonly}
className="mr-3 w-24 shrink-0"
value={keywordNumber}
onValueChange={onKeywordNumberChange}
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
onChange={onKeywordNumberChange}
/>
<Input
disabled={readonly}

View File

@@ -93,11 +93,11 @@ describe('trigger-schedule components', () => {
const onChange = vi.fn()
render(<OnMinuteSelector value={15} onChange={onChange} />)
const slider = screen.getByLabelText('workflow.nodes.triggerSchedule.onMinute')
const slider = screen.getByRole('slider')
slider.focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith(16, expect.objectContaining({ activeThumbIndex: 0 }))
expect(onChange).toHaveBeenCalledWith(16, 0)
})
it('should keep at least one weekday selected', async () => {

View File

@@ -1,6 +1,6 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Slider } from '@/app/components/base/ui/slider'
import Slider from '@/app/components/base/slider'
type OnMinuteSelectorProps = {
value?: number
@@ -27,8 +27,7 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
min={0}
max={59}
step={1}
onValueChange={onChange}
aria-label={t('nodes.triggerSchedule.onMinute', { ns: 'workflow' })}
onChange={onChange}
/>
</div>
</div>

View File

@@ -42,7 +42,6 @@ import {
import { useHooksStore } from '../../hooks-store'
import { useWorkflowStore } from '../../store'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { upsertTopLevelTracingNodeOnStart } from '../../utils/top-level-tracing'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
@@ -509,13 +508,19 @@ export const useChat = (
}
},
onNodeStarted: ({ data }) => {
if (params.loop_id)
return
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess!.tracing!, {
...data,
status: NodeRunningStatus.Running,
})
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess!.tracing![currentIndex] = {
...data,
status: NodeRunningStatus.Running,
}
}
else {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
@@ -534,9 +539,6 @@ export const useChat = (
})
},
onNodeFinished: ({ data }) => {
if (params.loop_id)
return
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
@@ -552,7 +554,7 @@ export const useChat = (
}
},
onAgentLog: ({ data }) => {
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.node_execution_id)
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentNodeIndex > -1) {
const current = responseItem.workflowProcess!.tracing![currentNodeIndex]
@@ -778,7 +780,8 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const iterationIndex = tracing.findIndex(item => item.id === iterationFinishedData.id)!
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
if (iterationIndex > -1) {
tracing[iterationIndex] = {
...tracing[iterationIndex],
@@ -795,10 +798,22 @@ export const useChat = (
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
...nodeStartedData,
status: NodeRunningStatus.Running,
})
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
if (nodeStartedData.iteration_id)
return
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
}
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
@@ -809,9 +824,6 @@ export const useChat = (
if (nodeFinishedData.iteration_id)
return
if (nodeFinishedData.loop_id)
return
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.id === nodeFinishedData.id
@@ -839,7 +851,8 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const loopIndex = tracing.findIndex(item => item.id === loopFinishedData.id)!
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
if (loopIndex > -1) {
tracing[loopIndex] = {
...tracing[loopIndex],

View File

@@ -1,133 +0,0 @@
import type { NodeTracing } from '@/types/workflow'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import { upsertTopLevelTracingNodeOnStart } from './top-level-tracing'
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: 'llm' as NodeTracing['node_type'],
title: 'Node 1',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
elapsed_time: 0,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 0,
...overrides,
})
describe('upsertTopLevelTracingNodeOnStart', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should append a new top-level node when no matching trace exists', () => {
const tracing: NodeTracing[] = []
const startedNode = createTrace({
id: 'trace-2',
node_id: 'node-2',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
expect(updated).toBe(true)
expect(tracing).toEqual([startedNode])
})
it('should update an existing top-level node when the execution id matches', () => {
const tracing: NodeTracing[] = [
createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
}),
]
const startedNode = createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
expect(updated).toBe(true)
expect(tracing).toEqual([startedNode])
})
it('should append a new top-level node when the same node starts with a new execution id', () => {
const existingTrace = createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
})
const tracing: NodeTracing[] = [existingTrace]
const startedNode = createTrace({
id: 'trace-2',
node_id: 'node-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
expect(updated).toBe(true)
expect(tracing).toEqual([existingTrace, startedNode])
})
it('should ignore nested iteration node starts even when the node id matches a top-level trace', () => {
const existingTrace = createTrace({
id: 'top-level-trace',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
})
const tracing: NodeTracing[] = [existingTrace]
const nestedIterationTrace = createTrace({
id: 'iteration-trace',
node_id: 'node-1',
iteration_id: 'iteration-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedIterationTrace)
expect(updated).toBe(false)
expect(tracing).toEqual([existingTrace])
})
it('should ignore nested loop node starts even when the node id matches a top-level trace', () => {
const existingTrace = createTrace({
id: 'top-level-trace',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
})
const tracing: NodeTracing[] = [existingTrace]
const nestedLoopTrace = createTrace({
id: 'loop-trace',
node_id: 'node-1',
loop_id: 'loop-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedLoopTrace)
expect(updated).toBe(false)
expect(tracing).toEqual([existingTrace])
})
})

View File

@@ -1,22 +0,0 @@
import type { NodeTracing } from '@/types/workflow'
const isNestedTracingNode = (trace: Pick<NodeTracing, 'iteration_id' | 'loop_id'>) => {
return Boolean(trace.iteration_id || trace.loop_id)
}
export const upsertTopLevelTracingNodeOnStart = (
tracing: NodeTracing[],
startedNode: NodeTracing,
) => {
if (isNestedTracingNode(startedNode))
return false
const currentIndex = tracing.findIndex(item => item.id === startedNode.id)
if (currentIndex > -1)
// Started events are the authoritative snapshot for an execution; merging would retain stale client-side fields.
tracing[currentIndex] = startedNode
else
tracing.push(startedNode)
return true
}

View File

@@ -9,7 +9,6 @@ import { getLocaleOnServer } from '@/i18n-config/server'
import { ToastProvider } from './components/base/toast'
import { ToastHost } from './components/base/ui/toast'
import { TooltipProvider } from './components/base/ui/tooltip'
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
import { AgentationLoader } from './components/devtools/agentation-loader'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
@@ -68,7 +67,6 @@ const LocaleLayout = async ({
<TanstackQueryInitializer>
<I18nServerProvider>
<ToastHost timeout={5000} limit={3} />
<PartnerStackCookieRecorder />
<ToastProvider>
<GlobalPublicStoreProvider>
<TooltipProvider delay={300} closeDelay={200}>

View File

@@ -1,11 +1,18 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from '@/next/navigation'
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
import NormalForm from './normal-form'
import OneMoreStep from './one-more-step'
const SignIn = () => {
const searchParams = useSearchParams()
const step = searchParams.get('step')
const { saveOrUpdate } = usePSInfo()
useEffect(() => {
saveOrUpdate()
}, [])
if (step === 'next')
return <OneMoreStep />

View File

@@ -965,6 +965,11 @@
"count": 1
}
},
"app/components/app/configuration/dataset-config/params-config/weighted-score.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -2038,6 +2043,11 @@
"count": 3
}
},
"app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
}
},
"app/components/base/features/new-feature-panel/annotation-reply/type.ts": {
"erasable-syntax-only/enums": {
"count": 1
@@ -2768,7 +2778,7 @@
},
"app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx": {
"react-refresh/only-export-components": {
"count": 4
"count": 3
}
},
"app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx": {
@@ -2868,6 +2878,19 @@
"count": 3
}
},
"app/components/base/slider/index.stories.tsx": {
"no-console": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/slider/index.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/base/sort/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
@@ -7018,6 +7041,9 @@
}
},
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"unicorn/prefer-number-properties": {
"count": 1
}
@@ -7846,6 +7872,12 @@
"app/components/workflow/nodes/iteration/panel.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/workflow/nodes/iteration/use-config.ts": {
@@ -7874,6 +7906,9 @@
"app/components/workflow/nodes/knowledge-base/components/index-method.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/workflow/nodes/knowledge-base/components/option-card.tsx": {

View File

@@ -140,6 +140,7 @@
"react-multi-email": "1.0.25",
"react-papaparse": "4.4.0",
"react-pdf-highlighter": "8.0.0-rc.0",
"react-slider": "2.0.6",
"react-sortablejs": "6.1.4",
"react-syntax-highlighter": "15.6.6",
"react-textarea-autosize": "8.5.9",
@@ -201,6 +202,7 @@
"@types/qs": "6.15.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react-slider": "1.3.6",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-window": "1.8.8",
"@types/sortablejs": "1.15.9",

23
web/pnpm-lock.yaml generated
View File

@@ -307,6 +307,9 @@ importers:
react-pdf-highlighter:
specifier: 8.0.0-rc.0
version: 8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-slider:
specifier: 2.0.6
version: 2.0.6(react@19.2.4)
react-sortablejs:
specifier: 6.1.4
version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7)
@@ -485,6 +488,9 @@ importers:
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/react-slider':
specifier: 1.3.6
version: 1.3.6
'@types/react-syntax-highlighter':
specifier: 15.5.13
version: 15.5.13
@@ -3531,6 +3537,9 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-slider@1.3.6':
resolution: {integrity: sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==}
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
@@ -6876,6 +6885,11 @@ packages:
react-dom: ^19.2.4
webpack: ^5.59.0
react-slider@2.0.6:
resolution: {integrity: sha512-gJxG1HwmuMTJ+oWIRCmVWvgwotNCbByTwRkFZC6U4MBsHqJBmxwbYRJUmxy4Tke1ef8r9jfXjgkmY/uHOCEvbA==}
peerDependencies:
react: ^16 || ^17 || ^18
react-sortablejs@6.1.4:
resolution: {integrity: sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==}
peerDependencies:
@@ -10925,6 +10939,10 @@ snapshots:
dependencies:
'@types/react': 19.2.14
'@types/react-slider@1.3.6':
dependencies:
'@types/react': 19.2.14
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 19.2.14
@@ -14919,6 +14937,11 @@ snapshots:
webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)
webpack-sources: 3.3.4
react-slider@2.0.6(react@19.2.4):
dependencies:
prop-types: 15.8.1
react: 19.2.4
react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7):
dependencies:
'@types/sortablejs': 1.15.9